diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b98b5d372851e..32a756853519a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -813,6 +813,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 }, + }), + {}, + ); }); }); @@ -832,6 +843,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", @@ -911,8 +938,12 @@ describe("ClaudeAdapterV2 session permissions", () => { }); }); -describe("ClaudeAdapterV2 Auto-accept edits", () => { - it.effect("asks before a command 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 fileSystem = yield* FileSystem.FileSystem; @@ -957,7 +988,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { }, }); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto-accept-edits", + runtimeMode: mode, interactionMode: "default", cwd: "/workspace", }); @@ -985,16 +1016,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { runtimePolicy, }), ); - assert.equal(openedOptions?.permissionMode, "acceptEdits"); + assert.equal(openedOptions?.permissionMode, mode === "auto" ? "auto" : "acceptEdits"); const canUseTool = openedOptions?.canUseTool; assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.takeUntil( + Stream.filter( (event) => event.type === "turn_item.updated" && event.turnItem.type === "approval_request", ), - Stream.runCollect, + Stream.runHead, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -1003,7 +1034,8 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", - description: "Check the Node runtime", + title: "Claude wants to run node", + decisionReason: "\u001b[33mHook needs confirmation\u001b[0m", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -1016,19 +1048,26 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); 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, - ); + 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* runtime.respondToRuntimeRequest({ - requestId: event!.runtimeRequest.id, - decision: "accept", + requestId: event.value.turnItem.requestId, + decision: response, }); - assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); + assert.equal( + (yield* Fiber.join(decision))?.behavior, + response === "accept" ? "allow" : "deny", + ); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bb914d54d1cd9..1c3d02cbfb920 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2887,12 +2887,11 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback; the callback must ask for everything else. + // Claude handles mode-approved actions before the callback; escalations still need approval. 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") { @@ -3738,7 +3737,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 } @@ -3800,6 +3801,10 @@ function toSessionPermissionUpdates( if (updates.length > 0) { return updates; } + // Shell tools need an SDK-scoped suggestion to avoid allowing unrelated commands. + if (toolName === "Bash" || toolName === "Monitor") { + return []; + } return [ { type: "addRules",