fix(cursor): mirror executed pagination in synthesized calls, resolve approval probes from policy

Forwarding the legacy read/grep frames' range and page fixed only the
execution: the transcript block was built from a second translation and
still showed a bare path and an unskipped search. That block is what a
reloaded session replays, so a slice read as the whole file and a later
window presented as page one. Both now come from the shared helpers,
`limit: 0` included -- recorded as the zero lines it returns.

The approval probe answered `approved` unconditionally, which laundered a
configured `deny` into a server-side blessing. It now resolves through a
bridge preflight against the same policy the wrapper applies at execution
time: approved only for a definite allow, refused for a deny, for a mode
demanding a prompt this frame cannot raise, and for an unknown tool.
Still never executes.

Comments and changelog no longer assert server-side semantics for
`range_applied`/`offset_applied`; they describe what the client did,
which is all the proto establishes.

(cherry picked from commit 5ac1870f6d67bf365c84b1affae63c89de32d1cb)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 18:11:19 -03:00
committed by can1357
parent f947ee3fb2
commit f6f2bab02e
9 changed files with 341 additions and 36 deletions
@@ -1385,6 +1385,84 @@ describe("CursorExecHandlers native delete gating (issue #5680)", () => {
});
});
// A `smart_mode_approval_only` frame asks whether an MCP call would be allowed,
// ahead of the call itself. The verdict has to come from the same policy that
// gates execution — without running the tool to find out.
describe("CursorExecHandlers MCP approval preflight", () => {
let cwd: string;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-preflight-test-"));
});
afterEach(async () => {
await removeWithRetries(cwd);
});
function mcpHandlers(settings: Settings): { handlers: CursorExecHandlers; executed: () => number } {
let executed = 0;
const tool: AgentTool = {
name: "mcp__ops__deploy",
label: "deploy",
description: "",
parameters: type({}),
execute: async () => {
executed += 1;
return { content: [{ type: "text", text: "ran" }] };
},
} as unknown as AgentTool;
const handlers = new CursorExecHandlers({
cwd,
tools: new Map([[tool.name, tool]]),
getToolContext: () => ({ settings }) as AgentToolContext,
});
return { handlers, executed: () => executed };
}
const call = {
name: "mcp__ops__deploy",
toolName: "mcp__ops__deploy",
toolCallId: "c1",
providerIdentifier: "ops",
args: {},
rawArgs: {},
};
it("approves a call the policy allows, without running it", async () => {
const { handlers, executed } = mcpHandlers(Settings.isolated({ "tools.approvalMode": "yolo" }));
expect(await handlers.mcpApprovalPreflight(call)).toBe(true);
// Approval is the answer; the invocation is a separate frame.
expect(executed()).toBe(0);
});
it("refuses a call the user's policy denies", async () => {
const { handlers, executed } = mcpHandlers(
Settings.isolated({ "tools.approvalMode": "yolo", "tools.approval": { mcp__ops__deploy: "deny" } }),
);
// Approving here would launder the deny into a server-side blessing.
expect(await handlers.mcpApprovalPreflight(call)).toBe(false);
expect(executed()).toBe(0);
});
it("refuses when the policy demands a prompt this frame cannot raise", async () => {
const { handlers } = mcpHandlers(Settings.isolated({ "tools.approvalMode": "always-ask" }));
// The user is asked for real when the call arrives; answering yes on
// their behalf pre-authorizes something they never saw.
expect(await handlers.mcpApprovalPreflight(call)).toBe(false);
});
it("refuses a tool the session does not have", async () => {
const { handlers } = mcpHandlers(Settings.isolated({ "tools.approvalMode": "yolo" }));
expect(
await handlers.mcpApprovalPreflight({ ...call, name: "mcp__ops__absent", toolName: "mcp__ops__absent" }),
).toBe(false);
});
});
// The Pi frames (`ExecServerMessage` 45-51) are a separate wire family from the
// legacy `read`/`shell`/`grep` args, with different field names and different
// semantics. Each bridge handler therefore performs a real translation, and a