diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 155370da1757d..6d87fb75aa190 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4879,7 +4879,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { turn: Effect.Success>, toolName: string, toolInput: Record, - options?: { toolUseID: string; agentID?: string }, + options?: { toolUseID: string; agentID?: string; decisionReasonType?: "mode" | "hook" }, ) { const decision = yield* Effect.promise(() => turn.canUseTool(toolName, toolInput, { @@ -4887,6 +4887,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { toolUseID: options?.toolUseID ?? "tool-plan-write", requestId: "request-plan-write", ...(options?.agentID === undefined ? {} : { agentID: options.agentID }), + ...(options?.decisionReasonType === undefined + ? {} + : { decisionReasonType: options.decisionReasonType }), }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -4950,6 +4953,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #23918 the CLI asks again in Plan despite the session rule it was given + it.effect("does not ask again for an MCP tool approved for the session in Plan", () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access"); + const toolName = "mcp__t3-code__t3_thread_read"; + const first = yield* requestWrite( + turn, + toolName, + { threadId: "thread-a", afterPosition: -1 }, + { toolUseID: "read-page-1", decisionReasonType: "mode" }, + ); + assert.equal(first.first.type, "request"); + if (first.first.type !== "request") return; + yield* turn.runtime.respondToRuntimeRequest({ + requestId: first.first.event.runtimeRequest.id, + decision: "acceptForSession", + }); + assert.equal((yield* Fiber.join(first.decision))?.behavior, "allow"); + + const second = yield* requestWrite( + turn, + toolName, + { threadId: "thread-a", afterPosition: 1 }, + { toolUseID: "read-page-2", decisionReasonType: "mode" }, + ); + assert.equal(second.first.type, "decision", "the session approval asked again"); + if (second.first.type !== "decision") return; + assert.equal(second.first.result?.behavior, "allow"); + + const hooked = yield* requestWrite( + turn, + toolName, + { threadId: "thread-a", afterPosition: 3 }, + { toolUseID: "read-page-3", decisionReasonType: "hook" }, + ); + assert.equal(hooked.first.type, "request", "a hook's ask was answered from the grant"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("denies Plan writes without asking when approvals are never allowed", () => Effect.gen(function* () { const turn = yield* openPlanTurn("full-access", "never"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b3c1fd1c1fed4..6419397238d7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5629,6 +5629,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( new Map(), ); const mcpElicitationSessionGrants = yield* Ref.make(new Set()); + // Plan asks for a tool without a read-only annotation even when a session rule allows it. + const planSessionToolGrants = yield* Ref.make(new Set()); // Background local_bash tasks can trigger wake turns on the query stream with no active provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< @@ -11677,6 +11679,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies PermissionResult; } + // The SDK passes decisionReasonType but leaves it out of its types. Only the mode's own + // ask is answered from a grant: a hook, an ask rule or a safety check still asks. + if ( + "decisionReasonType" in callbackOptions && + callbackOptions.decisionReasonType === "mode" && + callbackOptions.matchedAskRule === undefined && + callbackOptions.suppressAlwaysAllowRule !== true && + (yield* Ref.get(planSessionToolGrants)).has(toolName) + ) { + return { + behavior: "allow", + updatedInput: toolInput, + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult; + } + const requestKind = providerRequestKindFromClaudeTool(toolName); // The user approves the command itself; Claude's title and description only summarize it. const command = inputRecordValue(nativeToolInput, "command"); @@ -11743,7 +11761,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); if (!(yield* Deferred.isDone(decision))) yield* cancelWithdrawnRequest(artifacts); - return permissionResultFromDecision({ + const result = permissionResultFromDecision({ toolName, ...resolvedDecision, toolInput, @@ -11753,6 +11771,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? {} : { suggestions: callbackOptions.suggestions }), }); + if ( + result.behavior === "allow" && + result.updatedPermissions?.some( + (update) => + update.type === "addRules" && + update.behavior === "allow" && + update.rules.some( + (rule) => rule.toolName === toolName && rule.ruleContent === undefined, + ), + ) === true + ) { + yield* Ref.update(planSessionToolGrants, (current) => new Set(current).add(toolName)); + } + return result; }); const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>