How to Block Dangerous Commands With Claude Code Hooks (2026)
This guide is for anyone who lets Claude Code work in a repository that touches real infrastructure and wants hard stops on the commands that hurt: terraform apply and destroy, kubectl delete, helm uninstall, rm -rf and force pushes. At the end you will have a project .claude/settings.json with deny, ask and allow rules, a PreToolUse hook script that denies those commands and blocks the call if the script itself breaks, a table-driven test you can run in CI, and the sandbox switched on in strict mode. Everything here was checked against the hooks reference and permissions docs for Claude Code 2.1.322 (the current npm release on 10 November 2026), and the hook was tested with jq 1.7.1 on macOS bash 3.2. You need Claude Code 2.1.295 or later for onFailure, jq, and a repository you can commit to. For why this matters and how other agents compare, read the companion post on AI coding agents for infrastructure in 2026.
Step 1: Check your version and pick the right settings file
Hooks and permission rules live in JSON settings files, and where you put them decides who they protect.
claude --version # expect 2.1.295 or later for "onFailure"
npm view @anthropic-ai/claude-code version # latest published, 2.1.322 on 2026-11-10
mkdir -p .claude/hooks
The hook locations table lists the scopes. For a team policy use the project file, because it is committed and reviewed like code:
| File | Scope | Use it for |
|---|---|---|
~/.claude/settings.json |
All your projects | Personal habits |
.claude/settings.json |
This repository, committed | Team infra policy (this guide) |
.claude/settings.local.json |
This repository, not committed | Your own overrides |
| Managed settings | Whole organisation | Policy users cannot remove |
Hook entries merge across levels rather than replacing each other, and deny rules from any scope beat allow rules from any other. A project deny cannot be undone by a user allow.
Step 2: Add permission deny, ask and allow rules
Rules are the first, cheapest layer. Claude Code evaluates them in a fixed order: deny, then ask, then allow, and the first match wins regardless of how specific the rules are. A broad deny always beats a narrow allow.
Create .claude/settings.json:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Bash(terraform apply *)",
"Bash(terraform destroy *)",
"Bash(tofu apply *)",
"Bash(tofu destroy *)",
"Bash(kubectl delete *)",
"Bash(helm uninstall *)",
"Bash(rm -rf *)",
"Read(**/*.tfstate)",
"Read(**/*.tfstate.backup)",
"Read(.env)",
"Read(~/.aws/**)",
"Read(~/.kube/config)"
],
"ask": [
"Bash(kubectl apply *)",
"Bash(helm upgrade *)",
"Bash(git push *)"
],
"allow": [
"Bash(terraform fmt *)",
"Bash(terraform validate)",
"Bash(terraform plan *)",
"Bash(tflint *)",
"Bash(kubectl get *)",
"Bash(kubectl diff *)"
],
"disableBypassPermissionsMode": "disable"
}
}
A few details from the rule syntax docs that matter here:
Bash(terraform apply *)with a space before*also matches bareterraform apply. Put the*after the subcommand, never before it.- Deny and ask rules apply to every subcommand of a chain, so
cd infra && terraform applyis caught. A deny rule also matches past leading variable assignments such asTF_LOG=debug terraform apply. Readdeny rules use gitignore patterns, and bare filenames match at any depth. They also block Edit and Write on the same path.disableBypassPermissionsModeset to"disable"keeps people from starting the session with--dangerously-skip-permissionsin this repository.
Now the limitation, which the docs state plainly: a Bash rule matches the command text Claude writes, not the program. Bash(rm *) in deny stops rm -rf build/ but not /bin/rm -rf build/ or bash -c 'rm -rf build/', and Bash(git push *) does not stop git -C . push. That is why the next step exists.
Step 3: Write the PreToolUse hook script
A PreToolUse hook runs after Claude has built the tool call and before it executes. It receives JSON on stdin with tool_name, tool_input (for Bash, tool_input.command) and the common fields such as cwd and permission_mode. It answers in one of two ways:
- Exit 0 and print JSON with
hookSpecificOutput.permissionDecisionset toallow,deny,askordefer, pluspermissionDecisionReason. Fordeny, the reason is shown to Claude, so write it as an instruction. - Exit 2 with a message on stderr. That blocks the call, and the stderr text becomes the reason.
Any other exit code, including the conventional 1, is a non-blocking error and the command runs anyway, unless the hook also printed valid JSON that decides the outcome. The reference has a warning box about exactly this. A policy hook that exits 1 on a match enforces nothing.
Save this as .claude/hooks/block-dangerous.sh:
#!/usr/bin/env bash
# .claude/hooks/block-dangerous.sh
# PreToolUse hook for Claude Code: deny destructive infra and shell commands.
# Needs jq 1.7+. Works with macOS bash 3.2 and Linux bash 5.
set -uo pipefail
# Without jq we cannot read the input, so fail closed (exit 2 = block).
command -v jq >/dev/null 2>&1 || { echo "block-dangerous: jq not found" >&2; exit 2; }
input=$(cat)
tool=$(jq -r '.tool_name // empty' <<<"$input")
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
# Only shell tools carry a command string; let everything else through.
case "$tool" in Bash|Monitor|PowerShell) ;; *) exit 0 ;; esac
[ -z "$cmd" ] && exit 0
# Lowercase and squash newlines so 'Terraform\napply' style tricks still match.
norm=$(printf '%s' "$cmd" | tr '[:upper:]' '[:lower:]' | tr '\n\t' ' ')
# B = start of a word: line start or a char that cannot be part of a name.
B='(^|[^[:alnum:]_.-])'
# A = rest of one simple command (stops at ; & | so chains are checked per part).
A='([[:space:]][^;&|]*)?'
deny() {
local reason="$1"
# Optional audit trail; never write to stdout except the JSON below.
log="${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/blocked.log"
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$reason" "$cmd" >>"$log" 2>/dev/null || true
jq -n --arg r "$reason" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}
ask() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: $r
}
}'
exit 0
}
has() { printf '%s' "$norm" | grep -Eq -- "$1"; }
# 1. Terraform / OpenTofu / Terragrunt: no apply, destroy, state surgery.
if has "${B}(terraform|tofu|terragrunt)${A}[[:space:]](apply|destroy|import|force-unlock)([[:space:]]|$)" ||
has "${B}(terraform|tofu|terragrunt)${A}[[:space:]]state[[:space:]]+(rm|mv|push|replace-provider)([[:space:]]|$)"; then
deny "Blocked by policy: terraform/tofu apply, destroy and state changes run in CI after review, not from the agent. Run plan and show me the output instead."
fi
# 2. kubectl / helm: deletes and drains are blocked unless that same
# command is a dry run. Check each ; & | separated part on its own.
segments=$(printf '%s' "$norm" | tr ';&|' '\n\n\n')
while IFS= read -r seg; do
if printf '%s' "$seg" | grep -Eq -- "${B}kubectl([[:space:]].*)?[[:space:]](delete|drain)([[:space:]]|$)" ||
printf '%s' "$seg" | grep -Eq -- "${B}helm([[:space:]].*)?[[:space:]](uninstall|delete)([[:space:]]|$)"; then
printf '%s' "$seg" | grep -Eq -- "--dry-run=(client|server)" && continue
deny "Blocked by policy: kubectl delete/drain and helm uninstall are not allowed for the agent. Use --dry-run=server or kubectl diff and ask a human."
fi
done <<<"$segments"
# 3. rm with both recursive and force flags, in any order or spelling.
RM="${B}rm[[:space:]]+([^;&|]*[[:space:]])?"
if has "${RM}-[[:alnum:]]*r[[:alnum:]]*f" || has "${RM}-[[:alnum:]]*f[[:alnum:]]*r"; then
deny "Blocked by policy: rm -rf is not allowed. Delete specific files by name, or ask me to do it."
fi
if has "${RM}(-r|--recursive)([[:space:]]|$)" && has "${RM}(-f|--force)([[:space:]]|$)"; then
deny "Blocked by policy: rm -r -f is not allowed. Delete specific files by name, or ask me to do it."
fi
# 4. Force pushes need a human.
if has "${B}git${A}[[:space:]]push${A}[[:space:]](--force|--force-with-lease|-f)([[:space:]=]|$)"; then
ask "Force push requested. Confirm you really want to rewrite remote history."
fi
exit 0 # no opinion: normal permission rules decide
chmod +x .claude/hooks/block-dangerous.sh
echo ".claude/hooks/blocked.log" >> .gitignore
Why it is written this way. The word-boundary pattern B treats / as a boundary, so /usr/local/bin/terraform apply and /bin/rm -rf match, which the plain deny rules miss. Matching the whole string catches sh -c 'terraform apply -auto-approve'. terraform plan -destroy is allowed because a plan changes nothing, and terraform show apply.tfplan is allowed because apply must be a whole word. The kubectl check splits on ;, & and | so that kubectl delete x --dry-run=server && kubectl delete ns prod cannot ride on the first part's dry-run flag. Exiting 0 with no output means "no opinion", so your rules from Step 2 still decide everything else.
Step 4: Register the hook so it fails closed
Add a hooks block to the same .claude/settings.json, next to permissions:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh",
"args": [],
"onFailure": "block",
"timeout": 10,
"statusMessage": "Checking command against infra policy"
}
]
}
]
}
}
Each field is there for a reason, per the command hook fields:
matcher: "Bash"is an exact tool-name match. On PreToolUse it also covers commands Claude starts with the Monitor tool (v2.1.314 and later), and a deny works on those.args: []switches to exec form: the script is spawned directly with no shell, so a project path with spaces cannot break the command.onFailure: "block"is the important one. Without it, a hook that cannot start, crashes or times out is a non-blocking error and the command runs. With it, a missing script, a non-0/2 exit, invalid JSON or a timeout blocks the call. It requires v2.1.295.timeout: 10overrides the 600-second default for command hooks. A policy check that takes longer than ten seconds is broken.
Hooks run before permission rules, and a hook's deny holds in every mode, including bypassPermissions. The reverse is not true: a hook returning allow cannot override a deny or ask rule from settings.
Step 5: Test the hook by piping JSON into it
The hooks guide recommends testing a hook by piping sample JSON into it and checking the exit code. Do that before Claude ever runs it:
echo '{"tool_name":"Bash","tool_input":{"command":"terraform destroy"}}' \
| .claude/hooks/block-dangerous.sh; echo "exit=$?"
Expected output:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by policy: terraform/tofu apply, destroy and state changes run in CI after review, not from the agent. Run plan and show me the output instead."
}
}
exit=0
Then keep a table-driven test next to it, .claude/hooks/test-hook.sh, and run it in CI so nobody weakens a pattern by accident:
#!/usr/bin/env bash
# .claude/hooks/test-hook.sh: table-driven tests for block-dangerous.sh
hook="$(dirname "$0")/block-dangerous.sh"
fail=0
check() { # check <expected: deny|ask|none> <command>
got=$(jq -n --arg c "$2" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}}' \
| "$hook" | jq -r '.hookSpecificOutput.permissionDecision // "none"' 2>/dev/null)
got=${got:-none}
if [ "$got" = "$1" ]; then echo "ok $1 $2"; else echo "FAIL want $1 got $got $2"; fail=1; fi
}
check none "terraform plan -out=tfplan"
check none "terraform plan -destroy"
check deny "terraform apply tfplan"
check deny "terraform -chdir=envs/prod apply -auto-approve"
check deny "cd infra && tofu destroy"
check deny "sh -c 'terraform apply -auto-approve'"
check deny "terraform state rm aws_db_instance.main"
check none "kubectl get pods -A"
check deny "kubectl --context prod delete deploy api"
check none "kubectl delete deploy api --dry-run=server"
check deny "kubectl delete deploy api --dry-run=server && kubectl delete ns prod"
check deny "helm uninstall ingress -n ingress"
check deny "rm -rf build/"
check deny "rm -r -f node_modules"
check none "rm build/out.txt"
check ask "git push --force origin main"
check none "git push origin main"
exit $fail
When I ran it, all 17 cases passed and the script exited 0:
ok none terraform plan -out=tfplan
ok none terraform plan -destroy
ok deny terraform apply tfplan
ok deny terraform -chdir=envs/prod apply -auto-approve
ok deny cd infra && tofu destroy
ok deny sh -c 'terraform apply -auto-approve'
ok deny terraform state rm aws_db_instance.main
ok none kubectl get pods -A
ok deny kubectl --context prod delete deploy api
ok none kubectl delete deploy api --dry-run=server
ok deny kubectl delete deploy api --dry-run=server && kubectl delete ns prod
ok deny helm uninstall ingress -n ingress
ok deny rm -rf build/
ok deny rm -r -f node_modules
ok none rm build/out.txt
ok ask git push --force origin main
ok none git push origin main
Add a CI step that runs bash .claude/hooks/test-hook.sh on any change under .claude/. My GitHub Actions CI guide covers the workflow scaffolding.
Step 6: Turn on the sandbox in strict mode
Rules and hooks match text. The sandbox enforces at the OS level for the command and every child process: Seatbelt on macOS, bubblewrap and socat on Linux and WSL2. It is off by default. Add this to the same settings file:
{
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"failIfUnavailable": true,
"filesystem": {
"denyRead": ["~/.aws", "~/.config/gcloud", "~/.azure"]
},
"network": {
"allowedDomains": ["registry.terraform.io", "releases.hashicorp.com", "github.com"]
}
}
}
allowUnsandboxedCommands: false removes the escape hatch where Claude retries a failed command with dangerouslyDisableSandbox; the docs call this strict sandbox mode, and it is the Overrides tab in /sandbox. failIfUnavailable: true makes Claude Code exit at startup instead of quietly running unsandboxed when bubblewrap is missing. The allowed-domains list starts empty. A connection to any other host waits for a decision that depends on the permission mode: a prompt in manual mode, refused in auto and dontAsk mode, allowed in bypassPermissions unless network.strictAllowlist is on. On Linux, install the dependencies first:
sudo apt-get install bubblewrap socat # Debian/Ubuntu
sudo dnf install bubblewrap socat # Fedora
Check it from inside a session by asking Claude to run touch ~/sandbox-probe. Per the docs it should fail with Operation not permitted on macOS or Read-only file system on Linux. If it succeeds, the sandbox is not on; delete the file and run /sandbox.
Expect real plans to fail inside this sandbox, because your cloud API hosts are not on the list and your credentials are unreadable. That is intended. The agent drafts and validates; CI plans and applies with its own identity, which I set up in GitHub Actions OIDC for AWS, GCP and Azure.
Step 7: Verify it inside Claude Code
Start a session in the repository and check what loaded:
/hooks # read-only list; your hook should show under PreToolUse, from project settings
/permissions # every rule and the settings file it came from
Then ask Claude to run terraform destroy. You should see the call refused with your reason, and Claude should respond by offering a plan instead. To see exactly what happened, start with a debug log at a known path:
claude --debug-file /tmp/claude-hooks.log
# in another terminal
grep "Hooks: PreToolUse:Bash" /tmp/claude-hooks.log
On v2.1.296 and later each command hook leaves a line ending in its status and duration, such as finished with status 0 (31ms). Press Ctrl+O in the session to open the transcript view and see the block message inline.
Step 8: Enforce it for the whole organisation
A project file protects one repository and can be edited in a pull request. For policy that users cannot remove, put the same hook and deny rules in managed settings. Two keys are worth knowing: allowManagedHooksOnly blocks user, project, local and plugin hooks so only managed ones run, and allowManagedPermissionRulesOnly makes managed settings the only source of permission rules. A disableAllHooks: true in user or project settings cannot disable managed hooks. Roll these out with the same care as any fleet-wide config change; see the managed settings docs for delivery options.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook "blocks" but the command still runs | Script exits 1 on a match | Exit 2, or exit 0 with permissionDecision: "deny" JSON |
| JSON printed but ignored, no error | Shell profile echoes text before the JSON, or permissionDecision at top level |
Guard profile echo with [[ $- == *i* ]]; nest fields under hookSpecificOutput; search the debug log for Hook output does not start with { |
/hooks shows nothing |
Invalid JSON (trailing comma, comments) or wrong file path | jq empty .claude/settings.json; restart the session |
Every Bash call blocked with failed; blocking because onFailure is "block" |
Script missing, not executable, or jq absent |
chmod +x, check the path, install jq |
Editor flags onFailure as unknown |
The SchemaStore schema lags the CLI | Ignore the warning; the docs say a schema warning on a new key is not a config error |
| Hook never fires for one command | Matcher typo or wrong case (bash vs Bash) |
Matchers are case-sensitive; use exactly Bash |
Legit command denied, e.g. echo "never run terraform apply" |
The pattern matches text inside quotes | Accept it, or narrow the pattern; reword the command |
Allow rule ignored for terraform plan |
Project allow rules wait for workspace trust | Accept the trust dialog; claude -p in a never-trusted folder skips project allow rules |
Agent ran /usr/bin/terraform apply past a deny rule |
Rules match text, not programs | That is what the hook and sandbox are for; keep both |
FAQ
What is the difference between exit code 2 and permissionDecision deny?
Both block a PreToolUse call and both show Claude a reason. Exit 2 uses your stderr as the reason and ignores any JSON's power to override the block. Exit 0 with permissionDecision: "deny" gives structured output and lets one script also return ask or allow. Pick one style per hook.
Do Claude Code hooks still work in bypassPermissions mode?
Yes. PreToolUse hooks fire before any permission-mode check, and a hook's deny blocks the tool even with --dangerously-skip-permissions. A hook's allow, however, never overrides a deny or ask rule.
Can a hook stop Claude from reading secrets?
Partly. Use Read deny rules for files Claude's tools open, and a sandbox denyRead for anything a subprocess might read. Read rules do not cover a Python script that opens the file itself; the sandbox does.
Is a regex hook a security boundary?
No. It catches the commands an agent normally writes and many obfuscations, but base64 or variable indirection can defeat any text matcher. Treat it as a seatbelt, and make credentials and the sandbox the boundary.
The first thing I check on any repository where an agent runs is not the hook but the shell: which cloud credentials and kube contexts are reachable from it. Then I check that the hook has onFailure: "block", because a policy script that silently stops running is worse than none. In production, the agent never holds apply rights at all; it opens a pull request and CI does the rest. Official references: hooks reference, hooks guide, permissions, permission modes and sandboxing.
Related: AI Coding Agents for Infrastructure (2026): 91% Fix It, 39% Clean Up, Policy as Code for AI Agents in 2026: Cedar at the Gateway, Rego in the Hook, and the 11.2% the Model Still Lets Through and How to Run an AI Coding Agent in GitHub Actions Safely (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Add LLM Guardrails With Llama Guard and NeMo Guardrails (2026)
Step-by-step: run Llama Guard 3 on Ollama, wire it into NeMo Guardrails 0.24.1 as input and output rails, add a tool-call allowlist with IORails, serve it over HTTP, and red-team it with a script that reports block rate and latency.
13 minOct 20, 2026How to Defend LLM Apps Against Prompt Injection: 8 Steps (2026)
A practical prompt injection defence guide for LLM apps and agents: break the lethal trifecta, split privileged and quarantined models, gate tool calls in code, sanitise output, add Prompt Guard 2 as a tripwire, and red team with garak 0.17.1 and promptfoo 0.124.1.
17 minOct 20, 2026How to Run an AI Coding Agent in GitHub Actions Safely (2026)
Step-by-step hardening for claude-code-action v1.0.254 and codex-action v1.13: SHA pinning, least-privilege permissions, safe triggers, keeping prompt injection from issues away from secrets, tool allowlists, turn caps, rulesets and troubleshooting.
15 min