A PreToolUse hook for the Bash tool that auto-approves read-only commands and can hard-block dangerous ones via config. Works with Crush and Claude Code.
Note: I use crushout with Crush. The Claude Code protocol is implemented based on their docs but I haven't personally verified it end-to-end. Bug reports welcome.
When an agent runs git status, ls -la, or cat file.txt, crushout skips the permission prompt. When it runs rm -rf /, git push, or anything it can't confidently classify, crushout stays silent and the normal permission flow takes over. With a .crushout.yml config, you can also hard-deny specific commands so they're blocked outright.
crushout parses bash commands with tree-sitter and walks the AST to extract every individual command. It checks each one against a recursive rule system:
- Non-mutable - always allowed (
ls,cat,grep,git status,go test) - Mutable - always requires confirmation (
rm,sudo,git push,make) - Flag-dependent - allowed unless a dangerous flag is present (
sedis fine,sed -iis not;findis fine,find -execis not) - Subcommand-dependent - resolved recursively (
git remote -vβ,git remote addβ;go mod graphβ,go mod tidyβ) - Unknown - requires confirmation (deny by default)
If any command in a chain (&&, ||, |, ;) is mutable, the whole thing goes through the normal permission prompt.
crushout tracks working directory across cd calls. If a cd would move outside the initial project root, the command is not auto-approved.
git -C, git --work-tree, and git --git-dir are no auto-approved as they bypass the tracked working directory.
Commands are rejected from auto-approval (not denied, just not fast-tracked) if they contain:
- Command substitutions:
$(...) - Process substitutions:
<(...) - Subshells:
(...) - Arithmetic expansion:
$((...)) - Parse errors
- Dynamic command names like
$CMD
If rtk is installed in $PATH, crushout passes non-denied commands through rtk rewrite for Crush or Claude Code to run instead.
This is enabled by default. Disable it in .crushout.yml:
rtk_rewrite: falseThe rewrite is invisible to the checker as it runs after the allow/deny decision. If rtk is not installed, the command passes through unchanged.
go build -o crushout ./cmd/crushoutMove the binary somewhere on your $PATH, or use a full path in the config.
Add to your project's .crush.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^bash$",
"command": "crushout",
"timeout": 5
}
]
}
}Add to your project's .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "crushout"
}
]
}
]
}
}Or use a full path in either:
"command": "/usr/local/bin/crushout"| Command | Result | Why |
|---|---|---|
ls -la |
β auto | always non-mutable |
cat file | grep foo | sort |
β auto | all non-mutable |
git status |
β auto | read-only subcommand |
git diff HEAD~3 |
β auto | read-only subcommand |
git log --oneline -10 |
β auto | read-only subcommand |
git remote -v |
β auto | nested: remote β -v |
git remote show origin |
β auto | nested: remote β show |
git branch -l |
β auto | nested: branch β -l |
git branch --show-current |
β auto | nested: branch β --show-current |
git tag -l |
β auto | nested: tag β -l |
git stash list |
β auto | nested: stash β list |
git config get user.name |
β auto | config with list/get |
find . -name '*.go' |
β auto | find without -exec/-delete |
sed 's/old/new/g' file |
β auto | sed without -i |
go test ./... |
β auto | read-only subcommand |
go mod graph |
β auto | nested: mod β graph |
cd src && ls -la && cd .. |
β auto | cd stays within root |
pwd && whoami && uname -a |
β auto | all non-mutable |
jq '.name' data.json |
β auto | always non-mutable |
sha256sum file |
β auto | always non-mutable |
xxd file.bin |
β auto | always non-mutable |
yq '.key' file.yaml |
β auto | yq without -i |
cargo test |
β auto | read-only subcommand |
cargo tree |
β auto | read-only subcommand |
gh pr list |
β auto | nested: pr β list |
gh repo view owner/repo |
β auto | nested: repo β view |
kubectl get pods |
β auto | read-only subcommand |
kubectl logs my-pod |
β auto | read-only subcommand |
docker ps |
β auto | read-only subcommand |
docker images |
β auto | read-only subcommand |
docker volume ls |
β auto | nested: volume β ls |
rm -rf / |
π prompt | mutable command |
sudo rm -rf / |
π prompt | mutable command |
git push |
π prompt | mutable subcommand |
git commit -m 'fix' |
π prompt | mutable subcommand |
git branch new-feature |
π prompt | branch without list flag |
git branch -D old |
π prompt | -D in deny flags |
git tag v1.0.0 |
π prompt | tag without -l |
git stash |
π prompt | bare stash = push |
git -C /tmp status |
π prompt | -C is denied |
git config --global user.name X |
π prompt | --global is denied |
find . -exec rm {} \; |
π prompt | -exec is denied |
sed -i 's/old/new/g' file |
π prompt | -i is denied |
go mod tidy |
π prompt | nested: mod β tidy |
cd /tmp && ls |
π prompt | cd escapes root |
cd .. && ls |
π prompt | cd escapes root |
echo $(whoami) |
π prompt | command substitution |
make build |
π prompt | mutable command |
docker run nginx |
π prompt | mutable subcommand |
npm install |
π prompt | mutable command |
unknown_cmd --foo |
π prompt | not in rules |
curl https://example.com (with rules: {curl: deny}) |
π« deny | hard-denied by config |
kubectl exec -it pod -- bash (with deny rule) |
π« deny | hard-denied by config |
crushout/
βββ cmd/crushout/main.go # entry point, crush protocol I/O
βββ internal/
β βββ hook/protocol.go # Hook interface, Crush/Claude Code protocol types
β βββ bash/parse.go # tree-sitter parsing and AST traversal
β βββ rewrite/rewrite.go # rtk rewrite integration
β βββ config/config.go # .crushout.yml loading and merging
β βββ rules/rule.go # recursive Rule type and resolution
β βββ rules/defaults.go # built-in rule definitions
β βββ checker/checker.go # orchestrator, path tracking
βββ go.mod
βββ go.sum
Edit internal/rules/defaults.go. The rule type is recursive:
var Default = map[string]*Rule{
"my-tool": {
Default: rules.Allow, // allow unknown subcommands
DenyFlags: []string{"--dangerous"}, // prompt on these flags
Subcommands: map[string]*Rule{
"read": {Default: rules.Allow},
"write": {Default: rules.NoOpinion}, // prompt
"db": {
Subcommands: map[string]*Rule{
"migrate": {Default: rules.NoOpinion},
"seed": {Default: rules.NoOpinion},
},
},
},
},
}Resolution walks arguments left-to-right:
DenyFlagsare checked at each level against all remaining args- If an arg matches a
Subcommandskey, descend into that rule - When no deeper match is found, use
Default
Instead of editing the source, you can drop .crushout.yml or .crushout.yaml in your project root. crushout looks for it in the cwd passed by the hook (typically the repo root).
overwrite_defaults: false
rules:
nix:
decision: prompt
subcommands:
build:
decision: allow
git:
subcommands:
status:
decision: prompt # require confirmation even for statusRules can be written as a simple string instead of a full mapping:
rules:
ls: allow
rm: deny
kubectl:
decision: prompt # default if no subcommand matches
subcommands:
get: allow # allow `kubectl get *`
exec: # deny `kubectl exec *`
decision: deny
message: "no remote execution"The string form (allow, deny, or prompt) is equivalent to {decision: <value>}.
When overwrite_defaults: false (the default), user rules are deep-merged with the built-in rules. Your values win where they differ, but anything you omit is inherited from the defaults.
When overwrite_defaults: true, only the rules you specify are active; the built-ins are ignored entirely.
| Field | Type | Description |
|---|---|---|
rtk_rewrite |
bool | If true, pass commands through rtk rewrite. Default is true. |
overwrite_defaults |
bool | If true, ignore built-in rules. Default is false. |
rules |
map | Map of command name β rule. |
rules.* |
string or map | Shorthand (allow, deny, prompt) or full rule mapping. |
rules.*.decision |
string | Decision for unknown subcommands: allow, deny, or prompt. Defaults to prompt if not set. |
rules.*.deny_flags |
[]string | Flags that always require confirmation. |
rules.*.message |
string | Custom message shown when denied. Only used with decision: deny. |
rules.*.subcommands |
map | Recursive map of subcommand name β rule. |
Allow nix build but prompt for everything else under nix:
rules:
nix:
decision: prompt
subcommands:
build: allowPrompt on git status (normally allowed):
rules:
git:
subcommands:
status:
decision: promptHard-deny curl and kubectl exec:
rules:
curl: deny
kubectl:
decision: prompt
subcommands:
get: allow
exec:
decision: deny
message: "no remote execution in this project"Start fresh with only ls allowed (everything else is prompted):
overwrite_defaults: true
rules:
ls: allowcrushout implements the Crush PreToolUse hook protocol and the Claude Code PreToolUse hook protocol. It reads JSON on stdin, detects which format is being used based on the field names, and writes the correct output format on stdout.
Input:
{
"event": "PreToolUse",
"session_id": "abc123",
"cwd": "/home/user/project",
"tool_name": "bash",
"tool_input": { "command": "git status" }
}Output (auto-approve):
{"version": 1, "decision": "allow"}Output (auto-approve with rtk rewrite):
{"version": 1, "decision": "allow", "updated_input": {"command": "rewritten command"}}Output (no opinion, let the normal permission prompt handle it):
{}Output (no opinion with rtk rewrite):
{"updated_input": {"command": "rewritten command"}}Output (hard-deny):
{"version": 1, "decision": "deny", "reason": "crushout: no curl allowed (rule for 'curl')"}Input:
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "git status" }
}Output (auto-approve):
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}Output (auto-approve with rtk rewrite):
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": {"command": "rewritten command"}}}Output (ask user, let the normal permission prompt handle it):
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask"}}Output (ask user with rtk rewrite):
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", "updatedInput": {"command": "rewritten command"}}}Output (hard-deny):
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "crushout: no curl allowed (rule for 'curl')"}}By default, crushout only has two outcomes: allow (auto-approve) and no opinion (fall through to the normal permission prompt). The built-in rules never deny, they either fast-track safe commands or get out of the way.
Through .crushout.yml, you can add explicit deny rules. A deny is final, the model sees the error and tries something else. This is useful when you want to block a command the defaults would otherwise allow (or just prompt for):
- Deny
kubectl execoutright instead of letting the user approve it each time - Deny
curlentirely in a sensitive project - Deny
git push --forcespecifically while still prompting for plaingit push
Use deny sparingly. The permission prompt already exists as the human-in-the-loop gate. False negatives (a safe command that isn't auto-approved) are just a minor inconvenience. False positives (a dangerous command that gets auto-approved) are the real threat, and the conservative default-unknown-to-prompt posture avoids them.
MIT