From 744c7b724a2ea364d3ba78d7a66eb48028024869 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 11:42:42 -0700 Subject: [PATCH 1/2] fix(orchestrator): scope Claude MCP tool pre-approval The Claude Agent SDK's allowedTools option only pre-approves tool calls; tool availability is controlled by the separate tools option. Attaching the t3-code MCP server therefore always pre-approves its tools so headless permission modes (dontAsk) do not deny the orchestrator's own MCP calls. Read-only sandboxes now pre-approve only the orchestrator tools annotated read-only, so a read-only session can no longer silently spawn child threads or scheduled tasks. --- .../Adapters/ClaudeAdapterV2.test.ts | 116 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 27 +++- 2 files changed, 142 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 54e96ffa084..8fbbc800b40 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -19,6 +19,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,9 +27,11 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { Tool } from "effect/unstable/ai"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -39,6 +42,8 @@ import { CLAUDE_DEFAULT_INSTANCE_ID, CLAUDE_PROVIDER, CLAUDE_READ_ONLY_ALLOWED_TOOLS, + CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, + CLAUDE_T3_MCP_TOOL_WILDCARD, ClaudeProviderCapabilitiesV2, claudeMcpQueryOverrides, claudeRuntimeQueryPolicyForRuntimePolicy, @@ -211,6 +216,116 @@ describe("ClaudeAdapterV2 runtime query policy", () => { }); }); +describe("ClaudeAdapterV2 MCP query overrides", () => { + const T3_MCP_SERVERS = { + "t3-code": { + type: "http", + url: "http://127.0.0.1:43123/mcp", + headers: { + Authorization: "Bearer secret-claude-token", + }, + }, + } as const; + + const withMcpSession = (threadId: ThreadId, run: () => void) => { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make(`environment-${threadId}`), + threadId, + providerSessionId: `mcp-session-${threadId}`, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-claude-token", + }); + try { + run(); + } finally { + McpProviderSession.clearMcpProviderSession(threadId); + } + }; + + it("leaves an absent allowlist absent when no MCP session exists", () => { + const overrides = claudeMcpQueryOverrides({ + threadId: ThreadId.make("thread-claude-no-mcp-no-allowlist"), + readOnlySandbox: false, + }); + + assert.deepEqual(overrides, {}); + }); + + it("preserves an explicit allowlist when no MCP session exists", () => { + const overrides = claudeMcpQueryOverrides({ + threadId: ThreadId.make("thread-claude-no-mcp-with-allowlist"), + readOnlySandbox: false, + allowedTools: ["Read"], + }); + + assert.deepEqual(overrides, { allowedTools: ["Read"] }); + }); + + it("pre-approves all t3-code tools when attaching an MCP session without an allowlist", () => { + const threadId = ThreadId.make("thread-claude-mcp-no-allowlist"); + withMcpSession(threadId, () => { + const overrides = claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }); + + assert.deepEqual(overrides, { + allowedTools: [CLAUDE_T3_MCP_TOOL_WILDCARD], + mcpServers: T3_MCP_SERVERS, + }); + }); + }); + + it("extends an explicit allowlist with the t3-code wildcard", () => { + const threadId = ThreadId.make("thread-claude-mcp-with-allowlist"); + withMcpSession(threadId, () => { + const overrides = claudeMcpQueryOverrides({ + threadId, + readOnlySandbox: false, + allowedTools: ["Read", "mcp__t3-code__*"], + }); + + assert.deepEqual(overrides, { + allowedTools: ["Read", "mcp__t3-code__*"], + mcpServers: T3_MCP_SERVERS, + }); + }); + }); + + it("pre-approves only read-only t3-code tools in a read-only sandbox", () => { + const threadId = ThreadId.make("thread-claude-mcp-read-only"); + withMcpSession(threadId, () => { + const overrides = claudeMcpQueryOverrides({ + threadId, + readOnlySandbox: true, + allowedTools: [...CLAUDE_READ_ONLY_ALLOWED_TOOLS], + }); + + assert.deepEqual(overrides, { + allowedTools: [...CLAUDE_READ_ONLY_ALLOWED_TOOLS, ...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS], + mcpServers: T3_MCP_SERVERS, + }); + assert.isFalse(overrides.allowedTools?.includes(CLAUDE_T3_MCP_TOOL_WILDCARD)); + }); + }); + + it("pre-approves only read-only t3-code tools in a read-only sandbox without an allowlist", () => { + const threadId = ThreadId.make("thread-claude-mcp-read-only-no-allowlist"); + withMcpSession(threadId, () => { + const overrides = claudeMcpQueryOverrides({ threadId, readOnlySandbox: true }); + + assert.deepEqual(overrides.allowedTools, [...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS]); + }); + }); + + it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { + const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) + .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) + .map((tool) => `mcp__t3-code__${tool.name}`) + .sort(); + + assert.deepEqual([...CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS].sort(), readOnlyToolNames); + }); +}); + describe("ClaudeAdapterV2 native protocol logging", () => { it("injects thread-scoped MCP configuration without logging the credential", () => { const threadId = ThreadId.make("thread-claude-mcp"); @@ -226,6 +341,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { try { const overrides = claudeMcpQueryOverrides({ threadId, + readOnlySandbox: false, allowedTools: ["Read"], }); assert.deepEqual(overrides, { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2740ceff6d2..3f1f369c795 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -678,8 +678,28 @@ export function makeClaudeQueryOptions(input: { return input.cwd === null ? options : { ...options, cwd: input.cwd }; } +export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; + +// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit; +// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. +export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ + "mcp__t3-code__orchestrator_capabilities", + "mcp__t3-code__task_status", + "mcp__t3-code__list_scheduled_tasks", + "mcp__t3-code__t3_thread_list", + "mcp__t3-code__t3_thread_read", + "mcp__t3-code__t3_thread_wait", +]; + +// The SDK's `allowedTools` only pre-approves tool calls; availability is the +// separate `tools` option. Attaching the t3-code MCP server therefore always +// pre-approves its tools (headless modes like `dontAsk` deny anything that is +// not pre-approved), but read-only sandboxes pre-approve only the annotated +// read-only orchestrator tools so a read-only session cannot silently spawn +// threads or scheduled tasks. export function claudeMcpQueryOverrides(input: { readonly threadId: ThreadId; + readonly readOnlySandbox: boolean; readonly allowedTools?: ReadonlyArray; }): { readonly allowedTools?: ReadonlyArray; @@ -689,8 +709,11 @@ export function claudeMcpQueryOverrides(input: { if (session === undefined) { return input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }; } + const mcpAllowedTools = input.readOnlySandbox + ? CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS + : [CLAUDE_T3_MCP_TOOL_WILDCARD]; return { - allowedTools: Array.from(new Set([...(input.allowedTools ?? []), "mcp__t3-code__*"])), + allowedTools: Array.from(new Set([...(input.allowedTools ?? []), ...mcpAllowedTools])), mcpServers: { "t3-code": { type: "http", @@ -3031,6 +3054,8 @@ export function makeClaudeAdapterV2( const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy); const mcpOverrides = claudeMcpQueryOverrides({ threadId: turnInput.threadId, + readOnlySandbox: + sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly", ...(queryPolicy.allowedTools === undefined ? {} : { allowedTools: queryPolicy.allowedTools }), From 4eafde49d6d23b36fc33e406cb46aa05f5807a05 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 12:38:41 -0700 Subject: [PATCH 2/2] fix(orchestrator): key query reuse on effective allowlist Reused live queries were keyed only by the runtime policy, so policies sharing a policy key could reuse a query opened with different MCP pre-approvals (wildcard vs read-only list) or without the MCP server attached. Key reuse on the effective allowlist instead. --- .../Adapters/ClaudeAdapterV2.test.ts | 33 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 15 ++++++++- 2 files changed, 47 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 8fbbc800b40..80fbd4f4a7d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,7 @@ import { CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, CLAUDE_T3_MCP_TOOL_WILDCARD, ClaudeProviderCapabilitiesV2, + claudeEffectiveQueryPolicyKey, claudeMcpQueryOverrides, claudeRuntimeQueryPolicyForRuntimePolicy, loggedClaudeQueryOptions, @@ -316,6 +317,38 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); }); + it("keys live-query reuse on the MCP-derived pre-approvals", () => { + const threadId = ThreadId.make("thread-claude-mcp-query-key"); + withMcpSession(threadId, () => { + const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy( + ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }), + ); + + const readOnlyKey = claudeEffectiveQueryPolicyKey( + queryPolicy, + claudeMcpQueryOverrides({ threadId, readOnlySandbox: true }), + ); + const fullAccessKey = claudeEffectiveQueryPolicyKey( + queryPolicy, + claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }), + ); + const detachedKey = claudeEffectiveQueryPolicyKey(queryPolicy, {}); + + assert.notEqual(readOnlyKey, fullAccessKey); + assert.notEqual(fullAccessKey, detachedKey); + }); + }); + it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3f1f369c795..8b364c4458c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1200,6 +1200,19 @@ function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string { }); } +// Live-query reuse must key on the effective allowlist (including the +// MCP-derived pre-approvals), not just the runtime policy: policies that +// share a policy key can still differ in MCP pre-approvals. +export function claudeEffectiveQueryPolicyKey( + queryPolicy: ClaudeRuntimeQueryPolicy, + mcpOverrides: { readonly allowedTools?: ReadonlyArray }, +): string { + return claudeRuntimeQueryPolicyKey({ + ...queryPolicy, + ...(mcpOverrides.allowedTools === undefined ? {} : { allowedTools: mcpOverrides.allowedTools }), + }); +} + type ClaudeToolItemType = Extract< OrchestrationV2TurnItem["type"], "command_execution" | "file_change" | "dynamic_tool" | "web_search" @@ -3060,7 +3073,7 @@ export function makeClaudeAdapterV2( ? {} : { allowedTools: queryPolicy.allowedTools }), }); - const queryPolicyKey = claudeRuntimeQueryPolicyKey(queryPolicy); + const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext);