fix(hooks): pre-bash-guard matches command words, not command text - #29
Open
Arasz wants to merge 1 commit into
Open
fix(hooks): pre-bash-guard matches command words, not command text#29Arasz wants to merge 1 commit into
Arasz wants to merge 1 commit into
Conversation
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
pre-bash-guard.shmatched destructive patterns against the raw command string, so it could not tell a command from a mention of one. Every one of these was blocked in a real session, and none of them deletes anything:Same shape as the defect ADR-006 fixed one tier down, and the same cost — a guard that blocks work people legitimately need is one they learn to route around.
What changed
The command is split on
;,&&,||,|, newlines and grouping, and each segment is matched on its command word: token 0 after strippingVAR=valueassignments,sudo/env/command/nohup, and any leading path, so/bin/rmstill reads asrm. Quoted strings,#comments and heredoc bodies are data and never trigger a rule.Coverage went up rather than down. A command-word check alone would have missed these; all are blocked:
sudo rm -rf·/bin/rm -rf·rm.exe -rfxargs rm -rf·find … -exec rm -rf·timeout 30 rm -rf$(rm -rf …)·`rm -rf …`bash -lc '…'·sh -ec '…'c, not an exact-ctoken — that match is one letter from a bypassrm -rftarget checking got stricter too: every target must be on the allowlist, not just one of them.The harness was reporting false passes
Worth flagging on its own.
run_with_timeout's fallback backgrounds the hook, and bash gives an asynchronous command/dev/nullfor stdin unless it carries its own redirection — so the hook read an empty payload, blocked nothing, and every case passed. Wherevertimeoutis absent (macOS, minimal Linux images) the entire pre-bash-guard suite was vacuous, including the existing force-push test. One<&0fixes it.Verification
24 cases, both directions of every rule. Run against the old guard, the six false-positive cases go red and everything else stays green — the block and no-bypass cases exist to prove the parse costs no coverage:
All 24 pass after the change, on bash 3.2 (macOS — exercises the watchdog fallback) and bash 5.2 (Debian — exercises the GNU
timeoutpath). I have not run it under Git Bash on Windows; that is what the existing CI job covers.Timing, median of 20 runs on macOS/bash 3.2,
dotnet build src/Api/Api.csproj -c Release: 20.9ms → 9.9ms, since parsing happens in-process instead of forking sixecho | greppipelines. A 24KB command is 250ms. My first draft was 6.7s there — scanning bash strings one character at a time re-slices the remainder on every step, so the tokenizer splits on whitespace first and only then scans.shellcheck --severity=warningclean onhooks/*.shand.github/scripts/*.sh;check_docs_consistency.pyand the version-consistency check both pass.Scope
New checks I'd also like to contribute — unscoped process kills,
dotnet build-server shutdown, recursive deletes of~/.nugetand~/.dotnet— are held back for a separate PR so this one stays a bug fix. ADR-007 records why the guard parses instead of matching, the alternatives rejected, and the two-directional test any new rule needs.The guard is a guardrail against accidents, not a sandbox:
eval, base64, or a variable holding the command name still get through, and the ADR says so.🤖 Generated with Claude Code