Permissions in the SDK
How the Agent SDK decides whether a tool call runs, with allow and deny rules, the six permission modes and the traps that silently widen access.
Every time Claude wants to run a tool, the SDK walks through a fixed sequence of checks to decide whether it runs, is blocked, or needs a decision from your code. Understanding that sequence is the difference between an agent that is genuinely locked down and one that only looks locked down.
This page covers the evaluation order, allow and deny rules, and permission modes. The two remaining pieces have their own pages: hooks and the canUseTool callback.
The evaluation order
A tool request passes through these six stages. The first stage that settles it wins.
| Stage | Can it block? | Can it approve? | Notes |
|---|---|---|---|
| 1. Hooks | Yes | Partly | A hook can deny outright. A hook allow does not skip stages 2 and 3, and cannot approve rm or rmdir on a critical path. |
| 2. Deny rules | Yes | No | From disallowedTools and settings. Applies even in bypassPermissions. |
| 3. Ask rules | No | No | From settings. A match sends the call to canUseTool, even in bypassPermissions. |
| 4. Permission mode | No | Yes | bypassPermissions, acceptEdits and plan act here. |
| 5. Allow rules | No | Yes | From allowedTools and settings, plus anything the tool approves on its own. |
6. canUseTool | Yes | Yes | Your callback decides. Skipped (and denied) in dontAsk. |
Some detail on the stages that have exceptions:
Deny rules. A bare tool name such as Bash removes the tool from Claude's context before evaluation even starts. Only scoped rules like Bash(rm *) are actually checked at stage 2.
Ask rules and forced prompts. Three kinds of call always fall through to canUseTool, even with a matching allow rule and even in bypassPermissions:
AskUserQuestion;- MCP tools whose server sets
_meta["anthropic/requiresUserInteraction"](Claude Code v2.1.199+); - claude.ai connector tools that your organisation has set to
ask. The callback receives the reasonYour organization requires approval for this tool.
In dontAsk mode all of these are denied instead, because that mode never prompts.
Permission mode. bypassPermissions approves everything that reaches it except rm/rmdir on a critical path, which falls through. acceptEdits approves the file operations listed below. plan sends file edits and shell writes to canUseTool regardless of allow rules.
Allow rules. Some calls approve themselves with no rule: reading a file inside your working directories, or a read-only Bash command. Critical-path removals are never approved here; whether they reach your callback depends on the mode (in an SDK session in auto mode they are denied by default without calling it).
canUseTool. In TypeScript, setting permissionPrompts: 'none' (Claude Code v2.1.259+) stops the callback being called at this stage. A PermissionRequest hook still gets a chance; if it does not decide, the call is denied.
The shadowed-callback warning
The TypeScript SDK emits a one-off Node.js warning with code CLAUDE_SDK_CAN_USE_TOOL_SHADOWED when you pass canUseTool in a configuration where earlier stages will approve calls first:
permissionMode: 'bypassPermissions';- any bare
allowedToolsentry such as"Read".
Scoped entries such as Bash(ls *), acceptEdits mode and allow rules from settings files do not trigger it. Catch it with process.on('warning', ...) and match on the code. If you need a check on every single call, use a PreToolUse hook, which runs first.
Allow and deny rules
allowedTools / disallowedTools (Python: allowed_tools / disallowed_tools) feed the allow and deny lists above.
| Entry | Result |
|---|---|
allowedTools: ["Read", "Glob"] | Those two are auto-approved. Unlisted tools still exist; calls that need approval go on to the mode and canUseTool. |
disallowedTools: ["WebFetch"] | WebFetch is removed from the request. Claude never sees it. |
disallowedTools: ["Bash(curl *)"] | Bash stays. Commands matching curl * as written are denied in every mode. Other Bash commands carry on through the flow. |
disallowedTools: ["*"] | Every tool definition removed. |
disallowedTools: ["mcp__*"] | Every MCP tool from every server removed. |
Things to know:
- Naming a task-tracking tool in
allowedToolsalso opts the session in to those tools. - Allow globs need a server prefix.
mcp__crm__*andmcp__crm__list_*work. Unanchored entries such as"*"or"mcp__*"in the allow list are ignored with a start-up warning and approve nothing. - Pattern rules match the command as written.
Bash(curl *)will not catch/usr/bin/curl. See Bash rule limits. Edit(path)covers every write tool, includingWriteandNotebookEdit. AWrite(path)rule is never matched by file checks.- Path anchoring.
//pathmeans an absolute filesystem path, soEdit(//etc/**)blocks writes under/etc. A single slash,Edit(/etc/**), anchors to the rule's source, which for SDK options is the working directory. See permissions for all four anchor forms.
Rules can also live in .claude/settings.json (allow, deny and ask lists). They apply when the project setting source is on, which it is by default. If you set settingSources yourself, include "project". Syntax is in the settings reference.
Warning: Anything approved before stage 6 never reaches
canUseTool. If you put checks in your callback and also passallowedTools: ["Bash"], every Bash call skips your checks. A bare name approves the whole tool; a scoped rule such asBash(pnpm test *)approves only matching calls.
A locked-down agent
The pattern I use for headless agents is an explicit allow list with dontAsk:
const options = {
allowedTools: ["Read", "Grep", "Glob", "Bash(pnpm lint *)"],
disallowedTools: ["WebFetch", "WebSearch"],
permissionMode: "dontAsk"
};
Listed tools run; anything that would have prompted is denied. Calls that never need approval in default mode still run: read-only Bash, file reads in your working directories, and tools like Agent that do not ask. To remove a tool completely, put its bare name in disallowedTools.
Warning:
allowedToolsdoes not limitbypassPermissions. WithallowedTools: ["Read"]andbypassPermissions, Bash, Write and Edit are all approved, because unlisted tools fall through to the mode. UsedisallowedToolsto block tools in that mode.
Permission modes
Which mode you start in
If you do not set a mode, Claude Code picks one: permissions.defaultMode from your settings files if one applies, otherwise the built-in default, which can be auto mode. A session that starts in auto mode drops broad allow rules such as a bare Bash. If your app depends on default behaviour or on such a rule, pass permissionMode: "default" explicitly. Before TypeScript SDK v0.3.286, leaving it out was the same as default.
The six modes
| Mode | In one line | Details |
|---|---|---|
default | Ask when unsure | No mode-based approvals. Calls needing approval with no allow rule go to canUseTool. |
dontAsk | Never ask, deny instead | Pre-approved and no-approval-needed calls run. Everything else is denied. canUseTool is never called. Org ask connector tools, interaction-required tools and critical-path removals are denied even if allowed. |
acceptEdits | Approve file changes | Edit and Write, plus mkdir, touch, rm, rmdir, mv, cp and sed, within the working directory or additionalDirectories. |
bypassPermissions | Approve almost everything | Only the actions no mode auto-approves are excluded. |
plan | Look, do not touch | Read-only tools run. File edits and (from v2.1.212) file-modifying shell commands always go to canUseTool. |
auto | A classifier decides | A model classifier reviews shell commands, network requests and similar, allowing or blocking each. See permission modes. |
acceptEdits limits
The auto-approval only covers paths inside the working directory or additionalDirectories. It does not approve work outside that scope, writes to protected paths, or rm/rmdir on a critical path. Other Bash commands still need normal permission. It suits prototyping or work in a throwaway directory.
dontAsk in practice
Tools pre-approved by allowedTools, settings allow rules or a hook run as normal, and so do calls that need no approval in default mode. A PreToolUse hook allow does not clear a critical-path removal. Use it when you want a fixed, explicit tool surface and a hard deny for everything else rather than relying on the absence of a callback.
bypassPermissions cautions
- Hooks still run and can block.
- Deny rules and explicit
askrules still apply, since they come earlier. - Org
askconnector tools, interaction-required tools and critical-path removals still go tocanUseTool. - The cross-session messaging safeguards still apply.
- On Linux and macOS, Claude Code refuses to start in this mode as root or under
sudooutside a recognised sandbox; the query fails before the first turn.
Only use it inside an environment you are happy for the agent to wreck. See secure deployment.
plan and later switching
In TypeScript, allowDangerouslySkipPermissions: true alongside permissionMode: 'plan' lets you switch to bypassPermissions later with setPermissionMode(). While still in plan mode, edits and file-modifying shell commands keep going to canUseTool. Claude may ask clarifying questions with AskUserQuestion while planning; see user input.
Subagents and modes
A subagent runs in the parent's mode, unless its definition sets permissionMode and the parent is in default, dontAsk or plan. Even then, a subagent's own bypassPermissions value is never applied (Claude Code v2.1.267+): a subagent only bypasses when the parent session does. Be careful: inheriting bypassPermissions gives a subagent with a different prompt full system access.
Setting and changing the mode
Set it at the start:
from claude_agent_sdk import query, ClaudeAgentOptions
async for msg in query(prompt="Rename the Order model to PurchaseOrder",
options=ClaudeAgentOptions(permission_mode="plan")):
...
Change it mid-session. A common flow is plan first, review, then let edits through:
import { query } from "@anthropic-ai/claude-agent-sdk";
const q = query({ prompt: "Rename the Order model to PurchaseOrder", options: { permissionMode: "plan" } });
// ...after your UI shows the plan and the user clicks Approve:
await q.setPermissionMode("acceptEdits");
for await (const msg of q) {
if (msg.type === "result") console.log(msg.subtype);
}
In Python, use ClaudeSDKClient and await client.set_permission_mode("acceptEdits"). The new mode applies to every subsequent tool request straight away.