|
| 1 | +#!/usr/bin/env bash |
| 2 | +# guard-shared-stash.sh — PreToolUse guard: the stash stack is SHARED across worktrees. |
| 3 | +# Blocks Bash commands that push to / pop from / drop the shared stash stack, and lets |
| 4 | +# the read-only and SHA-pinned forms through. |
| 5 | +# |
| 6 | +# Why: `git stash` keeps its stack in refs/stash inside the COMMON .git directory. Every |
| 7 | +# linked worktree shares that one LIFO stack, so the per-task worktree isolation AGENTS.md |
| 8 | +# Prime Directive #11 mandates — and guard-main-checkout.sh enforces — does NOT extend to |
| 9 | +# stash. Two agents stashing in their own worktrees operate on the same stack: A's pop |
| 10 | +# restores whatever B pushed a moment earlier, and A's own changes stay on the stack for |
| 11 | +# B to take. |
| 12 | +# |
| 13 | +# Live incident, objectui#3430 (2026-08-06, ~03:56Z): a reverse-verification |
| 14 | +# `git stash push -- packages/fields/.../RecordPickerDialog.tsx` followed by |
| 15 | +# `git stash pop` dropped b52e3aa instead — another agent's WIP on claude/issue-5733-…, |
| 16 | +# two unrelated plugin-detail files. Both agents' in-flight work swapped places; a |
| 17 | +# `git add -A` on either side would have merged the other's changes into the wrong PR. |
| 18 | +# The failure mode is maximally confusing: pop reports SUCCESS, and someone else's files |
| 19 | +# simply appear in your git status. Recovery worked only because the dropped SHA was still |
| 20 | +# in scrollback — once the stack empties, refs/stash and logs/refs/stash are both gone and |
| 21 | +# `git reflog refs/stash` answers `fatal: ambiguous argument`; a `git gc` in between makes |
| 22 | +# the loss permanent. Reverse verification is routine here and stash is the handiest tool |
| 23 | +# for it, so the collision probability is not small. |
| 24 | +# |
| 25 | +# Ported from objectui's hook of the same name (objectui#3430 / objectui PR #3433), filed |
| 26 | +# for this repo as objectstack#5742. The verdict logic below is deliberately kept |
| 27 | +# case-for-case identical to objectui's so the two repos' guards cannot drift; only issue |
| 28 | +# references, example paths and the package name in the self-test are localised — the same |
| 29 | +# mirroring discipline guard-main-checkout-bash.sh already documents. |
| 30 | +# |
| 31 | +# Alternatives — no shared state, all of these work inside your own worktree: |
| 32 | +# 1. clean re-read git checkout origin/main -- <path> (restore: git checkout <branch> -- <path>) |
| 33 | +# 2. patch file git diff > /tmp/wip.patch && git checkout -- <paths> |
| 34 | +# git apply /tmp/wip.patch (git apply -R to undo again) |
| 35 | +# 3. temporary commit git commit -am wip (git reset --soft HEAD~1) |
| 36 | +# 4. a second worktree for the comparison checkout |
| 37 | +# |
| 38 | +# Allowed through, deliberately: |
| 39 | +# - `git stash list` / `git stash show` — read-only, they never mutate the stack. |
| 40 | +# - `git stash create` — writes a commit object and prints its object id WITHOUT |
| 41 | +# storing it in the ref namespace (git-stash(1)); the safe primitive underneath the |
| 42 | +# SHA-pinned workflow. |
| 43 | +# - `git stash apply <sha>` / `git stash store <sha>` — the recovery path used to repair |
| 44 | +# the incident above. An explicit hex object id ONLY: stash@{0} is a POSITION in the |
| 45 | +# shared stack and may be another agent's entry by the time your command runs. |
| 46 | +# |
| 47 | +# Deliberate exception (you know the stack is yours alone): OS_ALLOW_STASH=1. |
| 48 | +# |
| 49 | +# Exit-code contract, mirroring guard-main-checkout.sh: 0 = allow, 2 = block with the |
| 50 | +# reason on stderr. Anything this cannot parse fails OPEN — a guard that blocks work it |
| 51 | +# does not understand gets disabled, and then it guards nothing. |
| 52 | +# |
| 53 | +# Known boundary, stated so nobody has to rediscover it: the check reads the FIRST WORD of |
| 54 | +# each shell segment, so a wrapped invocation (bash -c '…', xargs, ssh host '…') is not |
| 55 | +# caught. That is the deliberate trade — the target is the reflexive `git stash push` an |
| 56 | +# agent reaches for mid-task, not a determined evader, and OS_ALLOW_STASH=1 already exists |
| 57 | +# for anyone who means it. Widening it to string-match anywhere in the command would block |
| 58 | +# every `grep "git stash"` run against this very file. |
| 59 | +# |
| 60 | +# Self-test (32 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh |
| 61 | + |
| 62 | +set -uo pipefail |
| 63 | + |
| 64 | +[ "${OS_ALLOW_STASH:-}" = "1" ] && exit 0 |
| 65 | + |
| 66 | +input="$(cat 2>/dev/null || true)" |
| 67 | +cmd="" |
| 68 | +if command -v jq >/dev/null 2>&1; then |
| 69 | + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" |
| 70 | +fi |
| 71 | +if [ -z "$cmd" ]; then |
| 72 | + # jq-less fallback: lift the JSON string value honouring backslash escapes (so an |
| 73 | + # embedded \" does not truncate the command), then unescape what matters for shell text. |
| 74 | + cmd="$(printf '%s' "$input" \ |
| 75 | + | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' \ |
| 76 | + | head -1 \ |
| 77 | + | sed 's/\\n/ /g; s/\\t/ /g; s/\\"/"/g; s/\\\\/\\/g')" |
| 78 | +fi |
| 79 | + |
| 80 | +[ -n "$cmd" ] || exit 0 |
| 81 | + |
| 82 | +# --- split the command into shell segments, honouring quotes --------------------------- |
| 83 | +# A separator inside '…' or "…" does NOT split, so writing *about* the ban is never caught |
| 84 | +# by the ban: `grep -n "cd x && git stash pop" AGENTS.md` stays one segment whose first |
| 85 | +# word is grep. (objectstack#4890's lesson — the PR writing a rule must not trip it.) |
| 86 | +segments=() |
| 87 | +split_segments() { |
| 88 | + local s="$1" seg="" q="" ch i n=${#1} |
| 89 | + for ((i = 0; i < n; i++)); do |
| 90 | + ch="${s:i:1}" |
| 91 | + if [ -n "$q" ]; then |
| 92 | + seg+="$ch" |
| 93 | + [ "$ch" = "$q" ] && q="" |
| 94 | + continue |
| 95 | + fi |
| 96 | + case "$ch" in |
| 97 | + "'" | '"') q="$ch" ; seg+="$ch" ;; |
| 98 | + ';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;; |
| 99 | + *) seg+="$ch" ;; |
| 100 | + esac |
| 101 | + done |
| 102 | + segments+=("$seg") |
| 103 | +} |
| 104 | + |
| 105 | +# --- verdict for one segment ----------------------------------------------------------- |
| 106 | +# returns 0 = fine, 1 = this segment mutates the shared stash stack. |
| 107 | +check_segment() { |
| 108 | + local seg="$1" |
| 109 | + local -a w=() |
| 110 | + read -r -a w <<<"$seg" |
| 111 | + local i=0 n=${#w[@]} |
| 112 | + [ "$n" -gt 0 ] || return 0 |
| 113 | + |
| 114 | + # leading FOO=bar environment assignments |
| 115 | + while [ "$i" -lt "$n" ]; do |
| 116 | + case "${w[$i]}" in |
| 117 | + [A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)) ;; |
| 118 | + *) break ;; |
| 119 | + esac |
| 120 | + done |
| 121 | + [ "$i" -lt "$n" ] || return 0 |
| 122 | + |
| 123 | + # /usr/bin/git -> git |
| 124 | + [ "${w[$i]##*/}" = "git" ] || return 0 |
| 125 | + i=$((i + 1)) |
| 126 | + |
| 127 | + # git's own global options, before the subcommand |
| 128 | + while [ "$i" -lt "$n" ]; do |
| 129 | + case "${w[$i]}" in |
| 130 | + -C | -c | --exec-path | --git-dir | --work-tree | --namespace) i=$((i + 2)) ;; |
| 131 | + -*) i=$((i + 1)) ;; |
| 132 | + *) break ;; |
| 133 | + esac |
| 134 | + done |
| 135 | + [ "$i" -lt "$n" ] || return 0 |
| 136 | + [ "${w[$i]}" = "stash" ] || return 0 |
| 137 | + i=$((i + 1)) |
| 138 | + |
| 139 | + local sub="${w[$i]:-}" |
| 140 | + case "$sub" in |
| 141 | + --help | -h) return 0 ;; # reading the manual is not stashing |
| 142 | + list | show) return 0 ;; # read-only against refs/stash |
| 143 | + create) return 0 ;; # makes an object, does NOT store it in the stack |
| 144 | + apply | store) |
| 145 | + # pinned to an explicit hex object id => this cannot pick up another agent's entry. |
| 146 | + local j |
| 147 | + for ((j = i + 1; j < n; j++)); do |
| 148 | + [[ "${w[$j]}" =~ ^[0-9a-fA-F]{7,40}$ ]] && return 0 |
| 149 | + done |
| 150 | + ;; |
| 151 | + esac |
| 152 | + return 1 |
| 153 | +} |
| 154 | + |
| 155 | +split_segments "$cmd" |
| 156 | +for seg in "${segments[@]}"; do |
| 157 | + check_segment "$seg" && continue |
| 158 | + offending="${seg#"${seg%%[![:space:]]*}"}" |
| 159 | + cat >&2 <<EOF |
| 160 | +⛔ Blocked: git stash uses ONE stack shared by every worktree of this repo. |
| 161 | + command: $offending |
| 162 | +
|
| 163 | +refs/stash lives in the COMMON .git directory, so the per-task worktree isolation this |
| 164 | +repo mandates (AGENTS.md Prime Directive #11) does NOT cover the stash stack. Another |
| 165 | +agent's pop takes YOUR entry and yours takes theirs — pop reports success and their files |
| 166 | +show up in your git status, which is why objectui#3430 swapped two agents' in-flight |
| 167 | +changes without an error. |
| 168 | +
|
| 169 | +Use instead — no shared state, all inside your own worktree: |
| 170 | + 1. clean re-read git checkout origin/main -- <path> |
| 171 | + git checkout <your-branch> -- <path> # put your version back |
| 172 | + 2. patch file git diff > /tmp/wip.patch && git checkout -- <paths> |
| 173 | + git apply /tmp/wip.patch # git apply -R to undo again |
| 174 | + 3. temporary commit git commit -am wip # git reset --soft HEAD~1 |
| 175 | + 4. a second worktree for the comparison checkout |
| 176 | +
|
| 177 | +Already allowed, no flag needed: |
| 178 | + git stash list | git stash show | git stash create |
| 179 | + git stash apply <sha> | git stash store <sha> # literal hex id, never stash@{N} |
| 180 | +
|
| 181 | +Deliberate exception (the stack really is yours alone): re-run with OS_ALLOW_STASH=1. |
| 182 | +EOF |
| 183 | + exit 2 |
| 184 | +done |
| 185 | + |
| 186 | +exit 0 |
0 commit comments