diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index ec6072604..415309131 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2269,10 +2269,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } let cursorEventEmitter: ((event: AgentEvent) => void) | undefined; + // Built-in xd:// devices (ast_edit, debug, browser, lsp, web_search) are + // mounted in createTools BEFORE this loop wraps registry entries in + // ExtensionToolWrapper, so the registry holds them unwrapped. The normal + // `write xd://` path runs approval through the wrapped `write` tool's + // tier gate, but Cursor invokes advertised devices via `tool.execute()` + // directly — so wrap unwrapped devices here to keep the approval/deny/prompt + // gate. Dynamic mounts (custom/MCP) already come from the wrapped registry. + const resolveCursorDevice = (name: string): AgentTool | undefined => { + const device = toolSession.xdevRegistry?.get(name); + if (!device) return undefined; + return device instanceof ExtensionToolWrapper ? device : new ExtensionToolWrapper(device, extensionRunner); + }; const cursorExecHandlers = new CursorExecHandlers({ cwd, tools: toolRegistry, - getTool: name => toolSession.xdevRegistry?.get(name), + getTool: resolveCursorDevice, getToolContext: () => toolContextStore.getContext(), emitEvent: event => cursorEventEmitter?.(event), }); diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 6acffe28d..9702fd53c 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { create } from "@bufbuild/protobuf"; -import type { AgentEvent, AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { ReadArgsSchema, ShellArgsSchema } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor"; +import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { GrepTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; @@ -151,4 +153,41 @@ describe("CursorExecHandlers mounted tool bridge", () => { expect(result.isError).toBe(false); expect(result.content).toEqual([{ type: "text", text: "reported" }]); }); + + it("routes wrapped mounted devices through the approval gate", async () => { + let executed = false; + const device: AgentTool = { + name: "ast_edit", + label: "AST Edit", + description: "structural edit device", + parameters: type({}), + async execute() { + executed = true; + return { content: [{ type: "text", text: "edited" }], details: {} }; + }, + }; + // The deny path throws inside resolveApproval before the runner is touched, + // so a bare runner stub suffices to prove the gate runs. + const wrapped = new ExtensionToolWrapper(device, {} as unknown as ExtensionRunner); + const settings = Settings.isolated({ "tools.approval": { ast_edit: "deny" } }); + const handlers = new CursorExecHandlers({ + cwd: ".", + tools: new Map(), + getTool: name => (name === device.name ? (wrapped as unknown as AgentTool) : undefined), + getToolContext: () => ({ settings }) as AgentToolContext, + }); + + const result = await handlers.mcp({ + name: device.name, + providerIdentifier: "pi-agent", + toolName: device.name, + toolCallId: "call-denied", + args: {}, + rawArgs: {}, + }); + + expect(result.isError).toBe(true); + expect(executed).toBe(false); + expect(result.content.find(block => block.type === "text")?.text).toContain("blocked by user policy"); + }); });