diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index 1b5a6f62ea..c81fa9773f 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -531,7 +531,7 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(injectedToolSchema, inputSchema); }); - it("passes runtime integration tool allowlists from forwarded props into the runtime agent config", async () => { + it("does not pass undeclared forwarded remote tool allowlists into the runtime agent config", async () => { let capturedAllowedTools: string[] | undefined; const handler = new AgentStreamHandler({ @@ -590,7 +590,70 @@ describe("server/handlers/request/agent-stream.handler", () => { assertExists(result.response); assertEquals(result.response.status, 200); - assertEquals(capturedAllowedTools, ["gmail:list-emails", "gmail:get-email"]); + assertEquals(capturedAllowedTools, undefined); + }); + + it("drops undeclared Studio runtime tool allowlists for untrusted clients", async () => { + let capturedAllowedTools: string[] | undefined; + + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => {}, + getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, + getAllAgentIds: () => ["assistant-1"], + sessionManager: new AgentRunSessionManager(), + createRuntime: (agent) => { + capturedAllowedTools = (agent.config as typeof agent.config & RuntimeRemoteToolConfig) + .__vfAllowedRemoteTools; + + return { + stream: async (_messages, _context, callbacks) => { + callbacks?.onFinish?.({ + text: "ok", + messages: [], + toolCalls: [], + status: "completed", + usage: { + promptTokens: 1, + completionTokens: 1, + totalTokens: 2, + }, + }); + + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + }, + }; + }, + }); + + const body = createAgentStreamRequestBody({ + forwardedProps: { + clientId: "external-client", + runtimeOverrides: { + allowedTools: ["studio_todo_write"], + }, + }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { requestId: "run_1" }); + + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + createCtx(publicKeyPem), + ); + + assertExists(result.response); + assertEquals(result.response.status, 200); + assertEquals(capturedAllowedTools, undefined); }); it("auto-exposes Studio MCP tools for trusted Studio project-agent requests", async () => { diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index ad317731f9..e347500030 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -42,6 +42,7 @@ import { getInternalAgentStreamRequestSchema, type InternalAgentStreamRequest, type RuntimeAgentSourceContext, + type RuntimeRunAgentInput, toRuntimeRunAgentInput, } from "#veryfront/internal-agents/schema.ts"; import { BaseHandler } from "../response/base.ts"; @@ -187,6 +188,75 @@ function getRequestedStudioToolNames(input: { .sort(); } +function sanitizeForwardedRuntimeAllowedTools(input: { + forwardedProps?: Record; + availableToolNames: string[]; + allowStudioRuntimeTools: boolean; +}): Record | undefined { + const forwardedProps = input.forwardedProps; + if (!isRecord(forwardedProps)) { + return forwardedProps; + } + + const runtimeOverrides = isRecord(forwardedProps.runtimeOverrides) + ? forwardedProps.runtimeOverrides + : null; + if (!runtimeOverrides || !Object.hasOwn(runtimeOverrides, "allowedTools")) { + return forwardedProps; + } + + const allowedTools = runtimeOverrides.allowedTools; + if ( + !Array.isArray(allowedTools) || !allowedTools.every((toolName) => typeof toolName === "string") + ) { + return forwardedProps; + } + + const availableToolNames = new Set(input.availableToolNames); + // Platform remote tools are gated separately by the child agent config in + // withVeryfrontPlatformRemoteTools. The Studio path is the one that consumes + // forwarded allowedTools, and Studio-only runtime tools are preserved only + // for trusted Studio clients that can already attach the Studio MCP surface. + const sanitizedAllowedTools = allowedTools.filter((toolName) => + availableToolNames.has(toolName) || + (input.allowStudioRuntimeTools && STUDIO_RUNTIME_REMOTE_TOOL_NAMES.has(toolName)) + ); + if (sanitizedAllowedTools.length === allowedTools.length) { + return forwardedProps; + } + + const nextRuntimeOverrides: Record = { + ...runtimeOverrides, + allowedTools: sanitizedAllowedTools, + }; + if (sanitizedAllowedTools.length === 0) { + delete nextRuntimeOverrides.allowedTools; + } + + const nextForwardedProps: Record = { + ...forwardedProps, + runtimeOverrides: nextRuntimeOverrides, + }; + if (Object.keys(nextRuntimeOverrides).length === 0) { + delete nextForwardedProps.runtimeOverrides; + } + + return Object.keys(nextForwardedProps).length > 0 ? nextForwardedProps : undefined; +} + +function sanitizeRuntimeRunAgentInput(input: RuntimeRunAgentInput): RuntimeRunAgentInput { + const clientProfile = resolveRuntimeClientProfile(input.forwardedProps); + + return { + ...input, + forwardedProps: sanitizeForwardedRuntimeAllowedTools({ + forwardedProps: input.forwardedProps, + availableToolNames: input.tools.map((tool) => tool.name), + allowStudioRuntimeTools: clientAllowsStudioMcp(clientProfile), + }), + }; +} + function getVeryfrontApiMcpPolicy(agent: Agent): { allowAll: boolean; requestedToolNames: string[]; @@ -559,7 +629,7 @@ export class AgentStreamHandler extends BaseHandler { return this.respond(builder.json({ error: "Agent not found" }, 404)); } - const runtimeInput = toRuntimeRunAgentInput(payload); + const runtimeInput = sanitizeRuntimeRunAgentInput(toRuntimeRunAgentInput(payload)); const apiAuthToken = payload.credentials?.authToken || ctx.proxyToken || getHostEnv("VERYFRONT_API_TOKEN") || ""; const platformRuntimeAgent = await withVeryfrontPlatformRemoteTools({