From e4b3bee24ff59851db759e617b62aa47f6e0d8c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:47:04 +0000 Subject: [PATCH 1/2] test(server): Claude Plan session approval for an MCP tool asks again (#23918) Failing repro: after Always allow this session on mcp__t3-code__t3_thread_read in a Full access Plan turn, the next call raises another approval request. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 155370da1757d..29ff11d7ab1e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4950,6 +4950,47 @@ 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" }, + ); + 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" }, + ); + assert.equal(second.first.type, "decision", "the session approval asked again"); + if (second.first.type !== "decision") return; + assert.equal(second.first.result?.behavior, "allow"); + }).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"); From 06ecc147c60765402e8fd495e8cff07a1e21e079 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:54:28 +0000 Subject: [PATCH 2/2] fix(server): Claude Plan no longer asks again for a tool approved for the session In Plan the Claude CLI records the session rule T3 Code sends back for "Always allow this session", but its Plan check still asks for every tool without a read-only annotation, so each t3_thread_read call raised a new approval card. The adapter now remembers a whole-tool session grant and answers the mode's own ask from it. An ask from a hook, an ask rule or a safety check carries a different reason and still reaches the user. Fixes pingdotgg/t3code#23918 Co-Authored-By: Claude Fable 5.2 --- .../Adapters/ClaudeAdapterV2.test.ts | 17 ++++++++-- .../Adapters/ClaudeAdapterV2.ts | 34 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 29ff11d7ab1e1..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( @@ -4959,7 +4962,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { turn, toolName, { threadId: "thread-a", afterPosition: -1 }, - { toolUseID: "read-page-1" }, + { toolUseID: "read-page-1", decisionReasonType: "mode" }, ); assert.equal(first.first.type, "request"); if (first.first.type !== "request") return; @@ -4973,11 +4976,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { turn, toolName, { threadId: "thread-a", afterPosition: 1 }, - { toolUseID: "read-page-2" }, + { 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( 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) =>