Skip to content

Commit a814bdb

Browse files
hotlongclaude
andauthored
feat(hooks): guard the shared process table — kill only a PID you recorded (#16843)
* feat(hooks): guard the shared process table — kill only a PID you recorded Worktree isolation gives each agent its own checkout, index and HEAD; it gives it nothing over the process table, which is one table per container. A name-matched kill reaches whatever a parallel agent is running under that name, and it exits 0 either way — the agent that fired it observes nothing, the agent whose run died cannot attribute it, and the loss is recorded as a flake. The same shape as guard-shared-stash.sh, deliberately: hook + same-named .selftest.sh + an OS_ALLOW_* escape + the Bash PreToolUse registration, with the AGENTS.md line and the CLAUDE.md inline beside the stash rule they generalise. Swept first, so the rule covers the class rather than one spelling: the tree carries no pkill, no killall, no `xargs kill`, no `ps | grep | kill` and no `fuser -k`. Every live kill site is PID-scoped — `kill "$SERVER_PID"`, `kill $(lsof -ti tcp:PORT)`, `pgrep -s`/`pgrep -P` in publish-smoke.sh and gen-sdui-manifest.sh — and the self-test pins all of them ALLOWED alongside the blocked shapes, including the card's own specimen. Neither ceiling moves: AGENTS.md 1067 -> 1068 (its one line of headroom) and CLAUDE.md stays at 41, the stash section absorbing the second rule inside its existing six lines. The mechanism, the class and the escape live in the hook header, which carries no ceiling — the ratchet's own prescribed remedy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P58euzUXCVJNwmhuPC9DXY * test(hooks): pin bare `pkill` — the ablation found the pidscoped gate unpinned Second ablation leg: replacing check_pkill's `[ "$pidscoped" -eq 1 ] && return 0` with an unconditional allow left the self-test green at 68/0. Bare `pkill` is the only input that reaches that gate — every other spelling returns earlier — so the branch had no case standing on it. It does now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P58euzUXCVJNwmhuPC9DXY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f23f0e commit a814bdb

5 files changed

Lines changed: 564 additions & 6 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env bash
2+
# Self-test for guard-process-kill.sh — run it after touching that hook:
3+
#
4+
# .claude/hooks/guard-process-kill.selftest.sh
5+
#
6+
# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and asserts
7+
# the block/allow verdict per command. Needs jq (to build payloads) and nothing else: no
8+
# install, no build, no network. Exit 0 = all cases hold.
9+
#
10+
# The harness is guard-shared-stash.selftest.sh's, one-for-one — same verdict(), expect(),
11+
# stderr_of(), says() and lacks() — because this guard is that guard's shape applied to the
12+
# other shared object. The case matrix is this guard's own, and it pins BOTH sides on
13+
# purpose: a guard for a class this wide is worth nothing if the PID-scoped teardown the
14+
# repo already prescribes comes back red.
15+
16+
set -uo pipefail
17+
18+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19+
hook="$here/guard-process-kill.sh"
20+
pass=0
21+
fail=0
22+
23+
command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; }
24+
25+
# verdict <command> [env assignments…] -> prints "block" or "allow"
26+
verdict() {
27+
local cmd="$1"; shift
28+
local payload out rc
29+
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
30+
out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)"
31+
rc=$?
32+
case "$rc" in
33+
0) printf 'allow' ;;
34+
2) printf 'block' ;;
35+
*) printf 'exit%s' "$rc" ;;
36+
esac
37+
}
38+
39+
expect() { # expect <block|allow> <command> [env…]
40+
local want="$1" cmd="$2"; shift 2
41+
local got; got="$(verdict "$cmd" "$@")"
42+
if [ "$got" = "$want" ]; then
43+
pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$cmd"
44+
else
45+
fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$cmd"
46+
fi
47+
}
48+
49+
stderr_of() { # stderr_of <command> [env…] -> the refusal text an agent actually reads
50+
local cmd="$1"; shift
51+
local payload
52+
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
53+
printf '%s' "$payload" | env "$@" "$hook" 2>&1 >/dev/null
54+
}
55+
56+
says() { # says <needle> <label> <command> [env…]
57+
local needle="$1" label="$2" subject="$3"; shift 3
58+
local out; out="$(stderr_of "$subject" "$@")"
59+
case "$out" in
60+
*"$needle"*) pass=$((pass + 1)); printf ' ok says %s\n' "$label" ;;
61+
*) fail=$((fail + 1)); printf ' FAIL missing "%s" %s\n' "$needle" "$label" ;;
62+
esac
63+
}
64+
65+
lacks() { # lacks <needle> <label> <command> [env…]
66+
local needle="$1" label="$2" subject="$3"; shift 3
67+
local out; out="$(stderr_of "$subject" "$@")"
68+
case "$out" in
69+
*"$needle"*) fail=$((fail + 1)); printf ' FAIL still says "%s" %s\n' "$needle" "$label" ;;
70+
*) pass=$((pass + 1)); printf ' ok lacks %s\n' "$label" ;;
71+
esac
72+
}
73+
74+
echo "== the specimen this rule was filed for (objectstack#16182 / PR #16120) =="
75+
expect block "pkill -f 'dispatch-gates.mjs --self-test'"
76+
expect block 'pkill -f dispatch-gates.mjs'
77+
78+
echo "== pkill selects by NAME with or without -f =="
79+
expect block 'pkill node'
80+
expect block 'pkill -9 vitest'
81+
expect block 'pkill -x esbuild'
82+
expect block 'pkill -u root node'
83+
expect block 'pkill --signal TERM node'
84+
expect block 'pkill -f "pnpm --filter @objectstack/spec test"'
85+
expect block '/usr/bin/pkill -f vitest'
86+
expect block 'OS_FOO=1 pkill -f vitest'
87+
# Bare `pkill` — its option loop completes with no PID-family selector, and it is the ONLY
88+
# input that reaches the pidscoped gate at the end of check_pkill. Without this row that gate
89+
# is unpinned: an ablation replacing it with an unconditional allow left this file green at
90+
# 68/0, which is how the hole was found rather than shipped.
91+
expect block 'pkill'
92+
93+
echo "== killall is the spelling a pkill-only rule would go silent on =="
94+
expect block 'killall node'
95+
expect block 'killall -9 node'
96+
expect block 'killall -r "vitest.*"'
97+
expect block 'killall'
98+
99+
echo "== reached through separators and command substitution =="
100+
expect block 'cd /home/user/objectstack && pkill -f vitest'
101+
expect block 'pnpm test; pkill -f vitest'
102+
expect block 'out=$(pkill -f vitest)'
103+
104+
echo "== a pgrep NAME PATTERN feeding a kill is a pkill spelled out longhand =="
105+
expect block 'pgrep -f dispatch-gates.mjs | xargs kill'
106+
expect block 'kill $(pgrep -f vitest)'
107+
expect block 'kill -9 $(pgrep node)'
108+
expect block 'for p in $(pgrep -f vitest); do kill "$p"; done'
109+
expect block 'pgrep -lf node | awk "{print \$1}" | xargs kill -9'
110+
111+
echo "== ps piped through grep into a kill is the hand-rolled spelling of the same thing =="
112+
expect block 'ps aux | grep vitest | awk "{print \$2}" | xargs kill'
113+
expect block 'ps -ef | grep node | xargs kill -9'
114+
115+
echo "== PID-SCOPED kills are the POSITIVE form and must stay allowed =="
116+
expect allow 'kill "$SERVER_PID"'
117+
expect allow 'kill -0 "$SERVER_PID"'
118+
expect allow 'kill -9 12345'
119+
expect allow 'kill -- -"$PGID"'
120+
expect allow 'kill %1'
121+
expect allow 'cmd & pid=$!; kill "$pid"'
122+
123+
echo "== the teardown AGENTS.md itself prescribes: the port is one YOU picked =="
124+
expect allow 'kill $(lsof -ti tcp:38421)'
125+
expect allow 'kill $(lsof -ti tcp:3000) 2>/dev/null'
126+
127+
echo "== pgrep -P / -s select by a handle the caller owns — live in two tracked scripts =="
128+
# scripts/publish-smoke.sh kill_tree() and scripts/gen-sdui-manifest.sh sdui_live_pids().
129+
# A rule that reddened these would be routed around within the hour.
130+
expect allow 'for child in $(pgrep -P "$pid"); do kill "$child"; done'
131+
expect allow 'pgrep -s "$leader" | xargs kill'
132+
expect allow 'kill $(pgrep -P 4242)'
133+
expect allow 'pgrep -P "${frontier// /,}"'
134+
135+
echo "== the same selectors on pkill, with NO pattern operand =="
136+
expect allow 'pkill -P "$pid"'
137+
expect allow 'pkill -s "$sid"'
138+
expect allow 'pkill -P4242'
139+
expect allow 'pkill --help'
140+
# …and adding a pattern to them is blocked again, which is what makes the pair meaningful.
141+
expect block 'pkill -P "$pid" node'
142+
expect block 'pkill -s "$sid" -f vitest'
143+
144+
echo "== reads are reads: nothing dies, so nothing is blocked =="
145+
expect allow 'pgrep -f vitest'
146+
expect allow 'pgrep -lf node'
147+
expect allow 'ps aux | grep node'
148+
expect allow 'ps -o sid= -p "$LEADER" | tr -d " "'
149+
expect allow 'ps aux | grep -c vitest'
150+
151+
echo "== unrelated commands are untouched =="
152+
expect allow 'pnpm --filter @objectstack/spec test'
153+
expect allow 'git status'
154+
expect allow 'node scripts/pm/dispatch-gates.mjs --self-test'
155+
expect allow 'rm -rf node_modules'
156+
157+
echo "== writing ABOUT the ban must not trip the ban (objectstack#4890) =="
158+
expect allow 'grep -n "pkill -f" AGENTS.md'
159+
expect allow 'git grep -n "killall"'
160+
expect allow 'grep -rn "pgrep -f x | xargs kill" .claude/'
161+
expect allow 'echo "never run pkill -f against a shared container"'
162+
163+
echo "== an UNQUOTED \\\" opens no quote, so the kill behind it is still seen (#11738) =="
164+
# Inherited from guard-shared-stash.sh's split_segments(): reading the escaped `\"` as
165+
# OPENING a region that never closes collapses the command into one harmless-headed segment
166+
# and lets the real kill ride through as an argument. The bare forms next door are the
167+
# controls that say the guard was reached at all.
168+
expect block 'echo \" ; pkill -f vitest'
169+
expect block 'echo \" && killall node'
170+
expect allow 'echo \" ; echo hello'
171+
expect allow 'echo \" ; git status'
172+
173+
echo "== an escaped \\\" INSIDE a double-quoted word does NOT close it (#10406 half) =="
174+
# The other direction of the same rule: reading it as CLOSING splits where bash would not
175+
# and turns the tail of a pure READ into a segment judged on its own head word.
176+
expect allow 'grep -rn "he said \"pkill -f vitest\" once" .claude/'
177+
expect block 'echo "he said \"x\"" && pkill -f vitest'
178+
179+
echo "== escape hatch =="
180+
expect allow 'pkill -f vitest' OS_ALLOW_PROCESS_KILL=1
181+
expect allow 'ps aux | grep node | xargs kill' OS_ALLOW_PROCESS_KILL=1
182+
183+
echo "== the refusal leads with the POSITIVE form, and names where the hatch works =="
184+
# A rule that only says "not like this" gets routed into another spelling of the same
185+
# mistake, so the positive form is pinned as text an agent actually reads — not merely as
186+
# an intention in the header. And the hatch sentence must name the environment THIS HOOK
187+
# reads: a VAR=1 command prefix cannot reach it, and an instruction that does not work is
188+
# an invitation to route around the guard (#15971, the same repair the stash guard took).
189+
says 'Kill only a PID you recorded' 'the positive form' 'pkill -f vitest'
190+
says 'hook itself runs in' 'the hatch names the environment this hook reads' 'pkill -f vitest'
191+
lacks 're-run with' 'the refusal does not print an unusable prefix remedy' 'pkill -f vitest'
192+
193+
echo "== payload with no command fails open =="
194+
if printf '%s' '{"tool_name":"Bash","tool_input":{}}' | "$hook" >/dev/null 2>&1; then
195+
pass=$((pass + 1)); printf ' ok allow (empty tool_input)\n'
196+
else
197+
fail=$((fail + 1)); printf ' FAIL empty tool_input should fail open\n'
198+
fi
199+
200+
echo "== jq-less fallback still parses the command =="
201+
nojq="$(mktemp -d)"
202+
for b in bash env cat sed head grep; do
203+
p="$(command -v "$b")" && ln -s "$p" "$nojq/$b"
204+
done
205+
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"pkill -f vitest"}}' \
206+
| PATH="$nojq" "$hook" >/dev/null 2>&1
207+
case "$?" in
208+
0) got_nojq=allow ;;
209+
2) got_nojq=block ;;
210+
*) got_nojq="exit$?" ;;
211+
esac
212+
if [ "$got_nojq" = block ]; then
213+
pass=$((pass + 1)); printf ' ok block (no jq on PATH)\n'
214+
else
215+
fail=$((fail + 1)); printf ' FAIL no-jq fallback got=%s\n' "$got_nojq"
216+
fi
217+
rm -rf "$nojq"
218+
219+
printf '\n%s passed, %s failed\n' "$pass" "$fail"
220+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)