Permission modes are only the starting point. The real security model emerges from how modes, rules, settings layers, and OS-level sandboxing interact with each other. This chapter covers the precedence hierarchy, scoping behavior, and edge cases that determine what Claude can actually do at runtime.
See Permission Modes for the five-mode overview.
Precedence Hierarchy
When the same tool is referenced at multiple levels, Claude Code resolves conflicts using a strict top-down precedence. A higher-level rule always wins, and deny always beats allow at the same level.
Settings Precedence (Highest to Lowest)
| Level | Source | Scope | Can Be Overridden? |
|---|---|---|---|
| 1 | Managed settings (enterprise MDM) | Organization-wide | No — nothing overrides this |
| 2 | CLI arguments (—allowedTools, —disallowedTools) | Current session only | Only by managed settings |
| 3 | Local project settings (.claude/settings.local.json) | Per developer, gitignored | By levels 1-2 |
| 4 | Shared project settings (.claude/settings.json) | Team-wide, committed to repo | By levels 1-3 |
| 5 | User settings (~/.claude/settings.json) | Global defaults across all projects | By levels 1-4 |
Within each level, the evaluation order for rule types is deny > ask > allow. A deny rule at any level cannot be overridden by an allow rule at the same or lower level. This means a team lead can deny Bash(rm -rf *) in the shared project settings (.claude/settings.json) and no individual developer can re-enable it through their local settings or CLI flags.
Managed Settings Lock Fields
Enterprise-managed settings provide four key override controls:
{ "disableBypassPermissionsMode": true, "allowManagedPermissionRulesOnly": true, "permissions": { "deny": ["Bash(curl *)"], "allow": ["Bash(git *)"] }}When allowManagedPermissionRulesOnly is true, all user-level and project-level rules are ignored entirely. Only the rules in the managed settings file apply. When disableBypassPermissionsMode is true, the --dangerously-skip-permissions flag and bypassPermissions mode are rejected at startup.
If a tool is denied in managed settings, —allowedTools on the CLI cannot re-enable it. No flag, no setting, no argument can override a managed deny. This is by design for organizational security compliance.
Session vs Project Scope
Not all permission approvals are equal. The persistence behavior depends on the tool type, and understanding this is critical for both security audits and workflow efficiency.
Approval Persistence by Tool Type
| Tool Type | Example | Approval Required? | Persistence |
|---|---|---|---|
| Read-only tools | File reads, Grep, Glob | Never | N/A |
| File modification | Edit, Write | Yes (first use) | Until session end |
| Bash commands | Shell execution | Yes (first use) | Permanently per project + command pattern |
File edit approvals reset each session as a safety measure. You approve Edit once and it stays approved for the rest of that session, but the next time you launch Claude, you will be prompted again.
Bash command approvals persist permanently for the specific project because shell commands carry the highest risk. When you approve git status in a project, that approval is stored and survives across sessions. However, this is scoped to the exact command pattern — approving git status does not approve git push --force.
Compound Bash commands save separate rules. Approving git status && npm test creates individual stored rules for git status and npm test separately, up to five rules per compound command.
Settings Are Local-Only
Permission rules in .claude/settings.json are resolved from the project root only — they do not walk up the directory tree. A parent directory’s permissions.deny: ["Write"] has no effect on subfolder sessions, even when the subfolder has no settings.json of its own.
This is different from CLAUDE.md and MCP configs (which always walk up). The asymmetry means a parent can share instructions and MCP servers with all subfolders, but cannot enforce permission restrictions on them. For org-wide restrictions, use managed settings (enterprise-level) or ensure each subfolder defines its own .claude/settings.json.
CLI Arguments Are Session-Scoped
Flags like --allowedTools and --disallowedTools apply only for the current session. They override project and user settings for that invocation but leave no persistent trace. This makes them ideal for CI/CD pipelines where you want to lock down a specific run without affecting the project configuration.
# This session can only use Bash with npm commands -- nothing elseclaude -p "Run the tests" \ --permission-mode dontAsk \ --allowedTools "Bash(npm *)"Edge Cases
plan Mode and allowedTools Interaction
The plan mode restricts Claude to read-only tools. But what happens when you combine it with --allowedTools that includes write tools?
claude -p "Write tests" \ --permission-mode plan \ --allowedTools "Edit,Write,Bash"The permission mode wins. Even though Edit, Write, and Bash are in the allowed list, plan mode blocks all modifications. The --allowedTools flag restricts which tools are available, but the permission mode governs whether those tools can actually execute. Think of --allowedTools as a whitelist filter and the permission mode as the execution policy — both must agree for a tool call to proceed.
dontAsk and disallowedTools
The dontAsk mode silently denies any tool that does not have an explicit allow rule. Adding --disallowedTools on top creates a double-deny scenario.
claude -p "Deploy the app" \ --permission-mode dontAsk \ --disallowedTools "Bash(rm *)"In this configuration, Bash(rm *) is denied by --disallowedTools (level 2 precedence), and any other Bash command not explicitly in an allow rule is silently denied by dontAsk. The result is predictable but the failure mode is subtle: Claude will not report that a tool was disallowed versus silently denied. Both appear the same in the output. The difference only matters if you later add allow rules — a disallowed tool stays blocked regardless of allow rules, while a dontAsk-denied tool becomes available once you add a matching allow rule.
The Read/Edit Deny Bypass
This is a critical security edge case. Deny rules for Read and Edit only block the built-in tools. They do not block equivalent operations through Bash.
{ "permissions": { "deny": ["Read(./.env)"] }}This blocks Read tool calls targeting .env, but Claude can still run cat .env via the Bash tool. To truly protect sensitive files, you need one of these approaches:
- Add a corresponding Bash deny rule:
Bash(cat .env) - Enable OS-level sandboxing to restrict filesystem access
- Use both for defense in depth
A Read(./.env) deny rule provides a false sense of security on its own. Claude can still access the file contents through cat .env, head .env, grep . .env, or any other shell command. Pair file deny rules with sandboxing for real enforcement.
dontAsk vs default — The Silent Failure
Both modes use permission rules, but they diverge on what happens when no rule matches a tool call:
- default prompts the user for approval (interactive)
- dontAsk silently denies the tool (non-interactive)
In headless mode (-p flag), default mode causes an unapproved tool to block execution waiting for input that never comes. With dontAsk, it fails fast and Claude can try an alternative approach. Always use dontAsk with explicit allow rules for automated workflows.
For CI/CD pipelines, always pair —permission-mode dontAsk with explicit —allowedTools. This gives you fail-fast behavior for unexpected tool calls while still allowing the tools your pipeline actually needs.
Audit Trail
Every JSON response from Claude Code includes a permission_denials array. This field is your audit trail — it records every tool call that was blocked during the session, including the tool name, a unique use ID, and the exact input arguments Claude attempted to pass.
Reading the Denial Payload
Each denial entry has three fields:
permission_denials Entry Structure
| Field | Type | Description |
|---|---|---|
tool_name | string | Which tool was blocked (e.g., Write, Bash) |
tool_use_id | string | Unique identifier for this specific tool call attempt |
tool_input | object | The exact arguments Claude tried to pass to the tool |
The array length reveals retry behavior. When Claude gets a denial, it may retry once before giving up. A single denial means Claude accepted the block immediately. Two denials for the same action (same tool_name and tool_input but different tool_use_id) means Claude retried.
Comparing Denial Behavior Across Modes
The following payloads show the same prompt — “Write hello to /tmp/perm_test.txt” — run under three different permission modes. Watch how permission_denials changes.
In default mode, Claude tried once, was denied, and explained the situation. One entry in permission_denials, two turns total.
In acceptEdits mode, Claude expected file writes to succeed (since the mode auto-approves edits). When the write to /tmp/ was blocked — because acceptEdits does not override the sandbox’s directory restriction — Claude retried once before giving up. Two denial entries, three turns, higher cost.
acceptEdits does not auto-approve writes outside the project directory. The sandbox filesystem restriction applies regardless of permission mode. The retry behavior in this payload is Claude being surprised by the denial and trying again.
With bypassPermissions, the permission_denials array is empty. The write succeeded on the first attempt, resulting in the lowest cost and fastest completion of the three modes.
Using Denials for Security Logging
In automated workflows, parsing permission_denials lets you detect when Claude attempts actions outside its intended scope. Here is a pattern for flagging suspicious denials:
function auditDenials(result) { const denials = result.permission_denials ?? []; if (denials.length === 0) return;
for (const d of denials) { console.warn( `DENIED: ${d.tool_name} attempted with input:`, JSON.stringify(d.tool_input) ); }
// Flag if Claude retried a denied action const retries = denials.filter((d, i) => denials.findIndex( (other) => other.tool_name === d.tool_name && JSON.stringify(other.tool_input) === JSON.stringify(d.tool_input) ) !== i );
if (retries.length > 0) { console.warn( `WARNING: ${retries.length} retried denial(s) detected -- ` + 'Claude may be struggling with permission boundaries' ); }}In CI/CD pipelines, treat any non-empty permission_denials array as a signal worth logging. If only bypassPermissions allows an action (but all other modes deny it), that is a strong indicator the action falls outside normal operating boundaries.
Permissions and Sandboxing — Two Layers
Permissions and sandboxing are complementary but separate systems. Permissions operate at the application level, controlling which tools Claude can invoke. Sandboxing operates at the OS level, controlling what those tools can actually do on the filesystem and network.
Even with bypassPermissions, the OS sandbox can block file writes outside allowed directories. Conversely, even without sandboxing, permission rules can block specific tools. For defense in depth, use both: permission rules for granular tool-level control, and sandboxing for OS-level containment.
PreToolUse Hooks as a Third Layer
The PreToolUse hook fires before every tool invocation and can override permission decisions:
- Exit code
0— explicitly allow (bypasses permission checks) - Exit code
2— explicitly deny (overrides allow rules) - Any other exit code — fall through to normal permission evaluation
This gives you a programmable third layer. For example, a hook that denies all Write calls to files matching *.prod.*, regardless of what the permission rules say.
See Permission Modes for the five-mode overview.