> ## Documentation Index
> Fetch the complete documentation index at: https://ade-app.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Permissions and approvals

> The three forms of `permissions`, the per-tool policy object, and answering an approval with thread.approve() before it blocks the turn forever.

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.

<Warning>
  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`](#approval-timeout) so the SDK declines what nobody answers.
</Warning>

## What `"default"` actually does

It is not one behavior. `"default"` leaves each provider's own handling in place, and the providers disagree:

| Provider | `permissions: "default"`, no policy |
| - | - |
| claude | ADE installs no gate at all. The decision belongs to the Claude Agent SDK running non-interactively with no `canUseTool` callback and no terminal, which returns a refusal to the model as a tool error. You get no `approval_request`. |
| codex | An approval request **parks the turn** until something answers it. Shell commands, file patches and MCP tool calls all do this. |
| cursor · droid · opencode · pi | The provider's own non-interactive behavior. No ADE gate. |

If you want a gate you can actually see and answer, pass a policy.

## The policy object

```ts theme={null}
const thread = await ade.threads.open("assistant", {
  provider: "claude",
  model: "claude-sonnet-4-5",
  permissions: {
    allowedTools: ["mcp:catalog:*", "Read", "Glob"],
    deniedTools: ["Bash"],
    autoApproveMcpServers: ["catalog"],
    sandboxRoot: "/Users/me/Library/Application Support/MyApp/work",
    fallback: "ask",
    approvalTimeoutMs: 120_000, // optional
  },
});
```

| Field | Meaning |
| - | - |
| `allowedTools` | Runs without asking. Exact names, or a trailing `*` for a prefix. |
| `deniedTools` | Refused outright, never asked about. |
| `autoApproveMcpServers` | Every tool of these servers is allowed. Identical to `mcp:<server>:*`. |
| `sandboxRoot` | Absolute path. File writes inside it auto-approve, as do Codex commands, file changes, and permission escalations; outside they follow `fallback`. A relative path is judged against the request's own working directory, and falls through when there is none. Claude's `Bash` names no path, so a command follows the tool rules and `fallback` rather than the root. Not applied on Claude under `fallback: "deny"` — see below. |
| `fallback` | **Required.** What happens to everything unmatched. |
| `approvalTimeoutMs` | Optional. The SDK declines an approval that nobody answered in this many milliseconds. See [Approval timeout](#approval-timeout). |

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](#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"`:

| Tool call | `canUseTool` fires |
| - | - |
| An MCP tool | Yes |
| A command that changes something, for example `touch …` | Yes |
| A command that Claude Code classifies as read-only, for example `echo …` | **No.** Claude Code runs it without asking anyone |

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:

```ts theme={null}
// enforced — the whole server was the stated intent
permissions: { allowedTools: ["mcp:srv:*"], fallback: "deny" }

// best-effort — only the per-call gate refuses mcp:srv:delete
permissions: { allowedTools: ["mcp:srv:search"], fallback: "deny" }
```

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

| Provider | Policy level | What it means |
| - | - | - |
| claude, `fallback: "deny"` | **enforced**, or best-effort when an allow entry names one MCP tool | The tool lists and the MCP scope, both applied up front. See [above](#what-fallback-deny-does-on-claude). |
| claude, `fallback: "ask"` | best-effort | The same two lists, plus a `canUseTool` callback. Residual: the ask verdict needs the Agent SDK to call ADE's gate. On 0.3.280 it does for MCP tools and for commands that change things, but not for a command that Claude Code classifies as read-only, which runs without an approval. A Claude setting loaded through `settingSources` (`permissions.allow`, or `permissions.defaultMode: auto`) can also approve an unlisted tool before the gate runs. |
| codex | best-effort | `approvalPolicy: on-request` with a `workspace-write` sandbox. The policy governs sandbox **escapes** and **MCP tool calls**. An escape needs a `sandboxRoot` to be auto-accepted. An MCP tool call is judged on its tool name. See [below](#codex-mcp-tool-calls). |
| cursor | unsupported | The Cursor SDK takes a mode preset, not a rule set. |
| droid | unsupported | The Factory SDK takes an autonomy level, not a rule set. |
| opencode | unsupported | OpenCode takes an agent profile, not a rule set. |
| pi | unsupported | The Pi SDK takes a tool policy ADE derives from the session mode. |

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:

```ts theme={null}
if (thread.permissionCapability?.level !== "enforced") {
  console.warn(thread.permissionCapability?.residual);
}
```

`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.

<Note>
  **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"`.
</Note>

### 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.

<Warning>
  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.
</Warning>

## Answering an approval

```ts theme={null}
thread.on("event", (envelope) => {
  if (envelope.event.type === "approval_request") {
    showCard(envelope.event); // itemId, kind, description, detail
  }
  if (envelope.event.type === "pending_input_resolved") {
    settleCard(envelope.event.itemId, envelope.event.resolution);
  }
});

await thread.approve(itemId, "accept");        // this one time
await thread.approve(itemId, "accept_always"); // and every later one like it
await thread.approve(itemId, "reject", "not this time");
```

`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.

```ts theme={null}
for (const request of await thread.pendingApprovals()) {
  showCard(request);
}
```

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

```ts theme={null}
permissions: { allowedTools: ["mcp:app:*"], fallback: "ask", approvalTimeoutMs: 120_000 }
```

`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"`

```ts theme={null}
permissions: "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

<Card title="Threads" href="/docs/sdk/threads" icon="comments" horizontal>
  Instructions, working directory, and configuration layers.
</Card>
