diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 868e7fa0bd62..63df53a50459 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -33,6 +33,7 @@ import { NodeId, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, + type OrchestrationV2RuntimeRequest, ProjectId, ProviderInstanceId, type ProviderInstanceEnvironment, @@ -441,7 +442,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { }); }); - it("maps Auto runtime mode to Claude's AI-reviewed permission mode", () => { + 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", @@ -452,7 +453,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.deepEqual(queryPolicy, { permissionMode: "auto", - installPermissionCallback: false, + installPermissionCallback: true, }); }); @@ -1005,111 +1006,152 @@ 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. 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", () => 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.filter((event) => event.type === "runtime_request.updated"), - 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", - }), - ).pipe(Effect.forkScoped); - // Without a callback that asks, the command is allowed before any - // request is raised. - 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; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + assert.equal(first.runtimeRequest.kind, "command"); - yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + yield* turn.runtime.respondToRuntimeRequest({ + requestId: first.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); @@ -1120,6 +1162,94 @@ 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), + ), + ), + ), + ); + + // 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 alongside the request title 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, "Claude wants to run 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 { 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 approval cancellation", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 92c3b44ec583..d912a22acab3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -44,6 +44,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; // @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -1865,12 +1866,13 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback runs; everything else it - // leaves to the callback, which must ask rather than allow. + // 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. const installPermissionCallback = runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" || - runtimePolicy.runtimeMode === "auto-accept-edits" + ? runtimePolicy.runtimeMode !== "full-access" : runtimePolicy.approvalPolicy !== "never"; if (permissionMode === "plan") { @@ -2332,6 +2334,26 @@ function summarizeClaudeToolRequest(toolName: string, input: ClaudeNativeToolInp : `${toolName}: ${serialized.slice(0, 397)}...`; } +// Builds the approval prompt. The SDK nearly always sets `title`, so the +// reason the ask escalated (such as a PreToolUse hook's message) is shown +// 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 base = 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 { @@ -8286,11 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); - const prompt = - callbackOptions.title ?? - callbackOptions.description ?? - callbackOptions.decisionReason ?? - summarizeClaudeToolRequest(toolName, nativeToolInput); + const prompt = claudeApprovalPrompt(callbackOptions, toolName, nativeToolInput); const artifacts = yield* buildApprovalRequestArtifacts({ context, nativeItemId: nativeRequestId, diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 4a114188fa98..5312c4809aef 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -51,10 +51,8 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova