diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh new file mode 100755 index 0000000000..fc7a59ebc3 --- /dev/null +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -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 [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 [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 ] diff --git a/.claude/hooks/guard-shared-stash.sh b/.claude/hooks/guard-shared-stash.sh new file mode 100755 index 0000000000..19e7e738f7 --- /dev/null +++ b/.claude/hooks/guard-shared-stash.sh @@ -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 -- +# 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 ` / `git stash store ` — 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 < /tmp/wip.patch && git checkout -- + 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 | git stash store # 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 diff --git a/.claude/settings.json b/.claude/settings.json index 8aadfd9924..aaf9eb1898 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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\"" + } + ] } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 2150226792..8b86bf0a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -- # then: git apply /tmp/wip.patch +git commit -am wip # then: git reset --soft HEAD~1 +git worktree add ../objectui--cmp # 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 ` +/ `store ` 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.