Skip to content

feat(hooks): pre-bash-guard protects resources shared across the machine - #30

Open
Arasz wants to merge 2 commits into
codewithmukesh:mainfrom
Arasz:feat/pre-bash-guard-shared-resource-checks
Open

feat(hooks): pre-bash-guard protects resources shared across the machine#30
Arasz wants to merge 2 commits into
codewithmukesh:mainfrom
Arasz:feat/pre-bash-guard-shared-resource-checks

Conversation

@Arasz

@Arasz Arasz commented Aug 9, 2026

Copy link
Copy Markdown

Stacked on #29. The diff to review is the second commit, feat(hooks): guard resources shared across the machine — the first is #29 and will disappear from this PR once that merges. Happy to rebase whenever.

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, and pkill -f dotnet does not know which dotnet was yours.

Four rules

Rule Reaches past the project because
pkill, killall they match by name or pattern, so they reap every match on the machine
kill with a non-PID target kill $(pgrep -f MyApp) kills whatever else matched
dotnet build-server shutdown it 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 runs before the build-output allowlist. rm -rf ~/.dotnet/bin ends in bin and was being waved through as build output.

What I left out

Node and Python caches (~/.npm, ~/.cache/pip). 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. 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:

FAIL: blocks pkill by pattern (exit 0, expected 2)
FAIL: blocks killall by process name (exit 0, expected 2)
FAIL: blocks killall reached by absolute path (exit 0, expected 2)
FAIL: blocks kill against a pgrep substitution (exit 0, expected 2)
FAIL: blocks kill against a process name (exit 0, expected 2)
FAIL: blocks dotnet build-server shutdown (exit 0, expected 2)
FAIL: blocks recursive delete of ~/.dotnet without -f (exit 0, expected 2)
FAIL: blocks a cache path whose leaf looks like build output (exit 0, expected 2)
PASS: allows kill against a numeric PID
PASS: allows kill -s with a numeric PID
PASS: allows kill against a PID held in a variable
PASS: allows pgrep, which only reads
PASS: allows a command that merely mentions pkill
PASS: allows dotnet build
PASS: allows rm -rf on project build output
PASS: allows a command that merely mentions the cache path
8 hook test(s) failed

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=warning clean, 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

Arasz and others added 2 commits August 9, 2026 14:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant