From b742293602f79e49724ba82840571454c8af167b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 05:56:45 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20"Auto"=20runtime=20mode=20?= =?UTF-8?q?=E2=80=94=20AI-reviewed=20approvals=20for=20Codex=20and=20Claud?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "auto" RuntimeMode between auto-accept-edits and full-access. Both native agent backends recently shipped a model-reviewed approval mode that is strictly safer than full access: - Codex: keeps the workspace-write sandbox and on-request approvals but sets approvalsReviewer: "auto_review", so a reviewer subagent decides approval requests (sandbox escapes, network access) instead of the user. - Claude: passes permissionMode: "auto", where a classifier model approves/denies each permission prompt; escalations still flow through canUseTool into the existing approval UI, and classifier denials already surface via the permission_denied -> tool.denied event. Other adapters (Cursor, Grok, OpenCode) fall through to their existing non-full-access behavior for the new mode. Co-Authored-By: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 5 +++- .../src/features/threads/ThreadComposer.tsx | 5 +++- .../src/provider/Layers/ClaudeAdapter.test.ts | 19 ++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 1 + .../Layers/CodexSessionRuntime.test.ts | 25 +++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 10 ++++++++ apps/web/src/components/chat/ChatComposer.tsx | 6 +++++ .../chat/CompactComposerControlsMenu.tsx | 1 + packages/contracts/src/orchestration.ts | 1 + 9 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce81db8c486..e328bcef00e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -582,10 +582,13 @@ export function NewTaskDraftScreen(props: { ? "Approve actions" : flow.runtimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : flow.runtimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e712664d30a..9c9a90f7a6f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -627,10 +627,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Approve actions" : currentRuntimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : currentRuntimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2735b69a187..ba8b1b848a8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -356,6 +356,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 757d7a00eb2..285d9dac608 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3509,6 +3509,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", + auto: "auto", "full-access": "bypassPermissions", }; const permissionMode = runtimeModeToPermission[input.runtimeMode]; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 119fa36303a..07370193bc8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -193,6 +193,31 @@ describe("buildTurnStartParams", () => { NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); }); + it.effect("routes approvals to the auto reviewer in auto mode", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto", + prompt: "Ship it", + }); + + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxPolicy: { + type: "workspaceWrite", + }, + input: [ + { + type: "text", + text: "Ship it", + }, + ], + }); + }), + ); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5a81e915e34..bae1530fc66 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -264,6 +264,7 @@ function readResumeCursorThreadId( function runtimeModeToThreadConfig(input: RuntimeMode): { readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; + readonly approvalsReviewer?: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; } { switch (input) { case "approval-required": @@ -276,6 +277,12 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { approvalPolicy: "on-request", sandbox: "workspace-write", }; + case "auto": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + approvalsReviewer: "auto_review", + }; case "full-access": default: return { @@ -296,6 +303,7 @@ function buildThreadStartParams(input: { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, + ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; @@ -310,6 +318,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "auto": return { type: "workspaceWrite", }; @@ -382,6 +391,7 @@ export function buildTurnStartParams(input: { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, + ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 94e4af3bba6..ac137c8b075 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -107,6 +107,7 @@ import { LockIcon, LockOpenIcon, PenLineIcon, + SparklesIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; @@ -148,6 +149,11 @@ const runtimeModeConfig: Record< description: "Auto-approve edits, ask before other actions.", icon: PenLineIcon, }, + auto: { + label: "Auto", + description: "An AI reviewer approves routine actions; risky ones still ask.", + icon: SparklesIcon, + }, "full-access": { label: "Full access", description: "Allow commands and edits without prompts.", diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..b808f562920 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -71,6 +71,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls > Supervised Auto-accept edits + Auto Full access {props.activePlan ? ( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b01df310062..e1ddbdbd0cc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -117,6 +117,7 @@ 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; From 8c2ff69c0b808cb2d9f6d4b3ab11c262c0901161 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 06:02:53 -0700 Subject: [PATCH 2/2] fix: always send approvalsReviewer explicitly so it resets on mode switch thread/resume treats an omitted approvalsReviewer as "keep current", so switching a thread out of Auto would leave auto_review sticky. Send "user" explicitly for every non-auto mode. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/CodexSessionRuntime.test.ts | 3 +++ .../server/src/provider/Layers/CodexSessionRuntime.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 07370193bc8..d7346a0e0db 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -104,6 +104,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", + approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess", }, @@ -149,6 +150,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", + approvalsReviewer: "user", sandboxPolicy: { type: "workspaceWrite", }, @@ -230,6 +232,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", + approvalsReviewer: "user", sandboxPolicy: { type: "readOnly", }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index bae1530fc66..67108dd4dbb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -264,18 +264,22 @@ function readResumeCursorThreadId( function runtimeModeToThreadConfig(input: RuntimeMode): { readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; - readonly approvalsReviewer?: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; + // Always explicit: omitting the field on resume keeps the thread's previous + // reviewer, which would leave auto_review sticky after switching modes. + readonly approvalsReviewer: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; } { switch (input) { case "approval-required": return { approvalPolicy: "untrusted", sandbox: "read-only", + approvalsReviewer: "user", }; case "auto-accept-edits": return { approvalPolicy: "on-request", sandbox: "workspace-write", + approvalsReviewer: "user", }; case "auto": return { @@ -288,6 +292,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { return { approvalPolicy: "never", sandbox: "danger-full-access", + approvalsReviewer: "user", }; } } @@ -303,7 +308,7 @@ function buildThreadStartParams(input: { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), + approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; @@ -391,7 +396,7 @@ export function buildTurnStartParams(input: { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), + approvalsReviewer: config.approvalsReviewer, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),