This is Part 5 of the series “Deep Dive: How Claude Code Works Under the Hood.” If you are joining mid-series, each post is self-contained, but Parts 1-4 give you the full foundation.
Every time Claude Code runs a bash command or edits a file on your machine, something has to decide whether that action is allowed. This is not a trivial problem. Too strict, and the agent becomes useless --- asking for permission on every ls command. Too loose, and you have an AI agent with unrestricted shell access.
Claude Code solves this with a layered permission system and a hook architecture that lets you observe, validate, and control tool execution. I have been running Claude Code in various modes across my ZenoLab projects for months, and understanding these systems made the difference between trusting the agent and being nervous about it.
The Four-Stage Permission Pipeline
When Claude Code decides to use a tool --- say, running rm -rf node_modules or editing a config file --- the request passes through a four-stage pipeline before anything executes.
Stage 1: Deny List Check
The first gate is a hard deny list. Certain operations are blocked regardless of any other settings. This is the non-negotiable safety floor.
Think of it as the circuit breaker panel in your house. No matter what else you configure, certain things just cannot happen. Operations that could cause catastrophic damage --- like writing to system directories or executing known-dangerous patterns --- get stopped here.
Tool request: Bash("rm -rf /")
→ Stage 1: DENIED (matches hard deny pattern)
→ Pipeline stops. Action blocked.
Stage 2: Mode Check
If the request passes the deny list, it hits the mode check. Claude Code operates in one of three permission modes, and each mode has different rules for what gets auto-approved.
Default mode is the most conservative. Most tool executions require user approval. Read operations (listing files, reading content) are generally allowed, but write operations (editing files, running commands that modify state) trigger a permission prompt.
Plan mode is for when you want Claude to think but not act. The agent can read and analyze, but all write operations are blocked. This is useful for code review or architecture discussions where you want insights without modifications.
Auto mode is the fully autonomous setting. Most operations are approved without prompting, subject to the allow and deny lists. This is what you use when you trust the agent to work independently --- in CI pipelines, background tasks, or when you have set up proper guardrails.
# Default mode
Bash("cat src/index.ts") → Auto-approved (read operation)
Bash("npm install lodash") → Prompt user for approval
# Plan mode
Bash("cat src/index.ts") → Auto-approved (read operation)
Edit("src/index.ts", ...) → BLOCKED (write operation)
# Auto mode
Bash("npm install lodash") → Auto-approved
Edit("src/index.ts", ...) → Auto-approved
Stage 3: Allow List Pattern Matching
If the mode check does not produce a definitive answer, the system checks the allow list. This is where your custom rules live --- patterns that define which specific tools and arguments are pre-approved.
The matching follows a first-match-wins strategy. The system walks through your rules in order, and the first rule that matches the request determines the outcome.
{
"permissions": {
"allow": [
"Bash(npm test*)",
"Bash(npm run lint*)",
"Edit(src/**)",
"Bash(git status)",
"Bash(git diff*)"
],
"deny": [
"Bash(git push*)",
"Bash(rm -rf*)",
"Bash(sudo*)"
]
}
}
In this configuration:
- Running tests and linting is always allowed
- Editing files in
src/is always allowed - Git status and diff are always allowed
- Git push, destructive removes, and sudo are always blocked
- Anything else falls through to the user prompt (Stage 4)
The order matters. If you accidentally put a broad allow rule before a narrow deny rule, the allow wins. Structure your rules from most-specific to least-specific, and put deny rules before allow rules when there is overlap.
Stage 4: User Prompt
If none of the previous stages produced a definitive answer, Claude Code falls back to asking you. You see the tool name, the arguments, and a prompt to approve or deny.
This is the safety net. Anything the system is not sure about gets escalated to the human. It is the right default --- when in doubt, ask.
Claude wants to run: Bash("curl -X POST https://api.example.com/deploy")
Allow? [y/n/always]
That “always” option is interesting --- it leads us to dynamic rule addition.
The Circuit Breaker
Here is a nice UX detail: if you deny three tool executions in a row, Claude Code suggests switching to a different permission mode. The reasoning is that three consecutive denials usually means the agent is trying to do something you do not want it to do in the current mode.
You've denied 3 consecutive tool requests.
Would you like to switch to Plan mode? This will let Claude
analyze and suggest changes without executing them. [y/n]
This is a circuit breaker pattern borrowed from distributed systems. Instead of letting the agent keep banging against denied operations, it suggests a mode that better matches what you seem to want.
Dynamic Rule Addition
When you choose “always allow” on a permission prompt, Claude Code adds that pattern to your session’s allow list. This is dynamic rule addition --- your permission configuration evolves as you work.
The nice thing about this is that it is conversational. You do not need to edit a JSON config file to add rules. You just approve things as they come up, and the ones you mark as “always” become standing approvals.
For my ZenoLab projects, I typically start a session in default mode, approve a few common operations as “always allow,” and end up with a permission set that matches exactly what I need for that particular work session. Each session starts fresh unless I have persisted rules in the project config.
The Hook System
Permissions control whether something happens. Hooks let you control what happens around something. They are the observation and side-effect layer of the tool execution pipeline.
Three Lifecycle Events
Hooks attach to three points in the execution lifecycle:
SessionStart fires when a Claude Code session begins. Use this for environment setup, validation, or logging.
PreToolUse fires before a tool executes but after permission is granted. This is your last chance to inspect, modify, or block an operation.
PostToolUse fires after a tool completes. Use this for cleanup, logging, notifications, or triggering dependent workflows.
Session begins
│
├── SessionStart hooks fire
│
▼
User sends prompt → Claude decides to use a tool
│
├── Permission pipeline (4 stages)
│
├── PreToolUse hooks fire
│
├── Tool executes
│
├── PostToolUse hooks fire
│
▼
Claude processes result → responds to user
Exit Code Protocol
Hooks communicate back to Claude Code through exit codes. This is a clean, Unix-standard interface:
- Exit 0: Continue. The hook ran successfully, and execution should proceed normally.
- Exit 1: Block. The hook detected a problem, and the tool execution should be stopped. The agent receives the hook’s stderr as an explanation.
- Exit 2: Inject. The hook ran successfully and wants to inject additional context into the agent’s input. The hook’s stdout is added to the conversation.
#!/bin/bash
# PreToolUse hook: block dangerous file deletions
TOOL_NAME="$CLAUDE_TOOL_NAME"
TOOL_INPUT="$CLAUDE_TOOL_INPUT"
if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -q "rm.*-rf"; then
echo "Blocked: destructive delete detected" >&2
exit 1 # Block the operation
fi
exit 0 # Allow everything else
#!/bin/bash
# PostToolUse hook: inject a reminder after file edits
TOOL_NAME="$CLAUDE_TOOL_NAME"
if [[ "$TOOL_NAME" == "Edit" ]]; then
echo "Reminder: run 'npm test' to verify this change doesn't break anything"
exit 2 # Inject this message into the conversation
fi
exit 0
Configuring Hooks with .hooks.json
Hooks are configured in a .hooks.json file at the project root or in your user config directory. The structure maps event names to arrays of hook definitions:
{
"hooks": {
"SessionStart": [
{
"command": "bash .claude/hooks/session-init.sh",
"description": "Initialize session environment"
}
],
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash .claude/hooks/validate-command.sh",
"description": "Validate bash commands before execution"
}
],
"PostToolUse": [
{
"matcher": "Edit",
"command": "bash .claude/hooks/post-edit.sh",
"description": "Run linter after file edits"
}
]
}
}
The matcher field filters which tools trigger the hook. Without a matcher, the hook fires for every tool invocation of that event type.
Context Passing via Environment Variables
Hooks receive context through environment variables. This is how the hook script knows what tool is being used, what arguments it received, and what the current session state looks like.
Key environment variables include:
CLAUDE_TOOL_NAME--- the name of the tool being invokedCLAUDE_TOOL_INPUT--- the JSON-encoded input to the toolCLAUDE_SESSION_ID--- unique identifier for the current sessionCLAUDE_PROJECT_DIR--- the project root directory
#!/bin/bash
# PostToolUse hook: log all tool executions
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | Tool: $CLAUDE_TOOL_NAME | Input: $CLAUDE_TOOL_INPUT" \
>> "$CLAUDE_PROJECT_DIR/.claude/tool-audit.log"
exit 0
This makes hooks incredibly composable. You can write them in any language --- bash, Python, Node --- as long as they read environment variables and return the right exit code.
Why the Loop Retains Control Flow
Here is the architectural insight that took me a while to appreciate: hooks observe the execution loop, but they do not own it.
The agent’s main loop --- read prompt, decide action, execute tool, process result, repeat --- is always in control. Hooks are callbacks that the loop invokes at specific points. A hook can block an action (exit 1) or inject context (exit 2), but it cannot redirect the loop, change what the agent does next, or take over the conversation.
This is a deliberate design choice. If hooks could redirect control flow, you would end up with an unpredictable system where side effects drive the main logic. By keeping hooks as observers with limited veto power, the system stays deterministic and debuggable.
Think of it like middleware in a web framework. Middleware can inspect requests, add headers, or reject bad requests. But it does not replace the route handler. The route handler (the agent loop) is always the primary decision-maker.
Practical Setup for Indie Developers
Here is the permission and hook setup I use for my ZenoLab projects. It balances autonomy with safety:
{
"permissions": {
"allow": [
"Bash(npm test*)",
"Bash(npm run *)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(ls *)",
"Bash(cat *)",
"Edit(src/**)",
"Edit(tests/**)"
],
"deny": [
"Bash(git push --force*)",
"Bash(rm -rf*)",
"Bash(sudo*)",
"Bash(curl*POST*)",
"Edit(.env*)"
]
}
}
And my hooks for safety:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash .claude/hooks/no-secrets-in-commands.sh",
"description": "Block commands containing API keys or tokens"
}
],
"PostToolUse": [
{
"matcher": "Edit",
"command": "bash .claude/hooks/auto-lint.sh",
"description": "Run ESLint on edited files"
}
]
}
}
This gives me an agent that can freely read, test, and edit source code, but cannot push force, delete things recursively, make network requests, or touch environment files. Every file edit gets automatically linted. And if something unexpected comes up, I get a permission prompt.
What’s Next
In Part 6, we will explore memory and system prompts --- how Claude Code remembers things across sessions and how the system prompt is assembled from multiple sources. We will cover the four memory categories, what not to store, the system prompt build pipeline, and how CLAUDE.md files layer from multiple directory levels to give the agent project-specific context.