Claude Code hands an LLM the ability to run shell commands, edit files, and call tools with minimal supervision. Getting claude code hooks permissions right is the difference between a productive autonomous coding agent and a recursive rm -rf incident. This guide walks through the actual config schema, the hook execution model, and a pragmatic path to constrain behavior without grinding the agent to a halt.
1. The permission model in practice
Claude Code evaluates a two-tier list for every tool call: allow and deny. The deny list always wins. Rules match either the tool name alone (Bash) or a tool with a command pattern (Bash(git *)). Project-level settings live in .claude/settings.json; user-level in ~/.claude.json. The two files merge at runtime, but a deny entry in either file blocks the call.
{
"permissions": {
"allow": ["Read", "Edit", "Bash(git status)", "Bash(git diff *)"],
"deny": ["Bash(rm *)", "Bash(sudo *)", "WebFetch"]
}
}
If the agent tries Bash(rm -rf build), the deny rule matches and the call is rejected before execution. Allow rules do not override deny. This is the first layer of defense and requires no custom code.
Pattern matching is prefix-based with * as a wildcard. Bash(git *) matches any command starting with git . Bash(* --force) matches any command ending in --force. There is no regex. Keep patterns as specific as the agent’s real workload allows.
A common mistake is assuming allow is a whitelist that implicitly blocks everything else. It is not. Without a deny entry or a global default, Claude Code may still prompt or execute unspecified tools depending on its interactive mode. For non-interactive runs, set an explicit deny for high-risk tools or use permissions.default if your version supports it (some builds treat missing default as “ask”).
2. Hook lifecycle and events
When static permissions are not enough, hooks let you run arbitrary code at defined points. The four events are:
PreToolUse– runs before a tool executes. Exit non-zero to block.PostToolUse– runs after a successful tool call. Good for auditing or reformatting.Stop– fires when the agent finishes a turn.Notification– for async alerts, carries a different payload shape.
The hook command receives a JSON payload on stdin. For PreToolUse and PostToolUse it looks like this:
{
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"session_id": "abc123"
}
A minimal Python validator that blocks any curl to external hosts:
import sys, json
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
if "curl" in cmd and "internal.service" not in cmd:
sys.stderr.write("External curl blocked by policy\n")
sys.exit(1)
sys.exit(0)
Wire it into settings:
{
"hooks": {
"PreToolUse": [
{ "command": "python3 /opt/hooks/check_net.py" }
]
}
}
Stdout and exit codes
Claude Code ignores stdout for PreToolUse unless your build supports a structured approval response. Exit code 0 allows; non-zero denies and surfaces stderr to the agent as a feedback message. Keep hooks fast—they block the agent’s progress synchronously. Environment variables like CLAUDE_SESSION_ID are available, but do not rely on them for security; the stdin payload is the source of truth.
3. An ordered path to lock down an agent
Follow this sequence to avoid breaking the agent while you tighten claude code hooks permissions.
Step 0: Baseline with no restrictions
Run the agent on real tasks with default settings for a short period to learn its behavior. Do not skip this; guessing tool usage leads to over-blocking.
Step 1: Run permissive with logging
Start with no deny list, but attach a PostToolUse hook that appends every tool call to a local file.
{
"hooks": {
"PostToolUse": [
{ "command": "python3 /opt/hooks/audit.py >> /var/log/claude_audit.jsonl" }
]
}
}
audit.py just echoes stdin to the file. After a day of real tasks, you have a corpus of actually-used commands.
Step 2: Convert observations to deny rules
Scan the log for dangerous patterns—rm, git push --force, unknown IPs. Add them to deny. Keep allow broad for low-risk tools like Read and Edit.
Step 3: Add PreToolUse gates for context-aware blocks
Static deny lists miss nuanced abuse (e.g., python -c "import os; os.system('rm...')"). A PreToolUse hook parsing the command string or a lightweight AST catches these. This is where claude code hooks permissions become dynamic rather than declarative.
Step 4: Disable interactive prompts in CI
In headless mode, set permissions.allow to the minimal set and rely on hooks for the rest. If the agent hits an unmatched tool, it should fail closed, not hang. Pass --permission-mode bypassPermissions only if you have hooks covering the gap.
4. Common pitfalls and tradeoffs
Hook latency. Every PreToolUse call waits for your script. A 200ms Python startup multiplied by 500 tool calls adds minutes. Compile to a binary or use a long-running daemon with a Unix socket if needed.
False positives. Over-blocking Bash(rm *) also blocks rm -rf node_modules in a clean script. Use narrower patterns like Bash(rm -rf ~*) or allow scoped paths via a hook that checks the resolved absolute path.
Secret leakage. Hooks receive full tool input. If you log blindly, you record API keys from Bash(env) or .env edits. Redact in the audit script before writing to disk.
Permission precedence confusion. Teams often edit user-level ~/.claude.json expecting project overrides. Project settings merge, but deny always wins across both files. Document your layers in the repo README.
Agent self-modification. If you grant Edit on .claude/settings.json, the agent can weaken its own claude code hooks permissions. Deny that path explicitly:
{ "permissions": { "deny": ["Edit(.claude/settings.json)"] } }
Hook crash behavior. If the hook command is missing or throws an uncaught exception, it exits non-zero. Claude Code treats that as a block. This fails closed, which is safe but can stall pipelines if a script path is wrong. Test hook presence in your deploy step.
5. Advanced patterns
Conditional allow via external policy
A PreToolUse hook can call an external policy service to decide. If you proxy model traffic through a gateway that meters tokens, the hook can check remaining quota and block expensive tools. The hook exits 0 only if the external service returns approval. Keep the network call under 50ms or cache decisions per session.
PostToolUse auto-format
After Edit on .py files, run black or ruff --fix automatically. The agent sees the formatted file in subsequent context, reducing lint loops.
{
"hooks": {
"PostToolUse": [
{ "command": "python3 /opt/hooks/format_if_py.py" }
]
}
}
format_if_py.py checks tool_input.get("file_path","") suffix and runs the formatter. Exit 0 regardless; formatting failure should not block the agent.
Stop-event cleanup
Use Stop to kill background processes the agent spawned, or to send a Slack message with the diff summary. Keep it idempotent—Claude Code may fire Stop multiple times on error paths.
6. Testing your configuration
Treat hooks like production code. Simulate the Claude Code stdin contract:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python3 /opt/hooks/check_net.py
echo $?
# Expect non-zero
For permission rules, use the claude CLI in dry-run if available, or temporarily set deny to a benign tool and confirm the agent reports rejection. Never test destructive rules on a real workspace without a snapshot.
Write a unit test for each hook that feeds crafted payloads:
def test_block_external_curl():
import subprocess
p = subprocess.run(["python3","/opt/hooks/check_net.py"],
input=b'{"tool_name":"Bash","tool_input":{"command":"curl evil.com"}}',
capture_output=True)
assert p.returncode != 0
7. Operational checklist
- Project
.claude/settings.jsoncommitted to repo with explicitdeny. -
PreToolUsehooks timeout under 100ms or use a daemon. - Audit log redacts secrets (scan for
key=,token,Authorization). - Agent cannot edit its own settings or hook scripts.
- CI runs with prompts disabled and fails closed on unknown tools.
- Monthly review of
/var/log/claude_audit.jsonlto tighten patterns.
Claude code hooks permissions are not a one-time config. As the agent learns new tasks, revisit the audit log and tighten patterns. The goal is a system that fails closed, logs aggressively, and lets the agent do real work without a human watching every keystroke.