A Claude Code PreToolUse hook that auto-approves safe Bash commands and blocks dangerous ones. Written in Go for fast startup.
In Claude Code:
/plugin install github:mariusvniekerk/claude-bash-approve
That's it. The hook registers automatically and the Go binary compiles on first use. Requires Go 1.25+.
When Claude Code is about to run a Bash command, this hook intercepts it and makes one of four decisions:
- deny — command is blocked (with a reason shown to Claude)
- ask — recognized command, user is prompted to confirm (terminal — no further hooks run) (e.g.
git tag) - no opinion — hook has nothing to say, exits silently so the next hook in the chain can handle it (e.g.
git push,gh pr create, or unrecognized commands) - allow — command runs immediately, no prompt
flowchart TD
A["Parse command AST"] --> C{"All segments\nmatched?"}
C -->|No| NOP["**no opinion**\nnext hook in chain"]
C -->|Yes| priority
subgraph priority["Decision priority"]
D{"any segment\ndenied?"} -->|Yes| DENY["**deny**\nblock command"]
D -->|No| E{"any segment\nask?"}
E -->|Yes| ASK["**ask**\nprompt user"]
E -->|No| F{"any segment\nno-opinion?"}
F -->|No| OK["**allow**\nrun immediately"]
end
F -->|Yes| NOP
Commands are parsed into an AST (using mvdan/sh) so chained commands (&&, ||, ;, |), subshells, command substitutions ($(…)), and control flow (if, for, while) are all handled correctly — every segment must be safe for the whole command to be approved.
The hook uses a compositional model: a command is split into wrappers (prefixes like timeout 30, env, VAR=val) and a core command (like git status, pytest). Both are matched against regex patterns organized into categories.
git clone https://github.com/mariusvniekerk/claude-bash-approve.git
cd claude-bash-approve
./install.shBuilds the binary, creates ~/.claude/settings.json if needed, and adds the hook. Pass --force to merge into an existing settings file (requires jq).
- Clone this repo:
git clone https://github.com/mariusvniekerk/claude-bash-approve.git- Add the hook to your Claude Code settings (
~/.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/claude-bash-approve/hooks/bash-approve/run-hook.sh"
}
]
}
]
}
}Replace /path/to/ with the actual path to your clone.
- The hook auto-compiles on first run. The
run-hook.shshim rebuilds the Go binary whenever source files change, so there's no manual build step.
Command categories are configured in hooks/bash-approve/categories.yaml. When this file is absent or empty, all commands are approved (with some exceptions noted below).
# Approve everything except git push
enabled:
- all
disabled:
- git push# Only approve git and shell commands
enabled:
- git
- shelldisabled always overrides enabled — use it to carve out exceptions.
Most matched commands are auto-approved. Some have different defaults:
| Decision | Commands |
|---|---|
| deny (blocked, reason shown to Claude) | git stash, git revert, git reset --hard, git checkout ., git clean -f, rm -r, go mod vendor, roborev tui |
| ask (terminal, user prompted) | git tag |
| no-opinion (deferred to next hook) | git push, jj git push, gh pr create, go mod init |
To override a default, add the specific command name to enabled or disabled.
Coarse groups (enable/disable entire ecosystems):
wrapper, git, jj, python, node, rust, make, shell, gh, go, kubectl, gcloud, bq, aws, acli, roborev, docker, ruby, brew, shellcheck, grpcurl
Fine-grained names (within each group):
| Group | Names |
|---|---|
| wrapper | timeout, nice, env, env vars, .venv, bundle exec, rtk proxy, command, node_modules/.bin, absolute path |
| git | git read op, git write op, git push, git tag, git destructive (git stash, git revert, git reset --hard, git checkout ., git clean -f) |
| jj | jj read op, jj write op, jj git push |
| python | pytest, python, ruff, uv, uvx |
| node | npm, npx, node -e, bun, bunx, vitest |
| rust | cargo, maturin |
| shell | read-only, touch, mkdir, cp -n, ln -s, shell builtin, shell vars, process mgmt, eval, echo, cd, source, sleep, var assignment, shell destructive (rm -r) |
| go | go, go mod vendor, go mod init, golangci-lint, ginkgo |
| gh | gh read op, gh pr create, gh write op, gh api |
| kubectl | kubectl read op, kubectl write op, kubectl port-forward, kubectl exec, kubectl cp |
| docker | docker, docker compose, docker-compose |
| ruby | rspec, rake, ruby, rails, bundle, gem, rubocop, solargraph, standardrb |
See categories.yaml for the full reference with examples.
Every decision is logged to a local SQLite database (telemetry.db, next to the binary). This lets you review what the hook approved, denied, or passed through:
sqlite3 hooks/bash-approve/telemetry.db "SELECT ts, decision, command, reason FROM decisions ORDER BY ts DESC LIMIT 20"Telemetry is best-effort — if the database can't be opened or written to, the hook continues normally.
Test the hook directly by piping JSON to stdin:
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | \
go run ./hooks/bash-approve/Output is a JSON object with the decision:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"git read op"}}No output (exit 0) means the hook has no opinion.
cd hooks/bash-approve
go test -v ./...The project includes a Claude Code skill (.claude/skills/bash-approve-telemetry/) that queries the telemetry database to find commands that need new rules. Ask Claude to "check the telemetry for approval candidates" or invoke /bash-approve-telemetry.
- Add a
NewPattern(...)entry toallCommandPatternsorallWrapperPatternsinhooks/bash-approve/rules.go - Choose the right decision:
allow(default) — auto-approveWithDecision("deny")+WithDenyReason("...")— block with reasonWithDecision("ask")— terminal prompt to userWithDecision("")— no opinion, defer to next hook
- Add test cases in
main_test.go - Update the category listing in
categories.yamlif introducing a new group - Run
go test ./...