From 8e1d0c4dd0dc8245835128fcb746262d59294d54 Mon Sep 17 00:00:00 2001 From: Rafal Araszkiewicz Date: Sun, 9 Aug 2026 14:38:35 +0200 Subject: [PATCH] fix(hooks): match the command word, not the command text pre-bash-guard.sh grepped the raw command string, so it could not tell a command from a mention of one. These were all blocked in real sessions, and none of them deletes anything: grep -rn 'rm -rf' docs/ python3 build.py # the old cleanup used rm -rf git log --grep='git reset --hard' echo "never run git push --force on main" Same defect ADR-006 fixed one tier down, and the same cost: a guard that blocks work people legitimately need is a guard they learn to route around. The command is now split on ; && || | newlines and grouping, and each segment is matched on its command word -- token 0 after stripping VAR=value assignments, sudo/env/command/nohup, and any leading path, so /bin/rm still reads as rm. Quoted strings, # comments and heredoc bodies are data. Coverage went up rather than down. sudo rm -rf, /bin/rm -rf, xargs rm -rf, find -exec rm -rf, $(...) and backtick bodies, and bash -lc '...' all reach a real rm and are all blocked. The -c handling matches any short cluster containing c (-lc, -cx, -ec); an exact -c token match is one letter from a bypass. The tokenizer splits on whitespace before it scans characters. Advancing one character at a time re-slices the remainder on every step, which is quadratic in bash -- my first draft took 6.7s on a 24KB command, on a hook that runs before every Bash call. It is 250ms now, and a typical command 9.9ms against the old guard's 20.9ms (median of 20, macOS, bash 3.2), since nothing forks. Separately: the test harness was reporting false passes. run_with_timeout's fallback backgrounds the hook, and bash hands an asynchronous command /dev/null for stdin unless it carries its own redirection, so the hook read an empty payload and blocked nothing. Wherever timeout is missing -- macOS, minimal Linux images -- the whole pre-bash-guard suite passed vacuously, including the existing force-push test. One <&0 fixes it. 24 cases cover both directions of every rule. Against the old guard the six false-positive cases go red and the rest stay green, which is what makes them worth having: they prove the parse costs no coverage. Verified on bash 3.2 (macOS, watchdog fallback path) and bash 5.2 (Debian, GNU timeout path). Windows Git Bash is CI's job. ADR-007 records the decision and the two-directional test any new rule needs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/test-hooks.sh | 92 ++- CHANGELOG.md | 13 + hooks/README.md | 37 +- hooks/pre-bash-guard.sh | 545 ++++++++++++++++-- knowledge/decisions/007-command-word-guard.md | 140 +++++ 5 files changed, 788 insertions(+), 39 deletions(-) create mode 100644 knowledge/decisions/007-command-word-guard.md diff --git a/.github/scripts/test-hooks.sh b/.github/scripts/test-hooks.sh index 4554049..b1cf88e 100644 --- a/.github/scripts/test-hooks.sh +++ b/.github/scripts/test-hooks.sh @@ -11,6 +11,13 @@ # payload that merely MENTIONS "reset --hard" in a # string field -> exit 0 (regression test for the # no-jq raw-payload over-blocking bug) +# a destructive string quoted, commented or fed to a +# heredoc -> exit 0 (it is data, not a command) +# the same command word reached through sudo, env, +# sh -c, $(...), xargs or find -exec -> exit 2 +# +# Nothing here executes a destructive command: every case is a command STRING +# handed to the guard inside a payload. set -u cd "$(dirname "$0")/../.." || exit 1 @@ -32,7 +39,10 @@ run_with_timeout() { timeout --kill-after=5 "$limit" "$@" return $? fi - "$@" & + # <&0 is load-bearing: without an explicit redirection bash gives an + # asynchronous command /dev/null for stdin, so the hook under test would read + # an empty payload, block nothing, and every case would "pass". + "$@" <&0 & local pid=$! ( sleep "$limit" @@ -142,6 +152,86 @@ else fail "pre-bash-guard exited $rc (expected 0) — over-blocking regression: payload text matched instead of the parsed command" fi +echo "" +echo "=== pre-bash-guard.sh: command word vs command text ===" + +# The guard must judge what a command RUNS, not what its text contains. These +# cases pair each destructive command with a benign one that merely mentions it. +json_escape() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\t'/\\t} + printf '%s' "$s" +} + +# guard_case