Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .claude/hooks/guard-shared-stash.selftest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Self-test for guard-shared-stash.sh — run it after touching that hook:
#
# .claude/hooks/guard-shared-stash.selftest.sh
#
# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and
# asserts the block/allow verdict per command. Needs jq (to build payloads) and nothing
# else: no install, no build, no network. Exit 0 = all cases hold.

set -uo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
hook="$here/guard-shared-stash.sh"
pass=0
fail=0

command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; }

# verdict <command> [env assignments…] -> prints "block" or "allow"
verdict() {
local cmd="$1"; shift
local payload out rc
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)"
rc=$?
case "$rc" in
0) printf 'allow' ;;
2) printf 'block' ;;
*) printf 'exit%s' "$rc" ;;
esac
}

expect() { # expect <block|allow> <command> [env…]
local want="$1" cmd="$2"; shift 2
local got; got="$(verdict "$cmd" "$@")"
if [ "$got" = "$want" ]; then
pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$cmd"
else
fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$cmd"
fi
}

echo "== mutating forms must be blocked =="
expect block 'git stash'
expect block 'git stash push -- packages/fields/src/RecordPickerDialog.tsx'
expect block 'git stash pop'
expect block 'git stash save wip'
expect block 'git stash drop'
expect block 'git stash clear'
expect block 'git stash branch recovered'
expect block 'git stash pop > /dev/null'

echo "== positional stash@{N} is NOT SHA-pinned: still the shared stack =="
expect block 'git stash pop stash@{0}'
expect block 'git stash apply stash@{1}'

echo "== reached through separators, substitution and git -C =="
expect block 'cd /home/user/objectui && git stash pop'
expect block 'git -C ../objectui-issue-3422 stash pop'
expect block 'out=$(git stash pop)'
expect block 'git status && git stash push -m wip; pnpm test'

echo "== read-only and SHA-pinned forms are allowed =="
expect allow 'git stash list'
expect allow 'git stash show -p'
expect allow 'git stash create'
expect allow 'git stash --help'
expect allow 'git stash apply abc1234'
expect allow 'git stash apply --index deadbeefcafe1234'
expect allow 'git stash store -m "WIP issue-3430" b52e3aa1234567'

echo "== unrelated commands are untouched =="
expect allow 'pnpm --filter @object-ui/app-shell test'
expect allow 'git status'
expect allow 'git commit -am wip'
expect allow 'git diff > /tmp/wip.patch && git checkout -- packages/fields'

echo "== writing ABOUT the ban must not trip the ban =="
expect allow 'grep -n "git stash" AGENTS.md'
expect allow 'grep -rn "cd x && git stash pop" .claude/'
expect allow 'echo "never run git stash pop in a shared checkout"'
expect allow 'git grep -n "git stash"'

echo "== escape hatch =="
expect allow 'git stash pop' OS_ALLOW_STASH=1

echo "== payload with no command fails open =="
if printf '%s' '{"tool_name":"Bash","tool_input":{}}' | "$hook" >/dev/null 2>&1; then
pass=$((pass + 1)); printf ' ok allow (empty tool_input)\n'
else
fail=$((fail + 1)); printf ' FAIL empty tool_input should fail open\n'
fi

echo "== jq-less fallback still parses the command =="
nojq="$(mktemp -d)"
for b in bash env cat sed head grep; do
p="$(command -v "$b")" && ln -s "$p" "$nojq/$b"
done
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git stash pop"}}' \
| PATH="$nojq" "$hook" >/dev/null 2>&1
case "$?" in
0) got_nojq=allow ;;
2) got_nojq=block ;;
*) got_nojq="exit$?" ;;
esac
if [ "$got_nojq" = block ]; then
pass=$((pass + 1)); printf ' ok block (no jq on PATH)\n'
else
fail=$((fail + 1)); printf ' FAIL no-jq fallback got=%s\n' "$got_nojq"
fi
rm -rf "$nojq"

printf '\n%s passed, %s failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
172 changes: 172 additions & 0 deletions .claude/hooks/guard-shared-stash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# guard-shared-stash.sh — PreToolUse guard: the stash stack is SHARED across worktrees.
# Blocks Bash commands that push to / pop from / drop the shared stash stack, and lets
# the read-only and SHA-pinned forms through.
#
# Why: `git stash` keeps its stack in refs/stash inside the COMMON .git directory. Every
# linked worktree shares that one LIFO stack, so the per-task worktree isolation AGENTS.md
# mandates — and guard-main-checkout.sh enforces — does NOT extend to stash. Two agents
# stashing in their own worktrees operate on the same stack: A's pop restores whatever B
# pushed a moment earlier, and A's own changes stay on the stack for B to take.
#
# Live incident, objectui#3430 (2026-08-06, ~03:56Z): a reverse-verification
# `git stash push -- packages/fields/.../RecordPickerDialog.tsx` followed by
# `git stash pop` dropped b52e3aa instead — another agent's WIP on claude/issue-5733-…,
# two unrelated plugin-detail files. Both agents' in-flight work swapped places; a
# `git add -A` on either side would have merged the other's changes into the wrong PR.
# The failure mode is maximally confusing: pop reports SUCCESS, and someone else's files
# simply appear in your git status. Reverse verification is routine here and stash is the
# handiest tool for it, so the collision probability is not small.
#
# Alternatives — no shared state, all three work inside your own worktree:
# 1. patch file git diff > /tmp/wip.patch && git checkout -- <paths>
# git apply /tmp/wip.patch (git apply -R to undo again)
# 2. temporary commit git commit -am wip (git reset --soft HEAD~1)
# 3. a second worktree for the comparison checkout
#
# Allowed through, deliberately:
# - `git stash list` / `git stash show` — read-only, they never mutate the stack.
# - `git stash create` — writes a commit object and prints its object id WITHOUT
# storing it in the ref namespace (git-stash(1)); the safe primitive underneath the
# SHA-pinned workflow.
# - `git stash apply <sha>` / `git stash store <sha>` — the recovery path used to repair
# the incident above. An explicit hex object id ONLY: stash@{0} is a POSITION in the
# shared stack and may be another agent's entry by the time your command runs.
#
# Deliberate exception (you know the stack is yours alone): OS_ALLOW_STASH=1.
#
# Exit-code contract, mirroring guard-main-checkout.sh: 0 = allow, 2 = block with the
# reason on stderr. Anything this cannot parse fails OPEN — a guard that blocks work it
# does not understand gets disabled, and then it guards nothing.
#
# Known boundary, stated so nobody has to rediscover it: the check reads the FIRST WORD of
# each shell segment, so a wrapped invocation (bash -c '…', xargs, ssh host '…') is not
# caught. That is the deliberate trade — the target is the reflexive `git stash push` an
# agent reaches for mid-task, not a determined evader, and OS_ALLOW_STASH=1 already exists
# for anyone who means it. Widening it to string-match anywhere in the command would block
# every `grep "git stash"` run against this very file.
#
# Self-test (26 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh

set -uo pipefail

[ "${OS_ALLOW_STASH:-}" = "1" ] && exit 0

input="$(cat 2>/dev/null || true)"
cmd=""
if command -v jq >/dev/null 2>&1; then
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
fi
if [ -z "$cmd" ]; then
# jq-less fallback: lift the JSON string value honouring backslash escapes (so an
# embedded \" does not truncate the command), then unescape what matters for shell text.
cmd="$(printf '%s' "$input" \
| sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' \
| head -1 \
| sed 's/\\n/ /g; s/\\t/ /g; s/\\"/"/g; s/\\\\/\\/g')"
fi

[ -n "$cmd" ] || exit 0

# --- split the command into shell segments, honouring quotes ---------------------------
# A separator inside '…' or "…" does NOT split, so writing *about* the ban is never caught
# by the ban: `grep -n "cd x && git stash pop" AGENTS.md` stays one segment whose first
# word is grep. (objectstack#4890's lesson — the PR writing a rule must not trip it.)
segments=()
split_segments() {
local s="$1" seg="" q="" ch i n=${#1}
for ((i = 0; i < n; i++)); do
ch="${s:i:1}"
if [ -n "$q" ]; then
seg+="$ch"
[ "$ch" = "$q" ] && q=""
continue
fi
case "$ch" in
"'" | '"') q="$ch" ; seg+="$ch" ;;
';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;;
*) seg+="$ch" ;;
esac
done
segments+=("$seg")
}

# --- verdict for one segment -----------------------------------------------------------
# returns 0 = fine, 1 = this segment mutates the shared stash stack.
check_segment() {
local seg="$1"
local -a w=()
read -r -a w <<<"$seg"
local i=0 n=${#w[@]}
[ "$n" -gt 0 ] || return 0

# leading FOO=bar environment assignments
while [ "$i" -lt "$n" ]; do
case "${w[$i]}" in
[A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)) ;;
*) break ;;
esac
done
[ "$i" -lt "$n" ] || return 0

# /usr/bin/git -> git
[ "${w[$i]##*/}" = "git" ] || return 0
i=$((i + 1))

# git's own global options, before the subcommand
while [ "$i" -lt "$n" ]; do
case "${w[$i]}" in
-C | -c | --exec-path | --git-dir | --work-tree | --namespace) i=$((i + 2)) ;;
-*) i=$((i + 1)) ;;
*) break ;;
esac
done
[ "$i" -lt "$n" ] || return 0
[ "${w[$i]}" = "stash" ] || return 0
i=$((i + 1))

local sub="${w[$i]:-}"
case "$sub" in
--help | -h) return 0 ;; # reading the manual is not stashing
list | show) return 0 ;; # read-only against refs/stash
create) return 0 ;; # makes an object, does NOT store it in the stack
apply | store)
# pinned to an explicit hex object id => this cannot pick up another agent's entry.
local j
for ((j = i + 1; j < n; j++)); do
[[ "${w[$j]}" =~ ^[0-9a-fA-F]{7,40}$ ]] && return 0
done
;;
esac
return 1
}

split_segments "$cmd"
for seg in "${segments[@]}"; do
check_segment "$seg" && continue
offending="${seg#"${seg%%[![:space:]]*}"}"
cat >&2 <<EOF
⛔ Blocked: git stash uses ONE stack shared by every worktree of this repo.
command: $offending

refs/stash lives in the COMMON .git directory, so the per-task worktree isolation this
repo mandates does NOT cover the stash stack. Another agent's pop takes YOUR entry and
yours takes theirs — pop reports success and their files show up in your git status,
which is why objectui#3430 swapped two agents' in-flight changes without an error.

Use instead — no shared state, all inside your own worktree:
1. patch file git diff > /tmp/wip.patch && git checkout -- <paths>
git apply /tmp/wip.patch # git apply -R to undo again
2. temporary commit git commit -am wip # git reset --soft HEAD~1
3. a second worktree for the comparison checkout

Already allowed, no flag needed:
git stash list | git stash show | git stash create
git stash apply <sha> | git stash store <sha> # literal hex id, never stash@{N}

Deliberate exception (the stack really is yours alone): re-run with OS_ALLOW_STASH=1.
EOF
exit 2
done

exit 0
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout.sh\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-shared-stash.sh\""
}
]
}
]
}
Expand Down
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,30 @@ Make all edits there, **one worktree per repo** a task spans. A PreToolUse hook
`NotebookEdit` unless the edited file is in a linked worktree, and it checks the edited
file's own repo (so sibling repos are covered). Non-task exception: `OS_ALLOW_MAIN_EDITS=1`.

## ⛔ Never `git stash` — the stash stack is NOT covered by worktree isolation

`git stash` keeps its stack in `refs/stash` inside the **common `.git` directory**, so
**every worktree shares one LIFO stack**. The per-task worktree isolation above does not
extend to it: two agents stashing in their own worktrees push and pop the *same* stack —
your `pop` restores whatever the other agent pushed a moment earlier, and your own
changes stay on the stack for them to take. `pop` reports **success**; the only symptom
is someone else's files appearing in your `git status`, and a following `git add -A`
merges their work into your PR. This is not hypothetical — it happened between two
parallel agents (objectui#3430) and cost both of them their in-flight changes.

Use one of these instead — no shared state, all inside your own worktree:

```
git diff > /tmp/wip.patch && git checkout -- <paths> # then: git apply /tmp/wip.patch
git commit -am wip # then: git reset --soft HEAD~1
git worktree add ../objectui-<task>-cmp <ref> # a second tree to compare against
```

A PreToolUse hook (`.claude/hooks/guard-shared-stash.sh`) enforces this — it blocks
`Bash` commands that push/pop/drop/clear the stack, and allows the forms that cannot
take another agent's entry: `git stash list`/`show`/`create`, and `git stash apply <sha>`
/ `store <sha>` pinned to a **literal hex object id** (never `stash@{N}` — that is a
*position* in a stack you don't own). Deliberate exception: `OS_ALLOW_STASH=1`. Changing
the hook? Re-run `.claude/hooks/guard-shared-stash.selftest.sh`.

See **AGENTS.md** for the full playbook.
Loading