The dangerous part of an autonomous coding session is rarely the code. It is the one shell command that looked ordinary in the transcript and was irreversible in the filesystem.

Claude Code already has permission rules, but those rules are easy to make too broad. A standing approval for Bash can remove the pause you were relying on. A prompt instruction can tell Claude not to run rm -rf, but it cannot enforce that promise. A PreToolUse hook can.

Claude Code hook resolution flow showing a PreToolUse guard blocking a Bash command

This is the small setup I would use before allowing an agent to work unattended in a real repository: start with 1 hook that blocks a short list of destructive patterns, plus native permissions for everything else. It is boring. That is exactly why it is useful.

The guard that actually blocks commands

Create a hook directory in the repository:

mkdir -p .claude/hooks

Save this as .claude/hooks/block-dangerous.sh:

#!/usr/bin/env bash
set -euo pipefail

payload=$(cat)
command=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')

if printf '%s' "$command" | grep -Eq '(^|[;&|])\s*(sudo\s+)?rm\s+-[[:alnum:]]*r[[:alnum:]]*f|git\s+push\s+.*(--force|-f)|git\s+reset\s+--hard|DROP\s+(TABLE|DATABASE)|mkfs\.|dd\s+if=.*of=/dev/'; then
  jq -n --arg reason "Blocked by the repository safety hook: $command" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}}'
  exit 0
fi

# No JSON means the normal Claude Code permission flow continues.
exit 0

Make it executable:

chmod +x .claude/hooks/block-dangerous.sh

The script reads the JSON event from standard input, extracts the proposed Bash command, and returns a structured deny decision only when a pattern matches. The official reference uses the same basic shape: PreToolUse matches the Bash tool, the hook inspects tool_input.command, and a permissionDecision of deny cancels the call before execution.

Wire it into .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

Test the script without starting Claude Code. This catches missing jq, quoting errors, and a bad JSON response:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"}}' \
  | .claude/hooks/block-dangerous.sh

echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | .claude/hooks/block-dangerous.sh

The first command should print JSON containing permissionDecision set to deny. The second should print nothing and exit successfully. That empty pass-through matters. It leaves the regular permission system in charge of commands the hook does not understand.

Do not turn this into a giant regex on day one. Start with commands that are difficult to undo: recursive force deletion, force pushes, hard resets, disk formatting, raw writes to devices, and destructive SQL. Add a rule after a real incident or a clear project requirement. A denylist that tries to understand every shell grammar edge case will become a false-positive machine.

The trap that silently removes your prompts

The most dangerous snippet in a safety hook is not the deny branch. It is the apparently helpful default branch:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow"
  }
}

That output says the hook has approved the call. If you return it for every command that did not match your dangerous-pattern list, you may skip the native approval prompt for all of those commands. The hook has changed from a narrow blocker into an approval system, and your regex is now the only thing standing between the agent and execution.

For a blocker, the safe default is usually simpler: output structured JSON when you want to deny, then exit 0 with no decision when you have no opinion. The official documentation distinguishes 3 outcomes for permission handling, allow, ask, and deny. A scoped deny rule keeps the tool available but stops matching calls. Native permissions can still ask about everything outside the hook.

This is also why the hook should not contain an allow response merely to make the output look complete. Empty output is not a failure. It means the hook passed control back to Claude Code.

There is a second boundary that people miss. A hook is not a replacement for operating-system isolation. A Bash hook sees the command string Claude proposes. It does not prove what a shell script, alias, downloaded program, or nested process will do after launch. For higher-risk work, use a disposable container or VM, restrict credentials, and keep the repository copy separate from production data. The hook reduces accidental damage. It does not make an untrusted process trustworthy.

Put the rule where it can travel

Use .claude/settings.json when the rule belongs to the repository and should be reviewed with the code. Anthropic's settings documentation says this file is the shared project scope, so you can commit it and give teammates the same hook configuration. Keep personal experiments in .claude/settings.local.json; that file is intended for machine-specific settings and should stay out of version control. A global hook in ~/.claude/settings.json is useful for your own baseline, but it will not travel with a project.

Review the hook like code. Give it a small test input file or shell test. Check that jq exists on the machines that will run it. Run the harmless path as well as each blocked path. Then inspect the result inside Claude Code with /hooks and use debug logging if the hook appears not to run.

If you also want tests after edits, add a separate PostToolUse hook for Edit|Write. Keep it asynchronous when the test suite takes time. The official reference shows an async test hook with a 120 seconds timeout. That is a useful ceiling for feedback, but it is not a reason to make every hook slow. A security blocker should return almost immediately.

The practical policy is straightforward:

  • Put irreversible operations in the deny hook.
  • Leave unknown commands to native permissions.
  • Commit project policy, but keep personal overrides local.
  • Test the hook outside the agent before trusting it inside the agent.
  • Use a container or VM when the agent has access to sensitive data.

I would install this before enabling any unattended mode. The goal is not to make Claude Code timid. The goal is to make one class of mistake impossible while preserving the useful friction everywhere else. That is a much better trade than approving Bash once and discovering later that the approval covered more than you thought.

Sources