|
| 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