Permission modes, approvals, and structured input
T3 Code persists a four-value runtime-mode choice on a thread, but each provider maps that choice into its own controls; live approval and structured-input requests travel through distinct canonical flows.
What this chapter resolves
- Name the four canonical runtime modes without treating them as an equivalent provider security policy.
- Distinguish approval requests from structured user-input requests, including their response payloads and pending state.
- Trace an interactive response through durable intent, the provider reactor, and native completion.
T3 Code has four persisted runtime-mode values: approval-required,
auto-accept-edits, auto, and full-access. They belong to the thread and
travel with provider session/turn start. They are compact product controls, not
proof that five native runtimes have the same sandbox, reviewer, allow-list, or
escalation semantics.
Four durable labels, provider-specific effects
The canonical union contains exactly these four values; its default is
full-access. A runtime-mode change becomes a thread.runtime-mode-set event.
When a turn starts, the decider writes the target thread’s already-stored runtime
and interaction modes into the start event. Read the labels as durable thread
policy, not an independent per-turn override hidden in the start command.
return Effect.succeed(base as typeof ModelSelectionSource.Encoded);
},
}),
),
);
export type ModelSelection = typeof ModelSelection.Type;
export const RuntimeMode = Schema.Literals([
"approval-required",
"auto-accept-edits",
"auto",
"full-access",
]);
export type RuntimeMode = typeof RuntimeMode.Type;
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
export type ProviderInteractionMode = typeof ProviderInteractionMode.Type;
export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";
export const ProviderRequestKind = Schema.Literals([
"command",
"file-read",
"file-change",
"mcp-elicitation",
]);| T3 mode | Codex app-server mapping | Claude SDK mapping | Cursor ACP path | Grok ACP path | OpenCode SDK mapping |
|---|---|---|---|---|---|
approval-required |
untrusted approval, read-only sandbox, user reviewer |
no explicit entry in the adapter’s mode map; base mode is left unset | prefers a native approval-mode alias when offered; permission callbacks still wait | no ACP session-mode mapping; a raised permission callback waits for an explicit response | broad operations ask; question is allowed |
auto-accept-edits |
on-request, workspace-write, user reviewer |
acceptEdits |
prefers an implement-mode alias when offered; permission callbacks still wait | no ACP session-mode mapping; a raised permission callback waits for an explicit response | broad operations ask; question is allowed |
auto |
on-request, workspace-write, auto_review reviewer |
auto |
prefers an implement-mode alias when offered; permission callbacks still wait | no ACP session-mode mapping; a raised permission callback waits for an explicit response | broad operations ask; question is allowed |
full-access |
never, danger-full-access, user reviewer |
bypassPermissions plus allowDangerouslySkipPermissions: true |
prefers an implement-mode alias and auto-selects an offered allow option; otherwise waits on the callback | auto-selects an offered allow-always or allow-once option; otherwise waits on the callback | allows all permission rules |
The table records the executed mappings. It does not turn
the blank Claude map entry, Cursor mode aliases, Grok callback policy, or OpenCode’s
shared non-full-access ruleset into claims of equivalent safety. Codex, for example,
also selects a native sandbox and reviewer for every mode. Claude intercepts
AskUserQuestion before the full-access shortcut, so it still uses structured input;
ExitPlanMode is intercepted separately.
},
) {
override get message(): string {
return `Invalid Codex user input answers for question '${this.questionId}'`;
}
}
export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedError<CodexSessionRuntimeThreadIdMissingError>()(
"CodexSessionRuntimeThreadIdMissingError",
{
threadId: Schema.String,
},
) {
override get message(): string {
return `Codex session is missing a provider thread id for ${this.threadId}`;
}
}
interface PendingApproval {
readonly requestId: ApprovalRequestId;
readonly jsonRpcId: string;
readonly requestKind: ProviderRequestKind;
readonly turnId: TurnId | undefined;
readonly itemId: ProviderItemId | undefined;
readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
}
interface ApprovalCorrelation {
readonly requestId: ApprovalRequestId;
readonly requestKind: ProviderRequestKind;
readonly turnId: TurnId | undefined;
readonly itemId: ProviderItemId | undefined;
}
interface PendingUserInput {
readonly requestId: ApprovalRequestId;
readonly turnId: TurnId | undefined;
readonly itemId: ProviderItemId | undefined;
readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
}
type McpElicitationPersistenceDecision = Extract<
ProviderApprovalDecision,
"acceptForSession" | "acceptAlways"
>;
function mcpElicitationPersistenceDecision(
value: string,
): McpElicitationPersistenceDecision | null {
const normalized = value.toLowerCase();
if (normalized.includes("session")) return "acceptForSession";
if (
normalized.includes("always") ||
normalized.includes("permanent") ||
normalized.includes("forever") ||
normalized.includes("persistent")
) {
return "acceptAlways";
}
return null;
}
function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) {
if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) {
return undefined;
}
return payload.requestedSchema;
}
function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) {
if (field.oneOf) {
return field.oneOf.map((option) => ({ value: option.const, label: option.title }));
}Two interactive request shapes
An approval asks whether a provider may perform an action. Its canonical response
contains a requestId and one decision: accept, acceptForSession, decline,
or cancel. Runtime ingestion classifies known native request types into command,
file-read, and file-change when it can; unknown native request types remain an
approval without a fabricated subtype.
Structured input is different. It carries questions with ids, headers, options, and optional multi-select behavior. Its response is an answer record keyed by question id. An answer is not an approval decision, and an approval choice is not an answer map.
delta: Schema.String,
});
export type TurnProposedDeltaPayload = typeof TurnProposedDeltaPayload.Type;
const TurnProposedCompletedPayload = Schema.Struct({
planMarkdown: TrimmedNonEmptyStringSchema,
});
export type TurnProposedCompletedPayload = typeof TurnProposedCompletedPayload.Type;
const TurnDiffUpdatedPayload = Schema.Struct({
unifiedDiff: Schema.String,
});
export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type;
export const ToolActivitySurface = Schema.Literals(["browser", "computer"]);
export type ToolActivitySurface = typeof ToolActivitySurface.Type;
export const ToolActivityNativeAppReference = Schema.Union([
Schema.TaggedStruct("app-id", {
appId: TrimmedNonEmptyStringSchema.check(
Schema.isMaxLength(512),
Schema.isPattern(/^[A-Za-z0-9._-]+$/u),
),
}),
Schema.TaggedStruct("display-name", {
displayName: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(160)),
}),
]);
export type ToolActivityNativeAppReference = typeof ToolActivityNativeAppReference.Type;
export const ToolActivityIcon = Schema.Union([
Schema.TaggedStruct("website", {
pageUrl: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096)),
faviconUrl: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096))),
faviconUrlDark: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096))),
}),
Schema.TaggedStruct("native-app", {
app: ToolActivityNativeAppReference,
}),
Schema.TaggedStruct("themed-logo", {- Codex maps
item/tool/requestUserInputintouser-input.requestedand native approval methods intorequest.opened. - Claude handles
AskUserQuestionthrough the input flow; other tool decisions use the approval flow unless its runtime mode is full access. - Cursor and Grok wait on ACP deferred values for permission callbacks and their question extensions.
- OpenCode maps
permission.askedandquestion.askedseparately, then callspermission.replyorquestion.reply.
All six adapters implement the shared response boundary, with provider-specific support for originating each request shape. That does not promise every provider originates both request kinds in every configuration. The adapter capability surfaces are the positive evidence here; they are not a guarantee that a native runtime will open a given request type during every session.
Pending is partly durable and partly live
Activities record approval.requested/approval.resolved and
user-input.requested/user-input.resolved. Web and mobile derive open requests
from ordered activity history, allowing another client surface to discover what
needs attention.
Approvals additionally receive a projected pending-approval row, keyed by request id, with pending/resolved status, decision, and timestamps. Structured input does not reuse that table; its pending state derives from activities. The native adapter’s Deferred, map entry, or SDK callback remains live memory. A durable request record cannot recreate that wait after a process or server restart.
One response crosses four responsibilities
A web or mobile client dispatches either thread.approval.respond or
thread.user-input.respond. The decider records response-requested intent. The
provider command reactor resolves the thread, checks for a non-stopped session,
calls ProviderService, and that service routes to the bound adapter. The adapter
replies to its native runtime; later canonical resolution events return through
runtime ingestion and become durable activities.
Acceptance of the client command is not a synchronous certificate that a provider completed the action, and the hot provider bridge is not a replayable outbox.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Provider runtime request flows to ingestion, durable activity and optional pending-approval projection, then a client response command that records durable response intent. A provider command reactor routes that intent to the live bound adapter callback, which may emit a later resolution activity. A restart boundary separates durable request records from the in-memory provider callback.
packages/contracts/src/providerRuntime.ts:430–469 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:299–539 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:1707–1749 ↗apps/server/src/orchestration/decider.ts:1061–1110 ↗apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1199–1285 ↗apps/server/src/orchestration/Layers/ProjectionPipeline.ts:1540–1601 ↗Surface resolution and security boundaries
Web session logic and mobile’s selected-thread hook both derive pending approvals and pending user input from thread activities, then dispatch distinct commands. Mobile scopes drafts by environment-plus-request id. The shared model is a request addressed to a thread; each client chooses its controls.
| Boundary | Owns | Does not prove |
|---|---|---|
| Thread/orchestration | selected mode, response intent, activities, projected approval state | a live native request still exists or an action succeeded |
| Client | request presentation and answer/decision collection | sandbox enforcement or provider identity |
| Provider reactor/service | exact bound-session route and response failures | provider parity or crash-proof delivery |
| Adapter/native runtime | reviewer, sandbox, callback, and reply semantics | that a T3 label means the same thing elsewhere |
| Person operating the client | the authorization decision | that a broad mode replaces reviewing a specific request |
Permission capability metadata could make these differences easier to negotiate in a UI, but no central inbox is implemented. That is a future design direction, not current behavior.