How to Block Dangerous Commands With Claude Code Hooks (2026)

Nov 10, 2026 · 15 min · Ajay Kumar

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 bare terraform apply. Put the * after the subcommand, never before it.
  • Deny and ask rules apply to every subcommand of a chain, so cd infra && terraform apply is caught. A deny rule also matches past leading variable assignments such as TF_LOG=debug terraform apply.
  • Read deny rules use gitignore patterns, and bare filenames match at any depth. They also block Edit and Write on the same path.
  • disableBypassPermissionsMode set to "disable" keeps people from starting the session with --dangerously-skip-permissions in 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.permissionDecision set to allow, deny, ask or defer, plus permissionDecisionReason. For deny, 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: 10 overrides 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.

What a Bash call passes through before it runs (Claude Code 2.1.322) Claude proposes Bash 1. PreToolUse hook deny or exit 2: block failure also blocks 2. Permission rules deny, then ask, then allow beat a hook's allow 3. Mode manual: prompt auto: classifier 4. Sandbox OS limits on files and network Command runs Blocked, reason shown to Claude or a prompt for an ask rule Hooks fire in every permission mode, including bypassPermissions and dontAsk. Exit code 1 is a non-blocking error: the command runs. Use 0 with JSON, or 2. Hooks and rules match command text. The sandbox and the credentials in the shell are the real boundaries.
Evaluation order for a Bash tool call, from the Claude Code hooks reference, permissions page and sandboxing page as of 10 November 2026.

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