diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 54e96ffa084..80fbd4f4a7d 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,7 +42,10 @@ 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, + claudeEffectiveQueryPolicyKey, claudeMcpQueryOverrides, claudeRuntimeQueryPolicyForRuntimePolicy, loggedClaudeQueryOptions, @@ -211,6 +217,148 @@ 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("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)) + .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 +374,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..8b364c4458c 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", @@ -1177,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" @@ -3031,11 +3067,13 @@ 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 }), }); - 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);