fix(cursor): gated mounted device execution through approval

Built-in xd:// devices are mounted before the SDK wraps registry tools in ExtensionToolWrapper, so Cursor executed them via tool.execute() without the deny/prompt approval gate that write xd:// enforces. Wrap unwrapped devices in the Cursor resolver, skipping already-wrapped dynamic mounts.

Fixes #5650
This commit is contained in:
roboomp
2026-07-16 03:31:13 +00:00
parent d8aaffa814
commit 8b0402b32c
2 changed files with 53 additions and 2 deletions
+13 -1
View File
@@ -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://<tool>` 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),
});
+40 -1
View File
@@ -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");
});
});