12.2 Permissions, Modes & Hooks
Key Takeaways
- Permission rules take the form Tool or Tool(specifier), such as Bash(pnpm test:*) or Read(./.env), and live in the permissions object's allow, ask, and deny arrays.
- Permission modes are default (aliased manual), acceptEdits, plan, auto, dontAsk, and bypassPermissions; dontAsk is the strictest because it auto-denies anything not pre-approved.
- Hook events are PascalCase and specific - PreToolUse, PostToolUse, SessionStart, SubagentStop, PreCompact - and are configured three levels deep as event, then matcher group, then handler.
- Only exit code 2 blocks a hooked action; exit 0 succeeds and other non-zero codes are non-blocking errors that let the action proceed unless decision JSON says otherwise.
- Headless runs use claude --print with --output-format, --permission-mode, --allowedTools, and --max-turns; --dangerously-skip-permissions is equivalent to --permission-mode bypassPermissions.
Permissions, Modes & Hooks
Exam Blueprint Focus: This section carries two separately weighted sub-skills: Claude Code Operation (3.1%) and, inside the Security and Safety domain, Claude Hooks (1.0%). Both are graded on exact names — the permission rule syntax, the mode names, and the hook event names. Approximate answers are wrong answers here.
The Permission Problem
An autonomous coding agent with shell and filesystem access creates a direct tension:
- Confirm every read and the agent is unusable for routine work.
- Confirm nothing and one misread instruction can drop a database, overwrite uncommitted work, or force-push to
main.
Claude Code resolves this with a tiered permission system plus explicit rules you write.
| Tool type | Example | Approval required in Manual mode |
|---|---|---|
| Read-only | Read, Grep, Glob | No, within the working directory and additionalDirectories |
| Bash commands | Shell execution | Yes, except a built-in set of read-only commands |
| File modification | Edit, Write | Yes, until the session ends or a rule allows it |
Permission Rule Syntax
Every rule has the form Tool or Tool(specifier). Parentheses inside a specifier are literal and need no escaping.
| Rule | Effect |
|---|---|
Bash | All Bash commands (Bash(*) is equivalent) |
Bash(npm run build) | Exactly that command |
Bash(pnpm test:*) | Any command matching the prefix pattern |
Read(./.env) | Reading .env in the current directory |
Edit(src/**) | Edits under src/ |
WebFetch(domain:example.com) | Fetches to that domain |
mcp__github__create_issue | One tool from an MCP server |
Agent(model:opus) | Subagent calls requesting the Opus tier |
Rules live in three arrays under permissions:
{
"permissions": {
"allow": ["Bash(pnpm test:*)", "Read(src/**)"],
"ask": ["Bash(git push:*)"],
"deny": ["Read(./.env)", "Bash(curl:*)"],
"defaultMode": "acceptEdits",
"additionalDirectories": ["../shared-lib"]
}
}
Behaviour that is directly testable:
denyandaskrules apply immediately.allowrules andadditionalDirectorieswait until the folder is trusted.- An
allowrule in your local file does not outrank anaskrule from project or managed settings. This is why "Yes, and don't ask again" sometimes still prompts. - List keys merge across scopes. Your personal
allowentries add to the team's; they do not replace them. - A deny rule with no specifier removes the tool from Claude's context entirely, so the model does not even see it as an option.
High-yield trap:
allowedTools,confirmTools, andblockedCommandsare notsettings.jsonkeys.--allowedToolsand--disallowedToolsexist as CLI flags, but the file-based configuration is thepermissionsobject only.
Permission Modes
The mode sets the baseline behaviour before rules are consulted. Set it with permissions.defaultMode in settings, or --permission-mode for one session.
| Mode | Behaviour |
|---|---|
default (aliased manual) | Prompts on first use of each tool |
acceptEdits | Auto-accepts file edits and common filesystem commands (mkdir, touch, mv, cp) inside the working and additional directories |
plan | Claude explores with reads and read-only shell commands but does not edit source files |
auto | Auto-approves tool calls with background safety classification instead of human review |
dontAsk | Auto-denies anything not pre-approved by an allow rule |
bypassPermissions | Skips prompts entirely, except for the small set of actions no mode auto-approves |
Two nuances worth carrying into the exam. dontAsk is not a permissive mode — it is the strictest, failing closed rather than prompting, which makes it the right choice for unattended automation with a curated allowlist. And bypassPermissions is the dangerous one: it skips prompts even for writes to protected paths such as .git and .claude, so it belongs only in a container or VM. Organizations can forbid it with permissions.disableBypassPermissionsMode (and disableAutoMode for auto) in managed settings, where individuals cannot override it.
Interactive vs. Headless Operation
Interactive mode
The default when you run claude in a terminal. You see a syntax-highlighted unified diff before an Edit applies, the exact command string before a Bash call runs, and an approval prompt offering yes / no / "yes, and don't ask again" / abort.
Headless mode
Headless runs are driven by --print (-p), not by an --auto-pilot flag:
claude -p "Fix the failing tests in src/billing and commit the result" \
--output-format json \
--permission-mode dontAsk \
--allowedTools "Read" "Edit" "Bash(pnpm test:*)" \
--model claude-sonnet-5 \
--max-turns 8
--output-formattakestext,json, orstream-json— usejsonwhen a CI step needs to parse the result.--max-turnscaps the agentic loop, the primary defence against a runaway agent burning budget.--dangerously-skip-permissionsis equivalent to--permission-mode bypassPermissions. The name is the warning.
Because nobody is watching, headless deployments need three safeguards: an ephemeral sandbox (container or VM, no host credentials), a scoped allowlist paired with dontAsk so anything unlisted fails closed, and fail-fast budgets via --max-turns and timeouts.
Hooks: Deterministic Lifecycle Governance
Prompts steer a probabilistic model. Hooks execute regardless of what the model decides, which is what makes them the enforcement layer that CLAUDE.md is not.
Event names
Hooks are keyed by event name in settings.json. The names are PascalCase and specific — PreToolUse, not preTool. The events you should know:
| Event | Fires |
|---|---|
SessionStart | A session begins or resumes |
UserPromptSubmit | Before Claude processes a prompt |
PreToolUse | Before a tool call executes |
PermissionRequest | When a tool needs a permission decision |
PostToolUse | After a tool call succeeds |
PostToolUseFailure | After a tool call fails |
SubagentStart / SubagentStop | A subagent is spawned / finishes |
PreCompact / PostCompact | Around context compaction |
Stop | Claude finishes responding |
SessionEnd | The session ends |
Configuration shape
The structure is three levels deep: event → matcher group → handler.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh" }
]
}
]
}
}
A matcher may be an exact tool name, a pipe- or comma-separated list, or a regex such as mcp__.*. Omitting it (or using "*") matches everything. Besides type: "command", handlers can be http, mcp_tool, prompt, or agent.
Hook input and the exit-code contract
The handler receives a JSON payload on stdin. Tool events carry tool_name, tool_input, and tool_use_id, alongside common fields such as session_id, cwd, and permission_mode.
The exit-code semantics are precise, and "any non-zero blocks" is wrong:
- Exit
0— success. If stdout is a JSON object it is parsed for decision fields; otherwise it is treated as text. - Exit
2— blocking error. The action is blocked on events that support blocking, with the reason taken from JSON or stderr. - Other non-zero codes — a non-blocking error. The action proceeds unless valid decision JSON says otherwise.
The explicit, reliable way to block is the JSON decision object:
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
TOOL=$(echo "$PAYLOAD" | jq -r '.tool_name // empty')
if [[ "$TOOL" == "Bash" ]]; then
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty')
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
if [[ "$BRANCH" == "main" && "$CMD" == git\ commit* ]]; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Commits on main are blocked; branch first."}}'
exit 0
fi
if echo "$CMD" | grep -iqE '(api[_-]?key|password|secret|bearer)'; then
echo "Command may expose a credential." >&2
exit 2
fi
fi
exit 0
permissionDecision accepts "deny", "allow", or "escalate". A deny reason is fed back to Claude as tool feedback, so it can choose a different approach — which is why a blocking hook should always explain itself. A hook that blocks silently sends the model into a retry loop that burns tokens on the same rejected action.
Post-tool hooks
PostToolUse fires after a tool succeeds and is where formatters and linters belong: run Prettier, Black, or gofmt on the file that was just written, so what lands on disk is always formatted. Note the ordering hazard — if a PostToolUse hook reformats a file and shifts line numbers, a subsequent Edit built on stale offsets can fail or corrupt adjacent lines.
Skills, Slash Commands, and Subagents
Three extension mechanisms sit alongside permissions and hooks, and CCDV-F expects you to tell them apart.
- Skills are reusable procedures in
SKILL.mdfiles under.claude/skills/<name>/(project) or~/.claude/skills/<name>/(personal). They use progressive disclosure: only thedescriptionsits in context, and the body loads when the skill is invoked — automatically when the description matches, or manually as/skill-name. - Slash commands invoke a fixed action by name, including user-invocable skills.
- Subagents are defined in
.claude/agents/and run a delegated task in an isolated context window.
Why subagents matter architecturally
+-------------------------------------------------------------------------+
| Primary Orchestrator Agent |
| Task: "Refactor all deprecated auth endpoints" |
| Context: clean, high-level architecture only |
+------------------------------------+------------------------------------+
| spawns subagent
v
+------------------------------------+------------------------------------+
| Search Subagent (isolated context) |
| Scope: find all 24 usages of verifyAuthToken() |
| Tools: Read, Glob, Grep only - no Bash, no Edit |
| Reads 500 files, processes 15,000 lines |
+------------------------------------+------------------------------------+
| returns summary only
v
| Orchestrator receives: "3 files need updates: a.ts, b.ts, c.ts" |
| The 15,000 lines of raw output never enter the parent context |
+-------------------------------------------------------------------------+
- Context hygiene. Raw search output stays quarantined in the subagent; only the synthesis returns.
- Least privilege. A read-only exploration subagent gets
Read,Glob, andGrepand nothing else. - Parallelism. Independent subproblems investigate concurrently.
A skill with context: fork in its frontmatter combines both: skill modularity plus subagent isolation.
Common Traps
- Using lowercase hook names.
preToolandpostToolare not events. The names arePreToolUseandPostToolUse. - Assuming any non-zero exit blocks. Only exit 2 blocks. Exit 1 or 3 is a non-blocking error and the action proceeds.
- Blocking without a reason. Emit
permissionDecisionReason, or write to stderr, so the model can adapt instead of retrying. - Unrestricted
bypassPermissionson a real workstation. It skips prompts even for.gitand.claudewrites. Containers only. - Confusing
dontAskwith a permissive mode. It auto-denies anything not pre-approved — the safest headless default, not the loosest. - Over-privileged subagents. Exploration subagents should be read-only unless the delegated task genuinely requires writes.
- Putting a procedure in
CLAUDE.mdinstead of a Skill.CLAUDE.mdloads every session; a Skill loads only when invoked.
A PreToolUse hook must block a Bash command that would push to main. The author writes the check, prints an explanation to stderr, and calls exit 1. Testing shows the push still happens. What is wrong?
A CI job runs Claude Code unattended to fix failing tests. The team wants anything not explicitly pre-approved to fail rather than prompt or auto-run. Which invocation matches that requirement?
A team keeps a 400-line release-verification procedure in CLAUDE.md. Sessions have grown slow, and Claude increasingly ignores unrelated project conventions. Which change best addresses both symptoms?