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:
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user