Claude Code’s Five‑Layer Permission System: How It Stops Unauthorized Tool Calls
The article dissects Claude Code’s built‑in five‑layer permission architecture, explaining why a single check is insufficient, how each layer (Hooks, Deny Rules, Permission Mode, Allow Rules, canUseTool) works, the engineering trade‑offs, performance concerns, and practical recommendations for secure AI agent deployments.
Why Five Layers?
Security best practice warns that a single point of defense is fragile—if the gate is broken, everything is compromised. Claude Code must guard a wide range of tool actions (reading files, writing files, executing shell commands, calling MCP services) whose risk profiles differ dramatically; for example, ls -la is harmless while rm -rf / is catastrophic. Usage scenarios also vary: a solo developer’s local run, CI/CD pipelines that must bypass user confirmation, enterprise deployments with strict IT policies, and SDK‑embedded agents that need fine‑grained per‑tool control. One layer cannot satisfy all these cases, so the permission system in src/utils/permissions/permissions.ts implements a five‑step pipeline, each with a dedicated responsibility.
Layer Overview
1 Hooks – Run custom hook code; may allow, deny, or modify the request. Can be bypassed (passes to next layer).
2 Deny Rules – Explicit blocklist ( disallowedTools). Cannot be bypassed in any mode.
3 Permission Mode – Global mode switch (default, dontAsk, acceptEdits, bypassPermissions, plan, auto). Cannot be bypassed; the mode decides later checks only.
4 Allow Rules – Explicit allow list ( allowedTools). Can be bypassed (short‑circuits later checks).
5 canUseTool – Runtime user/AI callback for manual approval. Skipped only in dontAsk mode.
The order is intentional; each step builds on the previous one.
Source Entry Point
The permission check is invoked from the function executeToolWithPermissionCheck (obfuscated as tGz in the compiled dist/cli.js). The simplified flow is:
// simplified from dist/cli.js tGz function
async function executeToolWithPermissionCheck(tool, toolUseID, input, toolUseContext, canUseTool, ...) {
// 1️⃣ Input validation (Zod schema)
const parseResult = tool.inputSchema.safeParse(input);
if (!parseResult.success) {
return errorResult(toolUseID, `InputValidationError: ${parseResult.error}`);
}
// 2‑4️⃣ Aggregate results via resolvePermission (vC_)
const { decision, input: resolvedInput } = await resolvePermission(
preToolHookResult, // 1️⃣ Hook result
tool,
parsedInput,
toolUseContext,
canUseTool, // 5️⃣ callback
assistantMessage,
toolUseID
);
// Apply decision
if (decision.behavior !== "allow") {
return rejectedResult(toolUseID, decision.message);
}
return await tool.call(resolvedInput, toolUseContext, ...);
}Layer 1 – Hooks (Programmable Pre‑Guards)
Hooks run before any other check and can inject arbitrary logic. They may return a permission decision or modify the tool’s input. Example: a hook could replace every occurrence of rm -rf with trash. However, a hook’s allow does **not** bypass Deny Rules; the resolvePermission implementation checks Deny Rules after a hook’s decision.
// runPreToolUseHooks (ZC_ function)
async function* runPreToolUseHooks(toolUseContext, tool, input, toolUseID, ...) {
for await (const result of invokeHook(tool.name, toolUseID, input, toolUseContext, mode, abortSignal, ...)) {
if (result.permissionBehavior !== undefined) {
yield {
type: "hookPermissionResult",
hookPermissionResult: {
behavior: result.permissionBehavior, // "allow" | "deny" | "ask"
updatedInput: result.updatedInput,
decisionReason: { type: "hook", hookName: `PreToolUse:${tool.name}` }
}
};
}
}
}The design guarantees that even a malicious hook cannot override an explicit deny rule.
Layer 2 – Deny Rules (Impenetrable Blocklist)
Deny Rules are the only layer that penetrates every permission mode, including bypassPermissions. They are defined both programmatically via the disallowedTools option and declaratively in .claude/settings.json. Both support glob patterns.
// programmatic disallowedTools example
const options = { disallowedTools: ["Bash"] }; // always reject Bash
// .claude/settings.json example
{
"permissions": {
"deny": [
"Bash(rm*)", // reject any rm command
"Write(/etc/*)" // reject writes to /etc
]
}
}The rule engine ( OA) matches paths and commands against these patterns. Bash/PowerShell tools receive an additional AST‑level safety layer that scans for dangerous patterns such as -encodedcommand, eval, or pipeline injection.
Layer 3 – Permission Mode (Global Behaviour Switch)
Six modes are defined. The table below is expressed as a list for readability: default – Deny, Allow, and canUseTool all active; typical for daily interaction. dontAsk – Deny and Allow active; canUseTool skipped → automatic deny for tools not already permitted; used in CI/CD automation. acceptEdits – Deny active; Allow active with automatic file‑operation allowance; canUseTool runs only for uncovered tools; suited for rapid iteration. bypassPermissions – Deny still active; Allow active; canUseTool skipped → automatic allow for tools not denied; intended for trusted environments. plan – Only Deny checked; no execution, used for planning. auto – Deny and Allow checked; canUseTool decision delegated to a model classifier; used for intelligent judgement.
Layer 4 – Allow Rules (Fine‑Grained Whitelist)
Allow Rules mirror Deny Rules but only short‑circuit the pipeline; they never override built‑in safety checks. The file‑system permission decision function illustrates a seven‑step, fail‑closed flow.
function checkFilePermission(path, context, operationType, isCanonical) {
// 1️⃣ Deny rules (highest priority)
const denyRule = checkRule(path, context, operationType, "deny");
if (denyRule !== null) return { allowed: false, decisionReason: { type: "rule", rule: denyRule } };
// 2️⃣ Built‑in safety (e.g., path traversal protection)
if (operationType !== "read") {
const safetyCheck = checkBuiltInSafety(path, {});
if (!safetyCheck.safe) return { allowed: false, decisionReason: { type: "safetyCheck", ...safetyCheck } };
}
// 3️⃣ Runtime always‑allow list (user‑approved)
if (isInAlwaysAllowList(path, context)) {
if (operationType === "read" || context.mode === "acceptEdits") return { allowed: true };
}
// 4️⃣ Built‑in read allow rules
if (operationType === "read") {
const readAllowRule = checkBuiltInReadAllow(path, {});
if (readAllowRule.behavior === "allow") return { allowed: true, decisionReason: readAllowRule.decisionReason };
}
// 5️⃣ Write sandbox whitelist
if (operationType !== "read" && isInSandboxWriteAllowlist(path)) return { allowed: true };
// 6️⃣ Explicit allow rules
const allowRule = checkRule(path, context, operationType, "allow");
if (allowRule !== null) return { allowed: true, decisionReason: { type: "rule", rule: allowRule } };
// 7️⃣ No match → defer to layer 5
return { allowed: false };
}The function demonstrates how the system defaults to deny unless an explicit allow is found.
Layer 5 – canUseTool Callback (Real‑Time Human Approval)
If the first four layers cannot reach a definitive decision, the system falls back to an interactive prompt. The user can choose “allow once”, “allow permanently” (adds to alwaysAllowRules), or “deny”. In dontAsk mode this layer is skipped, causing an immediate deny for any tool not already permitted.
async function promptUserForPermission(tool, input, context, assistantMessage, toolUseID, hookAskResult) {
const userDecision = await showPermissionPrompt({
toolName: tool.name,
input,
suggestions: hookAskResult?.suggestions,
options: [
{ id: "allow_once", label: "Allow once" },
{ id: "allow_permanent", label: "Allow permanently (whitelist)" },
{ id: "deny", label: "Deny" }
]
});
if (userDecision === "allow_permanent") {
addToAlwaysAllowList(context, tool.name, input);
}
return {
behavior: userDecision === "deny" ? "deny" : "allow",
type: "user",
permanent: userDecision === "allow_permanent"
};
}Design Insights
Deny Rules are immutable trust anchors. Even when a developer enables bypassPermissions, IT‑managed disallowedTools and policy files still block the tool.
The permission chain is unidirectional. Hook‑allow → Deny‑check → … cannot be reversed; a later layer cannot override an earlier explicit deny.
alwaysAllowRules accumulate user‑granted trust. Selecting “allow permanently” adds a rule that lives for the current session, reducing future prompts while preserving an audit trail via the source field.
Shell tools have an extra AST‑level safety layer. Bash/PowerShell commands are parsed into an abstract syntax tree and scanned for dangerous patterns such as -encodedcommand, pipeline injection, or UNC paths. This constitutes a “second‑layer enhanced” check for the most risky tools.
Critical Review – Boundaries and Technical Debt
Async hook race conditions. Parallel tool executions can cause multiple hooks to modify shared state concurrently. The code uses a siblingAbortController to mitigate, but edge cases remain.
Latency accumulation. Each layer may perform asynchronous work; the implementation logs a warning if the total pre‑hook duration exceeds 2000 ms, which can become a bottleneck in high‑frequency scenarios.
Underestimated risk of bypassPermissions . The mode is inherited by sub‑agents and cannot be overridden, so a developer who leaves it enabled after testing may unintentionally expose a production system.
Practical Recommendations for an Agent Project
// 1️⃣ Least‑privilege: specify allowed tools and use a restrictive mode
const options = {
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "dontAsk" // tools not listed are denied
}; // ✔ correct: dontAsk + allowedTools; ✘ dangerous: bypassPermissions without disallowedTools
// 2️⃣ Audit hook (records but does not intervene)
const auditHook = async (toolName, input) => {
await auditLog.write({ timestamp: Date.now(), tool: toolName, input: sanitize(input) });
return { permissionBehavior: undefined };
};
// 3️⃣ Enterprise policy file as final defense (IT‑controlled)
// .claude/policies.json
// { "permissions": { "deny": ["Bash(curl*)", "Bash(wget*)", "Write(/etc/*)"] } }Even if developers enable bypassPermissions, the policy file’s deny rules remain effective.
Conclusion
The five‑layer defense provides compartmentalised protection: Deny Rules form the immutable security anchor; Hooks offer flexible pre‑checks but cannot override denies; Permission Mode governs global behaviour without disabling denies; Allow Rules give a fast‑path whitelist; and the final canUseTool callback enables human‑in‑the‑loop decisions while respecting the dontAsk optimisation. Understanding the trade‑offs, latency impact, and the special AST safety layer for shell commands is essential for deploying Claude Code or similar AI agents securely.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
James' Growth Diary
I am James, focusing on AI Agent learning and growth. I continuously update two series: “AI Agent Mastery Path,” which systematically outlines core theories and practices of agents, and “Claude Code Design Philosophy,” which deeply analyzes the design thinking behind top AI tools. Helping you build a solid foundation in the AI era.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
