Skip to content

mariusvniekerk/claude-bash-approve

Repository files navigation

claude-bash-approve

A Claude Code PreToolUse hook that auto-approves safe Bash commands and blocks dangerous ones. Written in Go for fast startup.

Install

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+.

How it works

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
Loading

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.

Wrappers + Commands

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.

Alternative installation

install.sh

git clone https://github.com/mariusvniekerk/claude-bash-approve.git
cd claude-bash-approve
./install.sh

Builds the binary, creates ~/.claude/settings.json if needed, and adds the hook. Pass --force to merge into an existing settings file (requires jq).

Manual setup

  1. Clone this repo:
git clone https://github.com/mariusvniekerk/claude-bash-approve.git
  1. 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.

  1. The hook auto-compiles on first run. The run-hook.sh shim rebuilds the Go binary whenever source files change, so there's no manual build step.

Configuration

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).

Enabled / Disabled

# Approve everything except git push
enabled:
  - all
disabled:
  - git push
# Only approve git and shell commands
enabled:
  - git
  - shell

disabled always overrides enabled — use it to carve out exceptions.

Default decisions by command

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.

Available categories

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.

Telemetry

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.

Debugging

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.

Running tests

cd hooks/bash-approve
go test -v ./...

Discovering new patterns

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.

Adding new commands

  1. Add a NewPattern(...) entry to allCommandPatterns or allWrapperPatterns in hooks/bash-approve/rules.go
  2. Choose the right decision:
    • allow (default) — auto-approve
    • WithDecision("deny") + WithDenyReason("...") — block with reason
    • WithDecision("ask") — terminal prompt to user
    • WithDecision("") — no opinion, defer to next hook
  3. Add test cases in main_test.go
  4. Update the category listing in categories.yaml if introducing a new group
  5. Run go test ./...

About

An alternative take on permissions for claude code.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors