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
23 changes: 23 additions & 0 deletions .claude/agents/os-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ all real:
forcing the template; #4984 is the family origin — fixtures spelling
rejected aliases kept the tests green while the rule was dead).

**⛔ Take the fix out with `git checkout`, a patch file or a temp commit — NEVER
`git stash`.** The worktree isolates your files and your HEAD; it does **not**
isolate `refs/stash`, which lives in the **common** `.git` and is one LIFO stack
shared by every worktree of the repo. Reverse verification is what makes this
bite: "stash the fix, re-run, restore" is the reflex move, so two agents doing it
at the same time swap entries — one `pop` restores the *other's* changes into your
worktree while yours stay on the stack, `pop` reports **success**, and a following
`git add -A` commits their half-finished work into your PR (objectui#3430, two dev
agents, both changesets recoverable only as unreachable commits). Use instead, all
inside your own worktree:

```
git checkout origin/main -- <path> # take the fix out; restore: git checkout <branch> -- <path>
git diff > /tmp/wip.patch && git checkout -- <paths> # restore: git apply /tmp/wip.patch
git commit -am wip # restore: git reset --soft HEAD~1
```

`.claude/hooks/guard-shared-stash.sh` blocks the mutating forms on the `Bash`
matcher (`push`/`pop`/`drop`/`clear`, and `stash@{N}` — a *position* in a stack you
don't own); `git stash list`/`show`/`create` and `apply`/`store` pinned to a literal
hex object id stay allowed. Escape hatch, when the stack really is yours alone:
`OS_ALLOW_STASH=1`.

**Rejection-class cases assert the envelope, not the throw.** For any case whose
point is that bad input is *refused*, the minimum assertion set is the error's
**`code` AND `status`** (the ADR-0112 envelope). `expect(...).toThrow()` /
Expand Down
6 changes: 3 additions & 3 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
# Ported from objectui's hook of the same name (objectstack-ai/objectui#3452, filed there
# as objectui#3435) — the logic below is deliberately kept case-for-case identical to it so
# the two repos' guards cannot drift; only issue references, example paths and the package
# name in the self-test are localised. objectui additionally runs guard-shared-stash.sh on
# the same matcher; this repo has no such hook, so the Bash matcher is created here rather
# than joined (that gap is tracked separately — see #5790's closing observation).
# name in the self-test are localised. The Bash matcher this hook created is now shared
# with guard-shared-stash.sh, mirrored here from objectui in the same way (#5742) — the
# gap #5790's closing observation recorded is closed; both hooks run on this one matcher.
#
# The rule and the escape hatch are deliberately the SAME as guard-main-checkout.sh's:
# OS_ALLOW_MAIN_EDITS=1. One rule, one hatch — a second variable would just be another
Expand Down
118 changes: 118 additions & 0 deletions .claude/hooks/guard-shared-stash.selftest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/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.
#
# Mirrored from objectui's self-test of the same name (objectui#3430 / PR #3433) alongside
# the hook itself; the case matrix is kept one-for-one so the two repos' guards cannot
# drift, and only the example paths, worktree names and package name are localised.

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/spec/src/kernel/metadata-plugin.zod.ts'
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/objectstack && git stash pop'
expect block 'git -C ../objectstack-issue-5742 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-5742" b52e3aa1234567'

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

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 ]
186 changes: 186 additions & 0 deletions .claude/hooks/guard-shared-stash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/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
# Prime Directive #11 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. Recovery worked only because the dropped SHA was still
# in scrollback — once the stack empties, refs/stash and logs/refs/stash are both gone and
# `git reflog refs/stash` answers `fatal: ambiguous argument`; a `git gc` in between makes
# the loss permanent. Reverse verification is routine here and stash is the handiest tool
# for it, so the collision probability is not small.
#
# Ported from objectui's hook of the same name (objectui#3430 / objectui PR #3433), filed
# for this repo as objectstack#5742. The verdict logic below is deliberately kept
# case-for-case identical to objectui's so the two repos' guards cannot drift; only issue
# references, example paths and the package name in the self-test are localised — the same
# mirroring discipline guard-main-checkout-bash.sh already documents.
#
# Alternatives — no shared state, all of these work inside your own worktree:
# 1. clean re-read git checkout origin/main -- <path> (restore: git checkout <branch> -- <path>)
# 2. patch file git diff > /tmp/wip.patch && git checkout -- <paths>
# git apply /tmp/wip.patch (git apply -R to undo again)
# 3. temporary commit git commit -am wip (git reset --soft HEAD~1)
# 4. 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 (32 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 (AGENTS.md Prime Directive #11) 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. clean re-read git checkout origin/main -- <path>
git checkout <your-branch> -- <path> # put your version back
2. patch file git diff > /tmp/wip.patch && git checkout -- <paths>
git apply /tmp/wip.patch # git apply -R to undo again
3. temporary commit git commit -am wip # git reset --soft HEAD~1
4. 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
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-shared-stash.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout-bash.sh\""
Expand Down
Loading
Loading