From 1aff74c4218560ea7403dd12b9766433fa5ef52a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:56:41 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(hooks):=20guard=20the=20shared=20proce?= =?UTF-8?q?ss=20table=20=E2=80=94=20kill=20only=20a=20PID=20you=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01P58euzUXCVJNwmhuPC9DXY --- .claude/hooks/guard-process-kill.selftest.sh | 215 ++++++++++++ .claude/hooks/guard-process-kill.sh | 333 +++++++++++++++++++ .claude/settings.json | 4 + AGENTS.md | 1 + CLAUDE.md | 12 +- 5 files changed, 559 insertions(+), 6 deletions(-) create mode 100755 .claude/hooks/guard-process-kill.selftest.sh create mode 100755 .claude/hooks/guard-process-kill.sh diff --git a/.claude/hooks/guard-process-kill.selftest.sh b/.claude/hooks/guard-process-kill.selftest.sh new file mode 100755 index 0000000000..5889ec7c07 --- /dev/null +++ b/.claude/hooks/guard-process-kill.selftest.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Self-test for guard-process-kill.sh — run it after touching that hook: +# +# .claude/hooks/guard-process-kill.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. +# +# The harness is guard-shared-stash.selftest.sh's, one-for-one — same verdict(), expect(), +# stderr_of(), says() and lacks() — because this guard is that guard's shape applied to the +# other shared object. The case matrix is this guard's own, and it pins BOTH sides on +# purpose: a guard for a class this wide is worth nothing if the PID-scoped teardown the +# repo already prescribes comes back red. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/guard-process-kill.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 +} + +stderr_of() { # stderr_of [env…] -> the refusal text an agent actually reads + local cmd="$1"; shift + local payload + payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')" + printf '%s' "$payload" | env "$@" "$hook" 2>&1 >/dev/null +} + +says() { # says