Status: v3, post-consilium + Go switch. Approved for implementation.
Author: project maintainer (with Claude Opus 4.7)
Date: 2026-05-03
Target: Replace ~/.claude/hooks/validate-rm.py and ~/.claude/hooks/safety-net-ask.sh with a single, well-tested, false-positive-resistant Bash safety hook.
Changelog:
- v1 — initial Python + tree-sitter-bash design.
- v2 — consilium (Codex gpt-5.5 + OpenCode/Gemini-3.1-Pro) addressed 5 P0/P1 must-fixes: untrusted project configs, macOS bash binary selection, executor unwrapping, asymmetric fail-open, robust assertions.
- v3 — switched runtime to Go + mvdan.cc/sh per project maintainer's perf concern. Single static binary, build-on-stale wrapper for hot-reload editing.
- v3.1 — dropped the bash wrapper after measuring 80 ms per-invocation overhead from
go buildno-op. settings.json now points directly atbash_guard.bin; rebuilds are explicit (make buildormake watch). End-to-end latency 0-10 ms warm. - v3.2 — phases 0/1/2 collapsed in one go: shipped
rule_supabase.go,rule_bw.go,rule_infra.go, raninstall.sh --live --replace-legacy. Bash hook chain reduced from 7 entries to 1; 92 fixtures (~30 of them on the new rules) all green. - v3.3 — added Codex CLI / Codex App adapter and installer support. Internal decisions remain
allow|ask; Claude Code emitsask, while Codex maps internalaskto supportedpermissionDecision: "deny"because CodexPreToolUsedoes not support hook-created ask prompts yet.
Two false positives observed in production on 2026-05-03 prove the current hook stack is brittle:
FP-1: cat > /tmp/file <<'EOF' ... English prose with don't, it's, model's ... EOF triggers validate-rm.py because the word find appears in the prose. shlex(posix=True) fails on apostrophes inside the heredoc body, hook returns ask("Malformed command"). Harmless command, user confirmation dialog.
FP-2: cd /tmp && rm -rf ci-results && ... blocked by safety-net plugin's "rm -rf outside cwd" rule. /tmp is universally safe scratch space; this rule is wrong for an agent context.
Root causes: brittle parser (shlex breaks on heredocs), trigger keywords matched in non-executable spans, missing /tmp allowlist, fail-closed on parse error, forgotten plugin wrapper bypassing enabledPlugins.
- Eliminate FP-1 and FP-2 with regression-locked tests.
- Single replacement program for both legacy hooks.
- < 30 ms cold path, < 10 ms warm — Bash-hook is invoked on every Bash tool call.
- High recall on real destructive operations:
rm -rf /,$HOME,/etc,/usr, etc. - Internal policy decisions remain
allow|ask. Claude Code emits those directly. CodexPreToolUsecurrently does not support hook-created ask prompts, so the Codex adapter maps internalaskto supportedpermissionDecision: "deny"at the output boundary. - Treat the hook as a real program: typed, tested, configurable, observable, hot-editable.
- Replacing
protect-files.sh(Write|Edit matcher). - Replacing
ccnotify.py(UserPromptSubmit/Stop/Notification). - Generic shell linting.
- Network exfiltration detection (separate hook, separate concern).
Source lives in the ai-driven-development repo (under hooks/balanced-safety-hooks/); deployed via symlink to ~/.claude/hooks/bash-guard/ and/or ~/.codex/hooks/bash-guard/ so edits in git pick up live after rebuilding.
path/to/ai-driven-development/hooks/balanced-safety-hooks/
├── DESIGN.md # this file
├── README.md # short user-facing intro
├── install.sh # idempotent installer (symlinks src/ + writes settings.json)
├── .github/workflows/test.yml
└── src/
├── go.mod, go.sum
├── Makefile # build / test / fmt / watch
├── main.go # entrypoint, JSON in/out, top-level pipeline
├── parser.go # mvdan/sh wrapping, AST → spans
├── span.go # SpanKind classification
├── unwrap.go # executor unwrap table
├── safe_paths.go # safe-paths allowlist + realpath + lstat
├── decision.go # Decision dataclass + aggregation (no deny tier)
├── audit.go # JSONL log + size-based rotation + 0o600
├── config.go # TOML loader + trust system
├── rules/
│ ├── rules.go # Rule interface, registry
│ ├── rm.go # rm/unlink/rmdir/shred + safe-paths
│ ├── docker.go # supplants docker-prune-permission.sh (phase 2)
│ ├── supabase.go # supplants supabase-safety.sh (phase 2)
│ ├── bw.go # supplants bw-permission-check.sh (phase 2)
│ └── infra.go # supplants infra-safety.sh (phase 2)
├── trusted-projects.toml.example
├── config.toml # global defaults
├── build.log # gitignored
├── bash_guard.bin # gitignored
└── testdata/
├── README.md
└── fixtures/*.json
settings.json invokes the binary directly:
~/.claude/hooks/bash-guard/ is a symlink to ai-driven-development/hooks/balanced-safety-hooks/src/. Editing a .go file requires an explicit rebuild — make build from the source dir, or make watch (entr-based) during development. The Go binary itself is gitignored.
Performance (measured on M-series macOS):
- Cold first call (kernel page-cache miss): ~180 ms (one-time after reboot or upgrade).
- Warm: 0-10 ms end-to-end.
- Quick-reject path (no trigger keyword): ~0.16 ms inside the binary; rest is OS/exec overhead.
Earlier design had a build-on-stale shell wrapper that called go build on every invocation for hot-reload convenience. Measured ~80 ms steady-state overhead per Bash command. With hundreds of Bash calls per agent session, the cost added up to minutes per day for a feature used a few times per week. Dropped it — explicit make build after edits is cheap; we kept make watch for tight inner-loop development.
Why not pre-built binary committed to git: keeps repo small, avoids cross-arch concerns, source-only repo makes trust audit trivial. The Makefile re-bootstrap is 2 seconds.
Why not go run .: each invocation is a fresh compile to /tmp, ~200-300 ms even warm.
stdin JSON
│
├─ tool_name != "Bash" ──────────────► allow
│
├─ quick_reject(cmd) ────────────────► allow # no trigger keyword anywhere
│
├─ parse with mvdan/sh
│ │
│ └─ parse error ──────────────► allow # pre-trigger fail-open
│ # (asymmetry — see §3.6)
│
├─ build span tree (Executed | Data | HeredocBody | InlineCode)
│
├─ unwrap executor wrappers (sudo, env, xargs, find -exec, bash -c, ...)
│
├─ for each ExecutedCommand, run all matching rules
│
├─ aggregate(decisions): ask > allow (no internal deny tier)
│
└─ write agent-specific JSON to stdout, audit log to ~/.claude/logs/bash-guard.jsonl
mvdan.cc/sh/v3/syntax produces a typed Go AST. Walk it via the Walk(node, visitor) API; classify each subtree:
| AST node | SpanKind | Rules apply? |
|---|---|---|
*syntax.CallExpr (top-level / pipe child / subshell) |
Executed (after unwrap) | yes |
*syntax.SglQuoted, *syntax.DblQuoted |
Data (default) | no |
*syntax.Heredoc.Body |
HeredocBody | no |
*syntax.CmdSubst ($(...), `...`) |
Executed | yes |
*syntax.ProcSubst (<(...), >(...)) |
Executed | yes |
*syntax.Assign (rhs) |
Data | no |
Critical: mvdan/sh does NOT auto-unwrap executor wrappers. sudo rm -rf / parses as CallExpr{Args: ["sudo", "rm", "-rf", "/"]}. Without explicit unwrapping, the rm-rule's keyword set never matches. Implement an explicit unwrap pass.
| Wrapper | Behavior |
|---|---|
sudo, doas |
Skip flags (-u USER, -g GROUP, -C N take an arg; -i, -s, -n, -E, -H, -k standalone), then unwrap the inner command. |
env |
Skip leading KEY=VALUE assignments and flags (-i, -u VAR, --), then unwrap. |
command, builtin, exec |
Skip -p, -v, -V (or -c/-l for exec), then unwrap. |
time, /usr/bin/time, nice, nohup, ionice, setsid, script -q -c |
Skip until non-flag token, then unwrap. |
timeout, gtimeout |
Skip first non-flag (duration), then unwrap. |
chroot |
Skip newroot path + optional user, mark inner as Chrooted=true. Default ask — paths inside refer to a different fs root. |
ssh, rsync --rsync-path=, scp (only when remote command tail visible) |
Mark as Remote=true. Local safe-path allowlist does NOT apply. Default ask if remote command contains a trigger keyword. |
bash -c, sh -c, zsh -c, dash -c, fish -c, ksh -c |
Re-parse the next quoted arg as bash with mvdan/sh, recursively unwrap. |
eval |
Concatenate args with space, re-parse as bash, unwrap. |
xargs |
Mark target command as StdinArgs=true. Default ask if target is a trigger keyword. |
find (with -delete, -exec rm ..., -execdir rm ..., -okdir rm ...) |
Synthesize a virtual rm ExecutedCommand whose target paths are the search roots (path arguments of find before the predicate). |
parallel, watch, flock, at, crontab -e -c |
Same as xargs — args dynamic, ask if trigger keyword. |
Unwrapping is recursive: sudo timeout 10 bash -c "env FOO=1 rm -rf /tmp/x" resolves to a single Executed rm -rf /tmp/x.
echo "rm -rf /" | bash — mvdan/sh parses as a pipeline of two CallExprs: echo and bash. We detect the shape (last stage is bash/sh/zsh/dash with no -c) and re-parse the upstream echo/printf literal arg as bash. Limitation: arbitrary upstream computation (grep, tr, sed | bash) is NOT statically reducible — for those, default to ask because the sink is a shell evaluator with unknown input. Documented in §9.
Static set built at startup from Rule.Triggers() of every registered rule:
var triggerRE = regexp.MustCompile(`\b(?:` + strings.Join(escaped, "|") + `)\b`)
func quickReject(cmd string) bool {
return !triggerRE.MatchString(cmd)
}Runs in microseconds. False positives here are fine (e.g., find in heredoc body) — the parser then classifies it as HeredocBody and rules don't fire.
| Failure | Decision | Rationale |
|---|---|---|
| Quick-reject doesn't match → no parsing → done | allow |
Trivial commands stay fast. |
| Parse error in mvdan/sh BEFORE any rule trigger | allow |
Malformed-but-trigger-free command — bash itself will reject. |
| Parse error AFTER trigger keyword detected by quick-reject | ask with reason bash-guard unavailable for risky command |
Silent allow on a triggered command would disable safety on risky path. |
| AST walk panics, rule eval crashes | ask (recover with defer/recover()) |
Same logic — risky path, must surface. |
| Config load fails | allow + log to stderr |
Config error during normal startup; not blocking user. |
| Binary exec fails (wrapper) | allow + reason |
Build broken; user sees explanation in dialog. |
| Audit log open fails | continue, log to stderr | Logging is best-effort. |
This asymmetry was a P0 from Codex. Closes the silent-disable gap.
type Decision struct {
Level string // "allow" | "ask" — never "deny"
Rule string
Reason string
ReasonCode string // stable identifier for tests, e.g., "rm.outside_safe_path"
Context string // additionalContext payload
}
func Aggregate(ds []Decision) Decision {
asks := []Decision{}
for _, d := range ds { if d.Level == "ask" { asks = append(asks, d) } }
if len(asks) == 0 {
return Decision{Level: "allow", Rule: "default", ReasonCode: "no_rule_matched"}
}
// Concatenate reasons; preserve all reason_codes for log.
return Decision{
Level: "ask",
Rule: "aggregate",
Reason: strings.Join(reasons(asks), "; "),
ReasonCode: strings.Join(codes(asks), ","),
Context: strings.Join(contexts(asks), " | "),
}
}No internal deny tier. Compile-time invariant: the type only allows "allow" and "ask" (we use a typed enum, see §3.7 in code). The Codex output adapter is the only place that emits permissionDecision: "deny", because Codex does not support ask for PreToolUse.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "[rm] target /etc resolves outside safe paths",
"additionalContext": "code:rm.outside_safe_path target=/etc safe_paths=/home/example-user/myproject,/tmp,/var/tmp,$TMPDIR,~/.cache"
}
}Always exit 0 — Claude Code only parses JSON on exit-0.
var triggers = []string{"rm", "unlink", "rmdir", "shred"}Executor wrappers (sudo, env, xargs, find, bash -c, eval, ...) are NOT triggers — they're handled by the parser unwrap pass in §3.4. By the time rm-rule sees an ExecutedCommand, it's already unwrapped to the bare rm form.
Default allowlist (resolved with filepath.EvalSymlinks for the parent dir, os.Lstat for the final operand):
cwdand any path in its subtree, except whencwd ∈ {/, $HOME, /Users, /home}(consilium P0 from Gemini — cwd-as-root would whitelist everything; in those cases cwd contributes nothing to safe paths)./tmp,/private/tmp,/var/tmp$TMPDIRif set$XDG_CACHE_HOME(default~/.cache)$XDG_RUNTIME_DIR~/Library/Caches(macOS only)- Per-project safe paths from a trusted project config (see §6.2).
POSIX rm does NOT follow symlinks unless trailing slash. So:
rm /tmp/link-to-home(no trailing slash): deletes the symlink itself, not its target. Classify bylstat()of the operand: it's a symlink, parent is/tmp(safe), so allow. Don't realpath.rm /tmp/link-to-home/(trailing slash): dereferences the link. Realpath the target →~. Catastrophic. Ask.rm -rf /tmp/dir/with/link-insidewherelink-inside → ~: rm itself does not traverse into the symlink. Allow. (This is not a TOCTOU vulnerability of the hook — it's how rm works.)
/, $HOME, ~, /etc, /usr (except /usr/local/<scope>), /var (except /var/tmp), /System, /Library, /private (except /private/tmp, /private/var/tmp), /Applications, /bin, /sbin, /opt. All variants with realpath dereferencing too.
For each non-flag argument of rm:
| Resolution | Outcome |
|---|---|
| Path resolves inside a safe path | allow |
Path is exactly a safe-path root (rm -rf /tmp itself) |
ask (code: rm.delete_safe_root) |
Top-level glob inside a shared safe root (rm -rf /tmp/*, rm -rf /var/tmp/.*) |
ask (code: rm.shared_root_glob) |
| Path is a catastrophic path (or contains its prefix) | ask (code: rm.catastrophic) |
Path is . or $(pwd) and cwd is one of {/, $HOME, ...} |
ask (code: rm.cwd_is_root_or_home) |
Path is unresolvable ($VAR undefined, $(cmd), glob * outside cwd) |
ask (code: rm.unresolvable) |
| Path resolves outside safe paths but is not catastrophic | ask (code: rm.outside_safe_path) |
If any single argument warrants ask, the whole command is ask. Reason concatenates per-arg codes.
The rm rule's argument parser handles real GNU rm (and BSD rm) syntax:
--end-of-options sentinel (everything after is operands, even-rf).- Combined short flags:
-rf,-fr,-rfv,-RF. - Long flags with
=:--recursive=always,--interactive=never. - Repeated
-i/-f: last one wins (POSIX semantics). --no-preserve-rootflag (note in additionalContext as escalation indicator).- Operands beginning with
-after--: literal filename, e.g.,rm -- -rfdeletes a file named-rf.
Same logic for rmdir -p, shred --remove[=HOW], unlink.
# allow
rm -rf node_modules
rm /tmp/foo.txt
rm -rf /tmp/ci-results # FIXES FP-2
rm -- -rf # literal "-rf" file in cwd
git commit -m "Fix rm -rf detection bug" # rm in Data span only
echo "rm -rf /" | tee /tmp/joke.txt # rm in Data span only
cat > /tmp/x <<'EOF' # FIXES FP-1
We use find and rm a lot.
EOF
# ask
rm -rf /tmp # safe root itself
rm -rf /tmp/* # shared root top-level glob
rm -rf "$HOME/Downloads/old" # catastrophic prefix
rm -rf /etc/nginx # catastrophic /etc
rm -rf / # catastrophic / — still ASK, not deny
sudo rm -rf /var/log/old.log # /var/log not in safe paths
rm -rf "$UNDEFINED_VAR/important" # unresolvable
echo 'rm -rf /' | bash # pipe into shell evaluator
ssh server 'rm -rf /' # remote — local allowlist N/A
chroot /mnt rm -rf /etc # chrooted — paths reinterpreted
xargs -a list rm -rf # stdin args dynamicStandard Go testing. Golden-table fixtures under testdata/fixtures/:
{
"name": "rm_rf_tmp_subdir",
"description": "FP-2 regression (2026-05-03)",
"input": {
"tool_name": "Bash",
"tool_input": { "command": "cd /tmp && rm -rf ci-results && mkdir ci-results" },
"cwd": "/home/example-user/myproject"
},
"expect": {
"decision": "allow",
"rule": "default",
"reason_code": "no_rule_matched"
}
}Tests assert on the (decision, rule, reason_code) tuple plus optional reason_substring. Full reason strings are NOT in golden — they are stable for users but can change without breaking tests.
Regressions:
heredoc_with_apostrophes(FP-1)rm_rf_tmp_subdir(FP-2)
Span classification:
find_word_in_heredoc_bodyrm_word_in_quoted_stringgit_commit_m_rm_messagerm_word_in_command_substitution_data_argescaped_backslash_rm
rm safe-paths:
rm_rf_cwd_subdirrm_rf_tmp_subdir,rm_rf_var_tmp_subdir,rm_rf_xdg_cache_subdirrm_rf_TMPDIR_set,rm_rf_TMPDIR_unsetrm_rf_macos_cachesrm_dash_dash_target(rm -- -rf)
rm safe-root edge:
rm_rf_tmp_root_itself(ask)rm_rf_tmp_glob(ask)rm_rf_var_tmp_glob(ask)
rm catastrophic (all ask, no deny):
rm_rf_root,rm_rf_home,rm_rf_etc,rm_rf_usr_local,rm_rf_System,rm_rf_Library,rm_rf_var_logrm_rf_dollar_home,rm_rf_tilderm_rf_no_preserve_root
Symlink:
rm_symlink_no_trailing_slash_to_home(allow — link only, not target)rm_symlink_trailing_slash_to_home(ask — dereferences)
cwd-as-root:
cwd_is_home_rm_dot(ask)cwd_is_root_rm_dot(ask)cwd_normal_rm_dot(allow)
Parser-recursion / unwrap:
sudo_rm_rf_var_logenv_FOO_rm_rf_etcbash_dash_c_rm_rf_etcsh_dash_c_rm_in_safe_path(allow)eval_rm_stringfind_exec_rm_in_cwd(allow)find_delete_etc(ask)xargs_rm_via_stdin(ask)ssh_remote_rm_root(ask)chroot_rm_etc(ask)command_wrapper_rm
Variable / glob:
rm_rf_undef_var(ask, code:rm.unresolvable)rm_glob_in_cwd(allow)rm_glob_outside_cwd(ask)
Pipe-to-shell:
echo_rm_pipe_bash(ask)printf_rm_pipe_sh(ask)cat_static_pipe_bash(ask, conservative)
Edge cases:
empty_command,comment_only_commandmulti_command_chain(a && b && rm /tmp/x)subshell_rm((rm -rf /tmp/x))process_substitution_with_rmhuge_heredoc_no_rm(10 KB, performance regression)malformed_unbalanced_quote(allow — pre-trigger fail-open)malformed_after_trigger(ask — post-trigger fail-open)
testdata/fixtures/perf/*.json — set of representative commands. go test -run TestPerformance asserts:
| Path | Budget |
|---|---|
| Quick-reject hit | < 1 ms (per-call median) |
| Full parse + rule eval | < 30 ms |
| Huge heredoc (10 KB) | < 50 ms |
Uses testing.B benchmarks plus a regression assertion.
Append-only JSONL at ~/.claude/logs/bash-guard.jsonl:
{"ts":"2026-05-03T18:00:00Z","mode":"live","decision":"allow","rule":"default","reason_code":"no_rule_matched","latency_ms":3,"command_hash":"sha256:...","command_len":42}
{"ts":"2026-05-03T18:00:05Z","mode":"live","decision":"ask","rule":"rm","reason_code":"rm.catastrophic","latency_ms":42,"command_hash":"sha256:..."}- File created with
0o600(consilium P2 from Codex). - Rotation: at startup, if size > 10 MB, rename to
bash-guard.jsonl.1(keep 3 generations). - Atomic append: each JSON line < PIPE_BUF (4 KB), Linux/macOS guarantee atomicity for
O_APPENDwrites that small. If a line would exceed PIPE_BUF, fall back toflock.
Env vars:
BASH_GUARD_LOG_COMMANDS=1— log raw command text (default off, privacy).BASH_GUARD_DEBUG=1— mirror to stderr.BASH_GUARD_SHADOW=1— log decisions, but always emitallowJSON (phase 0 of rollout).BASH_GUARD_DRY_RUN=1— same as shadow, distinct log mode label (phase 1 of rollout).BASH_GUARD_FORCE_REBUILD=1— wrapper doesgo build -a.
Confirmed from official docs:
Input:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "..." },
"session_id": "...",
"cwd": "/abs/path",
"permission_mode": "default"
}Output (single JSON, exit 0):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|ask",
"permissionDecisionReason": "...",
"additionalContext": "..."
}
}We do NOT use updatedInput (rewrites change semantics — see §9). We do NOT emit defer (no use case in this hook).
[mode]
default = "live" # "live" | "shadow" | "dry-run"
[safe_paths]
extra = [] # absolute paths added to the global allowlist
[catastrophic_paths]
extra = [] # additional paths beyond the hardcoded set
[rules]
disabled = [] # e.g., ["docker"] to disablePer-project configs at <repo>/.claude/bash-guard.toml are NOT auto-loaded. They are advisory. To be trusted:
- The project's absolute root path must appear in
~/.claude/hooks/bash-guard/trusted-projects.toml:
[[trusted]]
root = "/home/example-user/myproject"
# extra safe paths to honour from this project's config (else the per-project config is ignored entirely)
honor_safe_paths = true
honor_catastrophic_overrides = false # NEVER allow projects to relax catastrophic rules-
Even trusted project configs cannot:
- Add safe paths outside the project root (
/etc,/,/home/other-user, etc. — rejected with stderr warning). - Disable catastrophic-paths protection.
- Disable rules that aren't already in the global
disabledlist.
- Add safe paths outside the project root (
-
When an untrusted project config is detected, bash-guard logs once per session (deduped by command_hash window):
bash-guard: untrusted project config at <path>; ignored. To trust, add to ~/.claude/hooks/bash-guard/trusted-projects.toml.
This is the consilium fix. Without it, an agent cloning a repo with a hostile .claude/bash-guard.toml could whitelist /.
~/.claude/hooks/validate-rm.py~/.claude/hooks/safety-net-ask.sh- Their entries in
~/.claude/settings.jsonhooks.PreToolUse[Bash].hooks. enabledPlugins["safety-net@cc-marketplace"](no longer referenced).
After phase 1 ships and runs clean for ≥7 days:
supabase-safety.sh→rules/supabase.gobw-permission-check.sh→rules/bw.godocker-prune-permission.sh→rules/docker.goinfra-safety.sh→rules/infra.go
Phase 2 reduces the hook chain from 6 entries to 1.
protect-files.sh(Write|Edit, different concern)~/.claude/ccnotify/ccnotify.py(UserPromptSubmit/Stop/Notification)
Phase 0 — Shadow (1-3 days): Install bash-guard alongside existing hooks. BASH_GUARD_SHADOW=1. Logs decisions but always emits allow (no behavioural change). Decision still comes from the legacy chain. Goal: collect log data, identify any new FPs, verify no regressions.
Phase 1 — Sole evaluator, dry-run (4-7 days): Remove validate-rm.py and safety-net-ask.sh from settings.json. bash-guard runs alone with BASH_GUARD_DRY_RUN=1 — still always allow, but log would_decision distinctly from effective_decision. Goal: prove bash-guard's decisions are correct without the legacy hooks shadowing them.
Phase 2 — Live: Remove BASH_GUARD_DRY_RUN. Decisions enforced. Old hook scripts can be deleted from disk.
Rollback: at any phase, set BASH_GUARD_SHADOW=1 instantly disables enforcement.
# Idempotent installer
# - symlinks ai-driven-development/hooks/balanced-safety-hooks/src to ~/.claude/hooks/bash-guard
# - ensures Go toolchain
# - first build (warm cache)
# - patches ~/.claude/settings.json: append bash_guard hook with BASH_GUARD_SHADOW=1Patching settings.json uses a JSON-aware tool (jq) — never sed-based, to avoid corrupting the user's config.
- Cold-start latency → answered by Go switch. < 30 ms cold including build no-op. Vendored pre-built binary not needed.
- Catastrophic-as-ask → ask is enough. No multi-step confirmation. Memory:
feedback_no_deny_in_hooks.md. - Parser gaps → mvdan/sh covers everything tree-sitter-bash issues #282/#283 surfaced (it's the more mature parser);
bash -nno longer needed as pre-filter. - Symlinks →
lstat()for the operand; trailing slash dereferences. Implemented in §4.2. - Cross-process correctness → single decision per Bash invocation. Long chains decided at submission.
- Plugin packaging → personal hook in
~/.claude/hooks/bash-guard/. Plugin packaging only after stable rules + trust system. Both panelists agreed. - Quick-reject FN → fine; parser handles the FP cleanly. No per-rule budget cap needed.
- Aggregation phrasing → one-line summary in
permissionDecisionReason, multi-line context inadditionalContext. Reason carries all per-rule codes for log/test asserts. bash -nportability → not used (mvdan/sh replaces it). Closes the macOS bash 3.2 vs 5.x divergence.- Test brittleness →
(decision, rule, reason_code)tuple + optional reason substring. No full reason-string snapshots.
These are documented intentionally so future readers don't try to "fix" them via shortcuts that break security:
- TOCTOU — bash-guard inspects the command at submission. Filesystem state can change during execution. Example: agent runs
ln -s /etc /tmp/x && rm -rf /tmp/xin a single Bash call. Static analysis seesln -s ... && rm -rf /tmp/x, the latter classified as/tmpsubdir = allow. Mitigation: when a Bash command chainsln -sand any rm/unlink/rmdir, ask. Rulerm.symlink_after_createcovers this. updatedInputrewrites — not used in phase 1. Rewriting destructive commands changes semantics (e.g.,rm -rf→trashis not a no-op). Out of scope.- Pipeline-tail with non-literal upstream —
cat manifest | bashcannot be statically reduced (manifest contents unknown). bash-guard asks for any pipeline whose last stage is a shell evaluator with non-literal upstream. Conservative. rm -rfinside subshell with cwd change —( cd /etc && rm -rf nginx ). We unwrap the subshell, see thecd /etcthen therm -rf nginx. The rm rule resolvesnginxagainst/etc(lexical cwd from precedingcd), classifies as catastrophic → ask. Tested.- Background processes —
rm -rf /tmp/x &parsed normally; the&is a stmt terminator. Handled. - Custom shells — fish, ksh, busybox-ash with
-care unwrapped same way. Bash-only constructs in fish source may parse differently — ask if mvdan/sh raises. - Symlink trailing-slash race — between
lstat()time and bash's actualunlink/rmdirsyscall, the symlink could be replaced. We accept this race; the user-permission dialog adds another layer.
- Claude Code hook docs: code.claude.com/docs/en/hooks. Hooks run regardless of permission mode (including
--dangerously-skip-permissions). We use onlyallowandask. - mvdan/sh Go bash parser: github.com/mvdan/sh. Active, used by
shfmt, full POSIX + bash extensions support. destructive_command_guardreference impl: Dicklesworthstone/destructive_command_guard — span-classification approach this design borrows.safe-rmfamily: shell-safe-rm, careful_rm, trash-cli — useful for the safe-paths list.- POSIX rm(1): https://pubs.opengroup.org/onlinepubs/9699919799/utilities/rm.html
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "BASH_GUARD_SHADOW=1 ~/.claude/hooks/bash-guard/bash_guard.bin" } ] }