From 0138b828f5aba8954d03d7f816140964dea7011d Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:11:26 +0200 Subject: [PATCH 1/6] fix(server): Claude Auto mode asks before commands it escalates Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 320 ++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 9 +- 2 files changed, 222 insertions(+), 107 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 42d24d0076d6e..3c8037d2623e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -455,6 +455,21 @@ describe("ClaudeAdapterV2 runtime query policy", () => { }); }); + it("maps Auto runtime mode to Claude's AI-reviewed permission mode and asks about escalations", () => { + const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", + }), + ); + + assert.deepEqual(queryPolicy, { + permissionMode: "auto", + installPermissionCallback: true, + }); + }); + it("keeps approval checks when Full access must fall back to an allowed Claude mode", () => { const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", @@ -882,117 +897,148 @@ describe("ClaudeAdapterV2 session permissions", () => { }); }); -describe("ClaudeAdapterV2 Auto-accept edits", () => { - it.effect("asks before a command instead of allowing it", () => +// Opens a Claude turn in the given permission mode and returns the runtime +// with the permission callback the SDK query was opened with. +const openClaudePermissionTurn = Effect.fn("openClaudePermissionTurn")(function* ( + runtimeMode: "auto-accept-edits" | "auto" | "full-access", +) { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-permission-callback-", + }); + let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + crypto: yield* Crypto.Crypto, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed(`native-thread-claude-${runtimeMode}`), + open: (input) => + Effect.sync(() => { + openedOptions = input.options; + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + applyFlagSettings: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/workspace", + }); + const threadId = ThreadId.make(`thread-claude-${runtimeMode}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-claude-${runtimeMode}`), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-${runtimeMode}`), + text: "Run the command.", + attachments: [], + runtimePolicy, + }), + ); + const canUseTool = openedOptions?.canUseTool; + assert.isFunction(canUseTool); + return { runtime, permissionMode: openedOptions?.permissionMode, canUseTool: canUseTool! }; +}); + +// Sends a Bash command through the permission callback and reports whichever +// comes first: an approval request raised to the user, or the callback's own +// answer. +const requestClaudeCommandPermission = Effect.fn("requestClaudeCommandPermission")(function* ( + turn: Effect.Success>, + command: string, +) { + const requestEvents = yield* turn.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const decision = yield* Effect.promise(() => + turn.canUseTool( + "Bash", + { command }, + { + signal: new AbortController().signal, + toolUseID: `tool-bash-${command}`, + requestId: `request-bash-${command}`, + }, + ), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Fiber.join(requestEvents).pipe( + Effect.map((events) => { + const request = events.find((event) => event.type === "runtime_request.updated"); + const approval = events.findLast((event) => event.type === "turn_item.updated"); + return request?.type === "runtime_request.updated" + ? ({ + type: "request", + runtimeRequest: request.runtimeRequest, + prompt: + approval?.type === "turn_item.updated" && + approval.turnItem.type === "approval_request" + ? approval.turnItem.prompt + : undefined, + } as const) + : ({ type: "none" } as const); + }), + ), + Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), + ); + return { first, decision }; +}); + +describe("ClaudeAdapterV2 permission callback", () => { + it.effect("asks before a command in Auto-accept edits instead of allowing it", () => Effect.scoped( Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-accept-edits-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto-accept-edits", - interactionMode: "default", - cwd: "/workspace", - }); - const threadId = ThreadId.make("thread-claude-accept-edits"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-accept-edits"), - text: "Run node.", - attachments: [], - runtimePolicy, - }), - ); - assert.equal(openedOptions?.permissionMode, "acceptEdits"); - const canUseTool = openedOptions?.canUseTool; - assert.isFunction(canUseTool); + const turn = yield* openClaudePermissionTurn("auto-accept-edits"); + assert.equal(turn.permissionMode, "acceptEdits"); - const requestEvent = yield* runtime.events.pipe( - Stream.takeUntil( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "approval_request", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const command = { command: "node -e 'console.log(42)'" }; - const decision = yield* Effect.promise(() => - canUseTool!("Bash", command, { - signal: new AbortController().signal, - toolUseID: "tool-bash-accept-edits", - requestId: "request-bash-accept-edits", - description: "Check the Node runtime", - }), - ).pipe(Effect.forkScoped); - const first = yield* Effect.raceFirst( - Fiber.join(requestEvent).pipe( - Effect.map((event) => ({ type: "request", event }) as const), - ), - Fiber.join(decision).pipe( - Effect.map((result) => ({ type: "decision", result }) as const), - ), + const { first, decision } = yield* requestClaudeCommandPermission( + turn, + "node -e 'console.log(42)'", ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event.find((event) => event.type === "runtime_request.updated"); - assert.equal(event?.runtimeRequest.kind, "command"); - const approval = first.event.findLast((event) => event.type === "turn_item.updated"); - assert.equal( - approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, - command.command, - ); + assert.equal(first.runtimeRequest.kind, "command"); + assert.equal(first.prompt, "node -e 'console.log(42)'"); - yield* runtime.respondToRuntimeRequest({ - requestId: event!.runtimeRequest.id, + yield* turn.runtime.respondToRuntimeRequest({ + requestId: first.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); @@ -1003,6 +1049,72 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ), ), ); + + // Claude's auto mode only calls the callback for actions it will not + // approve itself, such as a command matching a user `ask` rule. + it.effect("asks before a command Claude escalates in Auto", () => + Effect.scoped( + Effect.gen(function* () { + const turn = yield* openClaudePermissionTurn("auto"); + assert.equal(turn.permissionMode, "auto"); + + const { first, decision } = yield* requestClaudeCommandPermission(turn, "git push"); + assert.equal(first.type, "request", "the escalated command ran without asking"); + if (first.type !== "request") return; + assert.equal(first.runtimeRequest.kind, "command"); + + yield* turn.runtime.respondToRuntimeRequest({ + requestId: first.runtimeRequest.id, + decision: "accept", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("denies an escalated command in Auto when the user declines it", () => + Effect.scoped( + Effect.gen(function* () { + const turn = yield* openClaudePermissionTurn("auto"); + + const { first, decision } = yield* requestClaudeCommandPermission(turn, "git push"); + assert.equal(first.type, "request", "the escalated command ran without asking"); + if (first.type !== "request") return; + + yield* turn.runtime.respondToRuntimeRequest({ + requestId: first.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "deny"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("allows an escalated command in Full access without asking", () => + Effect.scoped( + Effect.gen(function* () { + const turn = yield* openClaudePermissionTurn("full-access"); + assert.equal(turn.permissionMode, "bypassPermissions"); + + const { first } = yield* requestClaudeCommandPermission(turn, "git push"); + assert.equal(first.type, "decision", "Full access raised an approval request"); + if (first.type !== "decision") return; + assert.equal(first.result?.behavior, "allow"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); }); describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4d5238b74b709..797dce1db6146 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2598,12 +2598,15 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback; the callback must ask for everything else. + // Claude approves what its mode allows before the callback runs (edits in + // acceptEdits, classifier-approved actions in auto) and sends the rest, + // including user ask rules, to the callback, which must ask rather than + // allow. Only Full access answers it without asking, unless it was + // downgraded to a mode Claude allows here. const installPermissionCallback = downgraded || (runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" || - runtimePolicy.runtimeMode === "auto-accept-edits" + ? runtimePolicy.runtimeMode !== "full-access" : runtimePolicy.approvalPolicy !== "never"); if (permissionMode === "plan") { From 96032576eeae7f082fda343998e1a34125300e8c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:10:43 +0200 Subject: [PATCH 2/6] fix(server): Claude approval prompts show why they were raised The SDK nearly always sets a title, so the reason an ask escalated, such as a PreToolUse hook's permissionDecisionReason, was dropped from the prompt. It now follows the title on its own line, with ANSI escapes stripped. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 60 ++++++++++++++----- .../Adapters/ClaudeAdapterV2.ts | 35 ++++++++--- 2 files changed, 71 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3c8037d2623e6..b83cbe673999f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -39,6 +39,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2PendingBackgroundTask, OrchestrationV2ProviderThread, + type OrchestrationV2RuntimeRequest, ProjectId, ProviderInstanceId, type ResolvedProviderInstanceEnvironment, @@ -975,15 +976,21 @@ const openClaudePermissionTurn = Effect.fn("openClaudePermissionTurn")(function* // Sends a Bash command through the permission callback and reports whichever // comes first: an approval request raised to the user, or the callback's own -// answer. +// answer. A raised request also carries the prompt from its turn item. const requestClaudeCommandPermission = Effect.fn("requestClaudeCommandPermission")(function* ( turn: Effect.Success>, command: string, + callbackOptions: { readonly title?: string; readonly decisionReason?: string } = {}, ) { + // The runtime's events come off one queue, so a single subscriber collects + // both the request and the approval turn item emitted right after it. const requestEvents = yield* turn.runtime.events.pipe( - Stream.takeUntil( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + Stream.filter( + (event) => + event.type === "runtime_request.updated" || + (event.type === "turn_item.updated" && event.turnItem.type === "approval_request"), ), + Stream.take(2), Stream.runCollect, Effect.forkScoped, ); @@ -995,25 +1002,24 @@ const requestClaudeCommandPermission = Effect.fn("requestClaudeCommandPermission signal: new AbortController().signal, toolUseID: `tool-bash-${command}`, requestId: `request-bash-${command}`, + ...callbackOptions, }, ), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( Fiber.join(requestEvents).pipe( Effect.map((events) => { - const request = events.find((event) => event.type === "runtime_request.updated"); - const approval = events.findLast((event) => event.type === "turn_item.updated"); - return request?.type === "runtime_request.updated" - ? ({ - type: "request", - runtimeRequest: request.runtimeRequest, - prompt: - approval?.type === "turn_item.updated" && - approval.turnItem.type === "approval_request" - ? approval.turnItem.prompt - : undefined, - } as const) - : ({ type: "none" } as const); + let runtimeRequest: OrchestrationV2RuntimeRequest | undefined; + let prompt: string | undefined; + for (const event of events) { + if (event.type === "runtime_request.updated") runtimeRequest = event.runtimeRequest; + if (event.type === "turn_item.updated" && event.turnItem.type === "approval_request") { + prompt = event.turnItem.prompt; + } + } + return runtimeRequest === undefined + ? ({ type: "none" } as const) + : ({ type: "request", runtimeRequest, prompt } as const); }), ), Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), @@ -1076,6 +1082,28 @@ describe("ClaudeAdapterV2 permission callback", () => { ), ); + // A PreToolUse hook that returns `ask` explains itself in `decisionReason`; + // the SDK's title alone would hide why the request was raised. + it.effect("shows a hook's escalation reason after the command in Auto", () => + Effect.scoped( + Effect.gen(function* () { + const turn = yield* openClaudePermissionTurn("auto"); + + const { first } = yield* requestClaudeCommandPermission(turn, "git push", { + title: "Claude wants to run git push", + decisionReason: "\u001b[33mPushing needs confirmation\u001b[0m", + }); + assert.equal(first.type, "request", "the escalated command ran without asking"); + if (first.type !== "request") return; + assert.equal(first.prompt, "git push\nPushing needs confirmation"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("denies an escalated command in Auto when the user declines it", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 797dce1db6146..cc01148da06a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3191,6 +3191,32 @@ function summarizeClaudeToolRequest(toolName: string, input: ClaudeNativeToolInp : `${toolName}: ${serialized.slice(0, 397)}...`; } +// Builds the approval prompt. The user approves the command itself; Claude's +// title and description only summarize it. The reason the ask escalated (such +// as a PreToolUse hook's message) follows on its own line instead of being +// dropped. It may carry ANSI escapes. +function claudeApprovalPrompt( + options: { + readonly title?: string; + readonly description?: string; + readonly decisionReason?: string; + }, + toolName: string, + input: ClaudeNativeToolInput, +): string { + const command = inputRecordValue(input, "command"); + const base = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? + options.title ?? + options.description ?? + summarizeClaudeToolRequest(toolName, input); + const reason = + options.decisionReason === undefined + ? "" + : NodeUtil.stripVTControlCharacters(options.decisionReason).trim(); + return reason === "" || reason === base ? base : `${base}\n${reason}`; +} + function outputFromClaudeToolResult( toolResult: ClaudeToolResultContentBlock, ): ClaudeToolResultOutput | undefined { @@ -9903,14 +9929,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); - // The user approves the command itself; Claude's title and description only summarize it. - const command = inputRecordValue(nativeToolInput, "command"); - const prompt = - (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? - callbackOptions.title ?? - callbackOptions.description ?? - callbackOptions.decisionReason ?? - summarizeClaudeToolRequest(toolName, nativeToolInput); + const prompt = claudeApprovalPrompt(callbackOptions, toolName, nativeToolInput); const artifacts = yield* buildApprovalRequestArtifacts({ context, nativeItemId: nativeRequestId, From 3eb7b8f5db922b5fe2e6940b156987471643238d Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:45:39 +0200 Subject: [PATCH 3/6] fix(server): approving Bash for the session no longer allows every command When Claude offers no suggested rule, a session approval for Bash fell back to a bare `Bash` rule, which allowed every later command for the session, including ones Auto mode escalated. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 15 +++++++++++++++ .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b83cbe673999f..67030f06ab1dd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -875,6 +875,21 @@ describe("ClaudeAdapterV2 session permissions", () => { ]); }); + it("adds no session rule for Bash when Claude offers no suggestion", () => { + const result = ClaudeAdapterV2.permissionResultFromDecision({ + toolName: "Bash", + decision: "acceptForSession", + toolInput: { command: "git push" }, + toolUseID: "tool-3", + }); + + assert.equal(result.behavior, "allow"); + if (result.behavior !== "allow") { + return; + } + assert.deepEqual(result.updatedPermissions, []); + }); + it("adds a whole-tool session rule when Claude offers no suggestion", () => { const result = ClaudeAdapterV2.permissionResultFromDecision({ toolName: "mcp__t3__custom_tool", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index cc01148da06a1..64c41938619aa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3524,6 +3524,11 @@ function toSessionPermissionUpdates( if (updates.length > 0) { return updates; } + // A bare Bash rule would allow every later command, including the ones Auto + // mode or a hook escalated for a reason. + if (toolName === "Bash") { + return []; + } return [ { type: "addRules", From b92ea7a7197de22f2c36a9c11537b05db3da86c8 Mon Sep 17 00:00:00 2001 From: Antony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:50:13 +0200 Subject: [PATCH 4/6] fix: Keep Monitor approvals scoped to the current escalation. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 64c41938619aa..37e550ce218e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3526,7 +3526,7 @@ function toSessionPermissionUpdates( } // A bare Bash rule would allow every later command, including the ones Auto // mode or a hook escalated for a reason. - if (toolName === "Bash") { + if (toolName === "Bash" || toolName === "Monitor") { return []; } return [ From 5658c88bc601fe4181811f6b658d4ac1ad1dc35b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 14:13:18 +0000 Subject: [PATCH 5/6] fix(server): ask before escalated Claude Auto commands --- .../Adapters/ClaudeAdapterV2.test.ts | 393 ++++++------------ .../Adapters/ClaudeAdapterV2.ts | 44 +- 2 files changed, 143 insertions(+), 294 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 67030f06ab1dd..bad0a9d5d64a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -39,7 +39,6 @@ import { type OrchestrationV2AppThread, type OrchestrationV2PendingBackgroundTask, OrchestrationV2ProviderThread, - type OrchestrationV2RuntimeRequest, ProjectId, ProviderInstanceId, type ResolvedProviderInstanceEnvironment, @@ -456,21 +455,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { }); }); - it("maps Auto runtime mode to Claude's AI-reviewed permission mode and asks about escalations", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto", - interactionMode: "default", - cwd: "/workspace", - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "auto", - installPermissionCallback: true, - }); - }); - it("keeps approval checks when Full access must fall back to an allowed Claude mode", () => { const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", @@ -819,6 +803,22 @@ describe("ClaudeAdapterV2 session permissions", () => { assert.equal(result.interrupt, undefined); }); + it.each(["Bash", "Monitor"])( + "does not grant all %s commands without an SDK suggestion", + (toolName) => { + const result = ClaudeAdapterV2.permissionResultFromDecision({ + toolName, + decision: "acceptForSession", + toolInput: { command: "git push" }, + toolUseID: "escalated-command", + }); + assert.equal(result.behavior, "allow"); + if (result.behavior !== "allow") return; + assert.deepEqual(result.updatedInput, { command: "git push" }); + assert.deepEqual(result.updatedPermissions ?? [], []); + }, + ); + it("forces suggested permission updates to session scope", () => { const result = ClaudeAdapterV2.permissionResultFromDecision({ toolName: "Bash", @@ -875,21 +875,6 @@ describe("ClaudeAdapterV2 session permissions", () => { ]); }); - it("adds no session rule for Bash when Claude offers no suggestion", () => { - const result = ClaudeAdapterV2.permissionResultFromDecision({ - toolName: "Bash", - decision: "acceptForSession", - toolInput: { command: "git push" }, - toolUseID: "tool-3", - }); - - assert.equal(result.behavior, "allow"); - if (result.behavior !== "allow") { - return; - } - assert.deepEqual(result.updatedPermissions, []); - }); - it("adds a whole-tool session rule when Claude offers no suggestion", () => { const result = ClaudeAdapterV2.permissionResultFromDecision({ toolName: "mcp__t3__custom_tool", @@ -913,244 +898,132 @@ describe("ClaudeAdapterV2 session permissions", () => { }); }); -// Opens a Claude turn in the given permission mode and returns the runtime -// with the permission callback the SDK query was opened with. -const openClaudePermissionTurn = Effect.fn("openClaudePermissionTurn")(function* ( - runtimeMode: "auto-accept-edits" | "auto" | "full-access", -) { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-permission-callback-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed(`native-thread-claude-${runtimeMode}`), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - setPermissionMode: () => Effect.void, - applyFlagSettings: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode, - interactionMode: "default", - cwd: "/workspace", - }); - const threadId = ThreadId.make(`thread-claude-${runtimeMode}`); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make(`provider-session-claude-${runtimeMode}`), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make(`attempt-claude-${runtimeMode}`), - text: "Run the command.", - attachments: [], - runtimePolicy, - }), - ); - const canUseTool = openedOptions?.canUseTool; - assert.isFunction(canUseTool); - return { runtime, permissionMode: openedOptions?.permissionMode, canUseTool: canUseTool! }; -}); - -// Sends a Bash command through the permission callback and reports whichever -// comes first: an approval request raised to the user, or the callback's own -// answer. A raised request also carries the prompt from its turn item. -const requestClaudeCommandPermission = Effect.fn("requestClaudeCommandPermission")(function* ( - turn: Effect.Success>, - command: string, - callbackOptions: { readonly title?: string; readonly decisionReason?: string } = {}, -) { - // The runtime's events come off one queue, so a single subscriber collects - // both the request and the approval turn item emitted right after it. - const requestEvents = yield* turn.runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" || - (event.type === "turn_item.updated" && event.turnItem.type === "approval_request"), - ), - Stream.take(2), - Stream.runCollect, - Effect.forkScoped, - ); - const decision = yield* Effect.promise(() => - turn.canUseTool( - "Bash", - { command }, - { - signal: new AbortController().signal, - toolUseID: `tool-bash-${command}`, - requestId: `request-bash-${command}`, - ...callbackOptions, - }, - ), - ).pipe(Effect.forkScoped); - const first = yield* Effect.raceFirst( - Fiber.join(requestEvents).pipe( - Effect.map((events) => { - let runtimeRequest: OrchestrationV2RuntimeRequest | undefined; - let prompt: string | undefined; - for (const event of events) { - if (event.type === "runtime_request.updated") runtimeRequest = event.runtimeRequest; - if (event.type === "turn_item.updated" && event.turnItem.type === "approval_request") { - prompt = event.turnItem.prompt; - } - } - return runtimeRequest === undefined - ? ({ type: "none" } as const) - : ({ type: "request", runtimeRequest, prompt } as const); - }), - ), - Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), - ); - return { first, decision }; -}); - -describe("ClaudeAdapterV2 permission callback", () => { - it.effect("asks before a command in Auto-accept edits instead of allowing it", () => +describe("ClaudeAdapterV2 escalated permissions", () => { + it.effect.each([ + { mode: "auto", response: "accept" }, + { mode: "auto", response: "decline" }, + { mode: "auto-accept-edits", response: "accept" }, + ] as const)("asks in $mode ($response)", ({ mode, response }) => Effect.scoped( Effect.gen(function* () { - const turn = yield* openClaudePermissionTurn("auto-accept-edits"); - assert.equal(turn.permissionMode, "acceptEdits"); - - const { first, decision } = yield* requestClaudeCommandPermission( - turn, - "node -e 'console.log(42)'", - ); - assert.equal(first.type, "request", "the command ran without asking"); - if (first.type !== "request") return; - assert.equal(first.runtimeRequest.kind, "command"); - assert.equal(first.prompt, "node -e 'console.log(42)'"); - - yield* turn.runtime.respondToRuntimeRequest({ - requestId: first.runtimeRequest.id, - decision: "accept", + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-accept-edits-", }); - assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - // Claude's auto mode only calls the callback for actions it will not - // approve itself, such as a command matching a user `ask` rule. - it.effect("asks before a command Claude escalates in Auto", () => - Effect.scoped( - Effect.gen(function* () { - const turn = yield* openClaudePermissionTurn("auto"); - assert.equal(turn.permissionMode, "auto"); - - const { first, decision } = yield* requestClaudeCommandPermission(turn, "git push"); - assert.equal(first.type, "request", "the escalated command ran without asking"); - if (first.type !== "request") return; - assert.equal(first.runtimeRequest.kind, "command"); - - yield* turn.runtime.respondToRuntimeRequest({ - requestId: first.runtimeRequest.id, - decision: "accept", + let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + crypto: yield* Crypto.Crypto, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), + open: (input) => + Effect.sync(() => { + openedOptions = input.options; + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, }); - assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - // A PreToolUse hook that returns `ask` explains itself in `decisionReason`; - // the SDK's title alone would hide why the request was raised. - it.effect("shows a hook's escalation reason after the command in Auto", () => - Effect.scoped( - Effect.gen(function* () { - const turn = yield* openClaudePermissionTurn("auto"); - - const { first } = yield* requestClaudeCommandPermission(turn, "git push", { - title: "Claude wants to run git push", - decisionReason: "\u001b[33mPushing needs confirmation\u001b[0m", + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: mode, + interactionMode: "default", + cwd: "/workspace", }); - assert.equal(first.type, "request", "the escalated command ran without asking"); - if (first.type !== "request") return; - assert.equal(first.prompt, "git push\nPushing needs confirmation"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("denies an escalated command in Auto when the user declines it", () => - Effect.scoped( - Effect.gen(function* () { - const turn = yield* openClaudePermissionTurn("auto"); + const threadId = ThreadId.make("thread-claude-accept-edits"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-accept-edits"), + text: "Run node.", + attachments: [], + runtimePolicy, + }), + ); + assert.equal(openedOptions?.permissionMode, mode === "auto" ? "auto" : "acceptEdits"); + const canUseTool = openedOptions?.canUseTool; + assert.isFunction(canUseTool); - const { first, decision } = yield* requestClaudeCommandPermission(turn, "git push"); - assert.equal(first.type, "request", "the escalated command ran without asking"); + const requestEvent = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runHead, + Effect.forkScoped, + ); + const command = { command: "node -e 'console.log(42)'" }; + const decision = yield* Effect.promise(() => + canUseTool!("Bash", command, { + signal: new AbortController().signal, + toolUseID: "tool-bash-accept-edits", + requestId: "request-bash-accept-edits", + title: "Claude wants to run node", + decisionReason: "\u001b[33mHook needs confirmation\u001b[0m", + }), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Fiber.join(requestEvent).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe( + Effect.map((result) => ({ type: "decision", result }) as const), + ), + ); + assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; + const event = first.event; + assert.isTrue(Option.isSome(event)); + if ( + Option.isNone(event) || + event.value.type !== "turn_item.updated" || + event.value.turnItem.type !== "approval_request" + ) + return; + assert.equal(event.value.turnItem.requestKind, "command"); + assert.equal(event.value.turnItem.prompt, command.command); + assert.equal(event.value.turnItem.reason, "Hook needs confirmation"); - yield* turn.runtime.respondToRuntimeRequest({ - requestId: first.runtimeRequest.id, - decision: "decline", + yield* runtime.respondToRuntimeRequest({ + requestId: event.value.turnItem.requestId, + decision: response, }); - assert.equal((yield* Fiber.join(decision))?.behavior, "deny"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("allows an escalated command in Full access without asking", () => - Effect.scoped( - Effect.gen(function* () { - const turn = yield* openClaudePermissionTurn("full-access"); - assert.equal(turn.permissionMode, "bypassPermissions"); - - const { first } = yield* requestClaudeCommandPermission(turn, "git push"); - assert.equal(first.type, "decision", "Full access raised an approval request"); - if (first.type !== "decision") return; - assert.equal(first.result?.behavior, "allow"); + assert.equal( + (yield* Fiber.join(decision))?.behavior, + response === "accept" ? "allow" : "deny", + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 37e550ce218e7..6b10ec64d64b1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2598,11 +2598,7 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // Claude approves what its mode allows before the callback runs (edits in - // acceptEdits, classifier-approved actions in auto) and sends the rest, - // including user ask rules, to the callback, which must ask rather than - // allow. Only Full access answers it without asking, unless it was - // downgraded to a mode Claude allows here. + // Claude handles mode-approved actions before the callback; escalations still need approval. const installPermissionCallback = downgraded || (runtimePolicy.approvalPolicy === undefined @@ -3191,32 +3187,6 @@ function summarizeClaudeToolRequest(toolName: string, input: ClaudeNativeToolInp : `${toolName}: ${serialized.slice(0, 397)}...`; } -// Builds the approval prompt. The user approves the command itself; Claude's -// title and description only summarize it. The reason the ask escalated (such -// as a PreToolUse hook's message) follows on its own line instead of being -// dropped. It may carry ANSI escapes. -function claudeApprovalPrompt( - options: { - readonly title?: string; - readonly description?: string; - readonly decisionReason?: string; - }, - toolName: string, - input: ClaudeNativeToolInput, -): string { - const command = inputRecordValue(input, "command"); - const base = - (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? - options.title ?? - options.description ?? - summarizeClaudeToolRequest(toolName, input); - const reason = - options.decisionReason === undefined - ? "" - : NodeUtil.stripVTControlCharacters(options.decisionReason).trim(); - return reason === "" || reason === base ? base : `${base}\n${reason}`; -} - function outputFromClaudeToolResult( toolResult: ClaudeToolResultContentBlock, ): ClaudeToolResultOutput | undefined { @@ -3524,8 +3494,7 @@ function toSessionPermissionUpdates( if (updates.length > 0) { return updates; } - // A bare Bash rule would allow every later command, including the ones Auto - // mode or a hook escalated for a reason. + // Shell tools need an SDK-scoped suggestion to avoid allowing unrelated commands. if (toolName === "Bash" || toolName === "Monitor") { return []; } @@ -9934,7 +9903,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); - const prompt = claudeApprovalPrompt(callbackOptions, toolName, nativeToolInput); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); + const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? + callbackOptions.title ?? + callbackOptions.description ?? + callbackOptions.decisionReason ?? + summarizeClaudeToolRequest(toolName, nativeToolInput); const artifacts = yield* buildApprovalRequestArtifacts({ context, nativeItemId: nativeRequestId, From 347f5365414175a46ea83aebfb57eab10686e436 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:21:58 +0100 Subject: [PATCH 6/6] fix(server): Claude approvals no longer repeat an escaped escalation reason When a request has no command, title, or description, its prompt is the raw decisionReason. Terminal escapes kept that from matching the cleaned reason, so the approval showed the reason twice. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 11 +++++++++++ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bad0a9d5d64a6..c1f039854c611 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -784,6 +784,17 @@ describe("ClaudeAdapterV2 approval presentation", () => { callbackOptions: { decisionReason: "Run make?" }, }); assert.deepEqual(presentation, {}); + + // With no command, title, or description, the prompt is the raw reason itself. + const reasonOnly = "\u001b[33mHook needs confirmation\u001b[0m"; + assert.deepEqual( + ClaudeAdapterV2.claudeApprovalPresentation({ + permissionMode: "default", + prompt: reasonOnly, + callbackOptions: { decisionReason: reasonOnly }, + }), + {}, + ); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6b10ec64d64b1..e2fdae16b5477 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3432,7 +3432,9 @@ export function claudeApprovalPresentation(input: { ? "provider_check" : "ask_rule"; return { - ...(reason === "" || reason === input.prompt ? {} : { reason }), + ...(reason === "" || reason === NodeUtil.stripVTControlCharacters(input.prompt).trim() + ? {} + : { reason }), ...(fullAccessOverride === undefined ? {} : { fullAccessOverride }), ...(input.callbackOptions.suppressAlwaysAllowRule === true ? { options: CLAUDE_NO_STANDING_GRANT_OPTIONS }