"default" preset, the "always-allow" preset, or a policy object that names the tools individually.
What "default" actually does
It is not one behavior. "default" leaves each provider’s own handling in place, and the providers disagree:
If you want a gate you can actually see and answer, pass a policy.
The policy object
Precedence on Claude, highest first:
deniedTools, then allowedTools and autoApproveMcpServers, then sandboxRoot containment, then fallback.
On Codex the rungs depend on the request. A shell command, a file change or a permission escalation is decided by sandboxRoot containment and then fallback. An MCP tool call is decided by deniedTools, then allowedTools and autoApproveMcpServers, then fallback. See Codex MCP tool calls.
sandboxRoot is containment. cwd is the plain working directory. They are separate fields precisely so setting one does not read as implying the other.
fallback is required on purpose. A policy with no fallback has no answer for an unmatched tool, and defaulting to "ask" would silently park the turn for a host that built no approval UI. Choose it deliberately:
"deny"— nothing ever asks. The turn cannot park. This is what a host with no approval UI wants. On Claude it is also the stronger of the two: it takes the unnamed tools away rather than gating them."ask"— unmatched tools emitapproval_requestand wait forapprove().
One exemption from asking
Underfallback: "ask" on Claude, the model’s own read-only built-ins run without a card: Read, Glob, Grep, ToolSearch, TaskList, TaskGet, WebFetch and WebSearch. A host that wants to be asked about writes would otherwise be asked about every file read, which teaches a user to click Allow without reading it.
The check is literal membership of that list, never a substring. It cannot reach one of your MCP tools: an MCP tool always keeps its mcp__<server>__ prefix, so mcp:docs:read asks like anything else. A tool’s risk is never inferred from its name.
Name a built-in in deniedTools to override the exemption, or use fallback: "deny", under which nothing unmatched runs at all.
deniedTools outranks everything, including a tool that would otherwise let itself through. Claude’s AskUserQuestion and ADE’s own ask_user normally skip the card, because each carries its own answer UI and a generic “Allow this tool?” card would hide the real question behind an extra click. Naming either in deniedTools still refuses it. The mirror also holds: an allowedTools entry means a tool may run, not that it skips its own machinery — allow-listing AskUserQuestion lets it ask, it does not answer for the user.
When Claude asks ADE’s gate
ADE measured this on 2026-09-28 against the pinned Agent SDK 0.3.280 (Claude Code 2.1.280), with permission mode"default" and settingSources: "none":
Against Agent SDK 0.3.258,
canUseTool did not fire on any permission mode tried. Earlier versions of this page said so.
allowedTools and disallowedTools stay the primary enforcement. The CLI removes a denied tool from the model’s catalog, and that holds whatever the prompt path does.
What fallback: "deny" does on Claude
A deny fallback is expressed in what the thread is given, not in what it is asked:
- Every Claude mutating built-in your policy does not name is denied up front:
Bash,Write,Edit,MultiEdit,NotebookEdit,Agent,Task,KillShell. Only an explicitallowedToolsentry keeps one. Read-only built-ins stay available — a deny fallback stops the agent changing things, it does not blind it. - MCP is restricted to the servers your policy names, drawn from
autoApproveMcpServersand everymcp:<server>:…entry inallowedTools. A server you never name is unreachable, including one you supplied yourself inmcpServers. Name it in the policy to use it. - The
canUseToolgate is also wired, and it denies everything unmatched. It is a second line, not the first.
permissionCapability reports both rather than hiding them.
sandboxRoot is a per-call decision about a path. A deny fallback removes every mutating built-in that the policy does not name from the catalog first, so no call reaches the containment check. A mutating built-in is refused outright rather than allowed inside the root. This does not lower the level, because refusing is stricter than the root and never looser, but the residual says it. If you want containment on Claude rather than refusal, use fallback: "ask" and answer the approvals.
An allowedTools entry naming one MCP tool admits its whole server. The allowlist is per-server, so letting mcp:srv:search run makes srv reachable. Only the per-call canUseTool gate then refuses mcp:srv:delete. On 0.3.280 that gate fires for MCP tools, but a Claude setting that you load through settingSources can approve the tool before the gate is asked. A policy in that shape reports best-effort with a residual naming the servers whose unnamed tools only the gate refuses:
residual also names any server you passed in mcpServers that the policy does not admit, since those are unreachable under a deny fallback.
Under fallback: "ask" none of this applies: no tools are removed from the catalog and MCP is not scoped.
Tool names are provider-neutral
MCP tools are namedmcp:<server>:<tool>, or mcp:<server>:* for all of them. The runtime translates to each provider’s own spelling — Claude writes mcp__server__tool.
Any other string matches the provider’s own tool name, case-insensitively, with an optional trailing *. Built-in tool names are provider-specific: Bash and Edit mean something on Claude and nothing on Pi.
A tool is never gated because of what its name contains. mcp:studio:edit_clip and mcp:studio:list_agents are asked about only if your policy says so.
Per-provider enforcement
Read the thread’s own report rather than the table, which is a summary. On Claude the level follows the policy’s shape, not just the provider:
permissionCapability is null when the thread opened with a preset rather than a policy, and also when an older runtime reported nothing — treat the second case as unenforced.
The SDK logs the same two shapes at open time: one line naming any server you supplied in mcpServers that a deny fallback blocks, and one naming any allow entry that admits a whole server. Neither widens the policy; both exist because neither has a symptom you would otherwise see.
Codex’s sandbox decides more than the policy does. Under
sandbox: workspace-write a command or file change inside the thread’s cwd, $TMPDIR or /tmp raises no approval request at all, so it reaches neither sandboxRoot nor fallback. Only a sandbox escape is ever put to the policy.An escape needs a sandboxRoot to be auto-accepted. A policy that names one auto-accepts escapes contained by it and sends the rest to fallback. A policy with no sandboxRoot approved no directory, so every escape goes straight to fallback: "ask" parks it on an approval_request, "deny" declines it. The presence of a policy object is not itself an approval of the working directory.On Codex, allowedTools, deniedTools and autoApproveMcpServers apply to MCP tool calls only. A Codex command escape is decided by sandboxRoot containment and then fallback, and the command text is never read. Do not send deniedTools: ["Bash(rm *)"] to Codex and read it as a refusal — a rm whose working directory sits inside sandboxRoot is auto-accepted. To refuse a command on Codex, leave the escape uncontained and set fallback: "deny".Codex MCP tool calls
Codex (@openai/codex 0.156.1) asks before every MCP tool call. It sends an MCP elicitation with _meta.codex_approval_kind: "mcp_tool_call" and the message Allow the <server> MCP server to run tool "<tool>"?. Since runtime 1.2.81, the policy judges that request as mcp:<server>:<tool>, in the same order as on Claude:
deniedTools→ declined.allowedToolsorautoApproveMcpServers→ accepted. No card is shown, the same as a Claude auto-allow.fallback."deny"declines it, and the transcript still records theapproval_requestand apending_input_resolvedwithresolution: "declined"."ask"raises anapproval_requestwithrequestKind: "approval", whichapprove()answers.
_meta.tool_name when Codex sends one, and else from the message text. When it cannot read a tool name (for example, an app connector’s approval), it judges the call on the server alone:
- Only a whole-server allowance approves it:
autoApproveMcpServers, ormcp:<server>:*. - Any
deniedToolsentry that could name one of that server’s tools sends it tofallback.
fallback: "deny", which declines them. Under "ask" they appear as questions.
Answering an approval
approve() resolves once the runtime accepts the decision, not once the tool has run — the same asymmetry send() has.
On a Codex MCP tool approval, accept_always tells Codex to remember the answer for this session (persist: "session"). Before runtime 1.2.81 it sent "always", and Codex then wrote the approval to the user’s own ~/.codex/config.toml for every later chat. The runtime sends "always" only when that is the one scope Codex offers.
On Claude, an ask can carry a flag saying the session-wide rule an accept_always would write is broader than the ask itself. accept_always is still a valid answer, but the runtime treats it as a one-shot accept: nothing is persisted, an earlier accept_always does not settle that ask, and the ask’s option list omits the session-wide choice.
It throws approval_not_found when the item is not pending: a stop, a teardown, or an earlier call already settled it. The engine settles unknown items silently, so without that check a second click on Allow would look like it worked.
interrupt() is not a substitute. It aborts the turn without answering the request, and a later approve() on that item throws approval_not_found.
Restoring cards after a reload
Approval requests outlive the client that saw the event.requestKind is "approval" or "permissions" can be answered with approve(). A Codex MCP tool approval is "approval". The other kinds — "question", "structured_question", "plan_approval", "model_selection" — want prose or a choice this surface cannot carry. Render those read-only rather than offering Allow and Reject for something that wants a sentence.
approve() refuses those four with invalid_option rather than sending the decision. The engine would take an accept for a question and the request would stay unanswered, so the turn would park while the call resolved — the failure this check exists to make visible.
Against a runtime with no pendingInputs action, the SDK reconstructs the list from the events this client observed and logs that it is doing so. That list cannot include anything raised before the client connected.
Approval timeout
approvalTimeoutMs is opt-in. It has no default: to continue after N seconds is a security decision, and to refuse after N seconds breaks a long human review, so you choose.
The SDK enforces it, not the runtime. The SDK removes the field before the policy goes to the runtime. When an approval-shaped request (requestKind "approval" or "permissions") is still pending after the timeout, the SDK answers it decline and logs a line. The transcript records the refusal like any other.
Limits:
- Only requests that this client saw are timed, from the moment it saw them. A request raised before the client connected, or while its runtime was down, is not timed.
- Questions, plan approvals and model selections are not timed. A decline is not an answer to them.
- An answer, a
pending_input_resolved, or the turn’s end stops the clock. - A value that is not a positive number throws
invalid_optionwhen the thread opens.
"always-allow"
bypassPermissions on Claude, approval_policy: never with danger-full-access on Codex, full-auto on OpenCode, auto-high on Droid. Cursor and Pi carry it as the generic full-auto mode alone. Nothing asks, and nothing is contained. It is the right choice for a trusted internal tool and the wrong one for an app holding a user’s credentials.
Next
Threads
Instructions, working directory, and configuration layers.
