From 8a119a99e67584fa06fdb2650e79fd2878789a7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:48:14 +0000 Subject: [PATCH 1/2] =?UTF-8?q?chore(hooks):=20=E6=8B=A6=E6=88=AA=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20refs/stash=20=E7=9A=84=20git=20stash,worktree=20?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E7=9B=96=E4=B8=8D=E4=BD=8F=E5=AE=83=20(#3430?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs/stash 存在共用的 .git 目录里,所有 linked worktree 共享同一个 LIFO 栈, 「一任务一 worktree」的物理隔离对它无效:两个 agent 在各自 worktree 里 push/pop 操作的是同一个栈,A 的 pop 取到 B 刚 push 的改动,A 自己的改动留在栈上被 B 取走。 pop 还会报成功,唯一现象是别人的文件出现在你的 git status 里。 新增 PreToolUse 钩子 guard-shared-stash.sh(Bash matcher,退出码契约与 guard-main-checkout.sh 一致:0 放行 / 2 拦截并把理由写到 stderr): - 拦截 push/save/pop/drop/clear/branch 与裸 git stash; - 放行只读与按 SHA 取回的形式:list / show / create,以及带字面十六进制 object id 的 apply / store —— stash@{N} 是共享栈里的**位置**,一律拦截; - 分段时识别引号,写「关于禁令」的文档/grep 不会被禁令自己拦下; - 解析不了的 payload 一律 fail open;逃生阀 OS_ALLOW_STASH=1。 配套 guard-shared-stash.selftest.sh(32 条用例)与 CLAUDE.md 一节(禁令 + 三种替代法)。反向验证:把判定函数改成恒放行,15 条 block 用例全红、17 条 allow 用例不动。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .claude/hooks/guard-shared-stash.selftest.sh | 114 +++++++++++++ .claude/hooks/guard-shared-stash.sh | 165 +++++++++++++++++++ .claude/settings.json | 9 + CLAUDE.md | 26 +++ 4 files changed, 314 insertions(+) create mode 100755 .claude/hooks/guard-shared-stash.selftest.sh create mode 100755 .claude/hooks/guard-shared-stash.sh 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..1dfc18354c --- /dev/null +++ b/.claude/hooks/guard-shared-stash.sh @@ -0,0 +1,165 @@ +#!/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. +# +# 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. From ae7fee01c97172f63eeba9269e96a95494b63c1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:52:51 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(hooks):=20=E5=86=99=E6=98=8E=20guard-s?= =?UTF-8?q?hared-stash=20=E7=9A=84=E5=B7=B2=E7=9F=A5=E8=BE=B9=E7=95=8C(?= =?UTF-8?q?=E5=8C=85=E8=A3=85=E8=B0=83=E7=94=A8=E4=B8=8D=E6=8B=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首词判定意味着 bash -c '…' / xargs / ssh 这类包装调用抓不到。这是刻意取舍: 目标是 agent 任务中途下意识敲的 git stash push,不是有意绕行的人(那有 OS_ALLOW_STASH=1)。改成在整条命令里做子串匹配的话,连 grep "git stash" 这个文件本身都会被拦。写进 header,省得下一个读的人自己去发现。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .claude/hooks/guard-shared-stash.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/hooks/guard-shared-stash.sh b/.claude/hooks/guard-shared-stash.sh index 1dfc18354c..19e7e738f7 100755 --- a/.claude/hooks/guard-shared-stash.sh +++ b/.claude/hooks/guard-shared-stash.sh @@ -39,6 +39,13 @@ # 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