feat(hooks): pre-bash-guard protects resources shared across the machine - #30
Open
Arasz wants to merge 2 commits into
Open
feat(hooks): pre-bash-guard protects resources shared across the machine#30Arasz wants to merge 2 commits into
Arasz wants to merge 2 commits 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>
The guard's rules all protected the working tree -- force push, reset --hard,
clean -f, checkout ., rm -rf. Right scope for one agent on one checkout, wrong
scope for how the kit gets used. A developer box often runs several agents at
once, and pkill -f dotnet does not know which dotnet was yours.
Four rules added, each for something that outlives the current project:
pkill / killall match by name or pattern, so they reap every match
kill with a non-PID target kill $(pgrep -f MyApp) kills whatever else matched
dotnet build-server shutdown stops the MSBuild, Razor and C#/VB compiler
servers machine-wide, not just this build
recursive rm on ~/.nuget, ~/.dotnet, ~/.templateengine, ~/.local/share/NuGet
every project on the machine restores from them
kill against a numeric PID, a %job spec, or a $VAR holding one stays allowed --
the agent already resolved a specific process, which is the distinction that
matters. pgrep only reads, so it is untouched.
The cache rule fires with or without -f, and is checked before the build-output
allowlist: rm -rf ~/.dotnet/bin ends in bin and was being waved through.
Node and Python caches are deliberately not covered. This is a .NET kit;
node_modules is on the allowlist only because .NET web projects vendor a
front-end, and a guard for another ecosystem's caches belongs in that
ecosystem's kit.
18 cases, both directions of every rule -- the kill blocked and the mention of
it allowed. Against the parser alone 8 go red and the allow cases stay green,
so the new rules are doing the blocking and are not over-firing.
Verified on bash 3.2 (macOS) and bash 5.2 (Debian). Stacked on the parsing fix;
these rules are unenforceable without it, since pkill inside a quoted string
would block on the text alone.
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.
The guard's rules all protect the working tree: force push,
git reset --hard,git clean -f,git checkout .,rm -rf. That is the right scope for one agent on one checkout, and the wrong scope for how the kit gets used. A developer box often runs several agents at once, andpkill -f dotnetdoes not know whichdotnetwas yours.Four rules
pkill,killallkillwith a non-PID targetkill $(pgrep -f MyApp)kills whatever else matcheddotnet build-server shutdownrmon~/.nuget,~/.dotnet,~/.templateengine,~/.local/share/NuGetkillagainst a numeric PID, a%jobspec, or a$VARholding one stays allowed — the agent already resolved a specific process, which is the distinction that matters.pgreponly reads, so it is untouched.The cache rule fires with or without
-f, and runs before the build-output allowlist.rm -rf ~/.dotnet/binends inbinand was being waved through as build output.What I left out
Node and Python caches (
~/.npm,~/.cache/pip). This is a .NET kit;node_modulesis on the allowlist only because .NET web projects vendor a front-end, and a guard for another ecosystem's caches belongs in that ecosystem's kit. One line to add if you disagree.dotnet nuget locals all --clear. Same blast radius as deleting~/.nuget/packages, but it is also the documented fix for a wedged restore, and blocking the documented fix is how a guard earns a reputation. Left allowed on purpose — say the word if you'd rather it warned.Verification
18 cases, both directions of every rule: the kill blocked, and a command that merely mentions it allowed. Against the parser alone (#29 without these rules), 8 go red and every allow case stays green — so the new rules do the blocking, and they are not over-firing:
All 18 pass after the change; 48 assertions green across the suite. Run on bash 3.2 (macOS) and bash 5.2 (Debian).
shellcheck --severity=warningclean, docs-count and version checks pass.These rules need #29 underneath them. Without the parser,
echo "run pkill -f dotnet if it hangs"would block on the text alone — which is the defect #29 exists to fix, and I'd rather not reintroduce it one rule at a time.🤖 Generated with Claude Code