Skip to content

Commit 9f22801

Browse files
feat(agents): mirror objectui's guard-shared-stash hook + the shared-stash warning (#5742) (#6632)
`refs/stash` lives in the COMMON .git dir, so every linked worktree shares one LIFO stack: the per-task worktree isolation AGENTS.md Prime Directive #11 mandates does not cover the stash. objectui#3430 (2026-08-06) had two parallel dev agents pop each other's reverse-verification stashes; pop reported success and both changesets survived only as unreachable commits. objectui landed the guard after that incident; this repo had neither the hook nor the warning (its own guard-main-checkout-bash.sh header recorded the gap). This mirrors both sides: - `.claude/hooks/guard-shared-stash.sh` + its 32-case self-test, kept case-for-case identical to objectui's verdict logic so the two guards cannot drift; only issue refs, example paths and the package name are localised. Contract preserved: blocks push/pop/save/drop/clear/branch and `stash@{N}`, allows list/show/create and apply/store pinned to a literal hex object id, fails open on unparseable shapes, escape hatch `OS_ALLOW_STASH=1`. - Wired on the existing `Bash` PreToolUse matcher in `.claude/settings.json`, ahead of guard-main-checkout-bash.sh (objectui's order); that hook's stale "this repo has no such hook" note is corrected. - AGENTS.md: the warning + collision-free replacements next to the worktree rule, and one clause inside Prime Directive #11. - `.claude/agents/os-dev.md`: the reverse-verification section now prescribes `git checkout origin/main -- <path>` / patch file / temp commit and forbids bare `git stash` — the producer-side fix for the recipe that caused #3430. - CLAUDE.md: inlined as a fourth never-miss rule (same bar as the other three: missing it corrupts other agents' work). Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8599c21 commit 9f22801

7 files changed

Lines changed: 396 additions & 5 deletions

File tree

.claude/agents/os-dev.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,29 @@ all real:
206206
forcing the template; #4984 is the family origin — fixtures spelling
207207
rejected aliases kept the tests green while the rule was dead).
208208

209+
**⛔ Take the fix out with `git checkout`, a patch file or a temp commit — NEVER
210+
`git stash`.** The worktree isolates your files and your HEAD; it does **not**
211+
isolate `refs/stash`, which lives in the **common** `.git` and is one LIFO stack
212+
shared by every worktree of the repo. Reverse verification is what makes this
213+
bite: "stash the fix, re-run, restore" is the reflex move, so two agents doing it
214+
at the same time swap entries — one `pop` restores the *other's* changes into your
215+
worktree while yours stay on the stack, `pop` reports **success**, and a following
216+
`git add -A` commits their half-finished work into your PR (objectui#3430, two dev
217+
agents, both changesets recoverable only as unreachable commits). Use instead, all
218+
inside your own worktree:
219+
220+
```
221+
git checkout origin/main -- <path> # take the fix out; restore: git checkout <branch> -- <path>
222+
git diff > /tmp/wip.patch && git checkout -- <paths> # restore: git apply /tmp/wip.patch
223+
git commit -am wip # restore: git reset --soft HEAD~1
224+
```
225+
226+
`.claude/hooks/guard-shared-stash.sh` blocks the mutating forms on the `Bash`
227+
matcher (`push`/`pop`/`drop`/`clear`, and `stash@{N}` — a *position* in a stack you
228+
don't own); `git stash list`/`show`/`create` and `apply`/`store` pinned to a literal
229+
hex object id stay allowed. Escape hatch, when the stack really is yours alone:
230+
`OS_ALLOW_STASH=1`.
231+
209232
**Rejection-class cases assert the envelope, not the throw.** For any case whose
210233
point is that bad input is *refused*, the minimum assertion set is the error's
211234
**`code` AND `status`** (the ADR-0112 envelope). `expect(...).toThrow()` /

.claude/hooks/guard-main-checkout-bash.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
# Ported from objectui's hook of the same name (objectstack-ai/objectui#3452, filed there
1515
# as objectui#3435) — the logic below is deliberately kept case-for-case identical to it so
1616
# the two repos' guards cannot drift; only issue references, example paths and the package
17-
# name in the self-test are localised. objectui additionally runs guard-shared-stash.sh on
18-
# the same matcher; this repo has no such hook, so the Bash matcher is created here rather
19-
# than joined (that gap is tracked separately — see #5790's closing observation).
17+
# name in the self-test are localised. The Bash matcher this hook created is now shared
18+
# with guard-shared-stash.sh, mirrored here from objectui in the same way (#5742) — the
19+
# gap #5790's closing observation recorded is closed; both hooks run on this one matcher.
2020
#
2121
# The rule and the escape hatch are deliberately the SAME as guard-main-checkout.sh's:
2222
# OS_ALLOW_MAIN_EDITS=1. One rule, one hatch — a second variable would just be another
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
# Self-test for guard-shared-stash.sh — run it after touching that hook:
3+
#
4+
# .claude/hooks/guard-shared-stash.selftest.sh
5+
#
6+
# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and
7+
# asserts the block/allow verdict per command. Needs jq (to build payloads) and nothing
8+
# else: no install, no build, no network. Exit 0 = all cases hold.
9+
#
10+
# Mirrored from objectui's self-test of the same name (objectui#3430 / PR #3433) alongside
11+
# the hook itself; the case matrix is kept one-for-one so the two repos' guards cannot
12+
# drift, and only the example paths, worktree names and package name are localised.
13+
14+
set -uo pipefail
15+
16+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17+
hook="$here/guard-shared-stash.sh"
18+
pass=0
19+
fail=0
20+
21+
command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; }
22+
23+
# verdict <command> [env assignments…] -> prints "block" or "allow"
24+
verdict() {
25+
local cmd="$1"; shift
26+
local payload out rc
27+
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
28+
out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)"
29+
rc=$?
30+
case "$rc" in
31+
0) printf 'allow' ;;
32+
2) printf 'block' ;;
33+
*) printf 'exit%s' "$rc" ;;
34+
esac
35+
}
36+
37+
expect() { # expect <block|allow> <command> [env…]
38+
local want="$1" cmd="$2"; shift 2
39+
local got; got="$(verdict "$cmd" "$@")"
40+
if [ "$got" = "$want" ]; then
41+
pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$cmd"
42+
else
43+
fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$cmd"
44+
fi
45+
}
46+
47+
echo "== mutating forms must be blocked =="
48+
expect block 'git stash'
49+
expect block 'git stash push -- packages/spec/src/kernel/metadata-plugin.zod.ts'
50+
expect block 'git stash pop'
51+
expect block 'git stash save wip'
52+
expect block 'git stash drop'
53+
expect block 'git stash clear'
54+
expect block 'git stash branch recovered'
55+
expect block 'git stash pop > /dev/null'
56+
57+
echo "== positional stash@{N} is NOT SHA-pinned: still the shared stack =="
58+
expect block 'git stash pop stash@{0}'
59+
expect block 'git stash apply stash@{1}'
60+
61+
echo "== reached through separators, substitution and git -C =="
62+
expect block 'cd /home/user/objectstack && git stash pop'
63+
expect block 'git -C ../objectstack-issue-5742 stash pop'
64+
expect block 'out=$(git stash pop)'
65+
expect block 'git status && git stash push -m wip; pnpm test'
66+
67+
echo "== read-only and SHA-pinned forms are allowed =="
68+
expect allow 'git stash list'
69+
expect allow 'git stash show -p'
70+
expect allow 'git stash create'
71+
expect allow 'git stash --help'
72+
expect allow 'git stash apply abc1234'
73+
expect allow 'git stash apply --index deadbeefcafe1234'
74+
expect allow 'git stash store -m "WIP issue-5742" b52e3aa1234567'
75+
76+
echo "== unrelated commands are untouched =="
77+
expect allow 'pnpm --filter @objectstack/spec test'
78+
expect allow 'git status'
79+
expect allow 'git commit -am wip'
80+
expect allow 'git diff > /tmp/wip.patch && git checkout -- packages/spec'
81+
82+
echo "== writing ABOUT the ban must not trip the ban =="
83+
expect allow 'grep -n "git stash" AGENTS.md'
84+
expect allow 'grep -rn "cd x && git stash pop" .claude/'
85+
expect allow 'echo "never run git stash pop in a shared checkout"'
86+
expect allow 'git grep -n "git stash"'
87+
88+
echo "== escape hatch =="
89+
expect allow 'git stash pop' OS_ALLOW_STASH=1
90+
91+
echo "== payload with no command fails open =="
92+
if printf '%s' '{"tool_name":"Bash","tool_input":{}}' | "$hook" >/dev/null 2>&1; then
93+
pass=$((pass + 1)); printf ' ok allow (empty tool_input)\n'
94+
else
95+
fail=$((fail + 1)); printf ' FAIL empty tool_input should fail open\n'
96+
fi
97+
98+
echo "== jq-less fallback still parses the command =="
99+
nojq="$(mktemp -d)"
100+
for b in bash env cat sed head grep; do
101+
p="$(command -v "$b")" && ln -s "$p" "$nojq/$b"
102+
done
103+
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git stash pop"}}' \
104+
| PATH="$nojq" "$hook" >/dev/null 2>&1
105+
case "$?" in
106+
0) got_nojq=allow ;;
107+
2) got_nojq=block ;;
108+
*) got_nojq="exit$?" ;;
109+
esac
110+
if [ "$got_nojq" = block ]; then
111+
pass=$((pass + 1)); printf ' ok block (no jq on PATH)\n'
112+
else
113+
fail=$((fail + 1)); printf ' FAIL no-jq fallback got=%s\n' "$got_nojq"
114+
fi
115+
rm -rf "$nojq"
116+
117+
printf '\n%s passed, %s failed\n' "$pass" "$fail"
118+
[ "$fail" -eq 0 ]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
{
3636
"matcher": "Bash",
3737
"hooks": [
38+
{
39+
"type": "command",
40+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-shared-stash.sh\""
41+
},
3842
{
3943
"type": "command",
4044
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout-bash.sh\""

0 commit comments

Comments
 (0)