Skip to main content
Every thread gates tool use one of three ways: the "default" preset, the "always-allow" preset, or a policy object that names the tools individually.
An unanswered approval blocks the turn. The runtime has no timeout — the turn stays parked until you call approve() or interrupt(). Render a card for every approval_request you receive, pass fallback: "deny" so nothing ever asks, or set approvalTimeoutMs so the SDK declines what nobody answers.

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 emit approval_request and wait for approve().

One exemption from asking

Under fallback: "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 explicit allowedTools entry 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 autoApproveMcpServers and every mcp:<server>:… entry in allowedTools. A server you never name is unreachable, including one you supplied yourself in mcpServers. Name it in the policy to use it.
  • The canUseTool gate is also wired, and it denies everything unmatched. It is a second line, not the first.
Two clauses do not survive this, and the thread’s 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:
If you need tool-level separation on Claude, split the tools across separate MCP servers and allow the whole of each. The 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 named mcp:<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:
  1. deniedTools → declined.
  2. allowedTools or autoApproveMcpServers → accepted. No card is shown, the same as a Claude auto-allow.
  3. fallback. "deny" declines it, and the transcript still records the approval_request and a pending_input_resolved with resolution: "declined". "ask" raises an approval_request with requestKind: "approval", which approve() answers.
Codex does not send the tool name as a field. The runtime reads it from _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, or mcp:<server>:*.
  • Any deniedTools entry that could name one of that server’s tools sends it to fallback.
Other MCP elicitations, such as a server’s own form or a URL sign-in, are not tool approvals. The policy reaches them only through fallback: "deny", which declines them. Under "ask" they appear as questions.
Before runtime 1.2.81 the policy did not judge these requests. They arrived as structured_question, which approve() refuses, so under any policy a Codex thread stopped at its first MCP tool call until you called interrupt(). Earlier versions of this page said that Codex does not gate MCP tool calls. That was wrong for Codex 0.156.1.

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.
Requests whose 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_option when the thread opens.

"always-allow"

Maps to each provider’s full-auto create args: 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.