fix(cursor): gate bridge-only edit and grep on the granted tool set

Both are constructed rather than looked up, and `executeTool` prefers a
constructed override over the registry — so a session that withheld
either still got a working frame. Native `pi_edit`/`pi_grep` arrive
regardless of the advertised catalog, so a restricted agent
(`toolNames` without them, or `restrictToolNames: true`) could modify
and search files it was never granted.

Both now check the registry for the grant before building. The edit
check reads it before the Cursor-specific delete, since that delete is
about not advertising the tool, not about revoking it. This is the same
escalation the `delete` frame already guards against (#5680); the
advisor path got the grep gate in the previous commit and the primary
session was missed.

(cherry picked from commit 68f82b0ce08bb93db941e0fbe1bd2a515d45c2cb)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 11:14:47 -03:00
committed by can1357
parent f785d76bc9
commit 8737987398
3 changed files with 31 additions and 4 deletions
+1
View File
@@ -135,6 +135,7 @@
- Fixed a `pi_grep` frame carrying `context` or `limit` escaping the approval gate. Honoring those fields needs a per-call `grep`, and the per-call instance was built raw while every registry tool is wrapped, so such calls bypassed `tools.approval.grep` and the exec-tier check for SSH-targeted paths. Both bridge callsites now build it through one shared factory that applies the same wrapper.
- Fixed Cursor advisors ignoring `pi_grep`'s `context` and `limit`. Only the primary session supplied the per-call `grep` factory, so advisor frames silently fell back to session defaults. Advisors now receive the same factory, gated on the advisor actually having been granted `grep`.
- Fixed `pi_bash` killing commands that explicitly asked for no deadline. `timeout` is `optional int32` and `bash` documents `0` as "disables the command deadline", but a truthiness check folded a supplied `0` into unset, applying the 300s default instead. A present `0` now passes through; negatives, which have no local meaning and would otherwise clamp to the 1s floor, still fall back to the default.
- Fixed the Cursor exec bridge granting `edit` and `grep` to sessions that withheld them. Both bridge-only tools are constructed rather than looked up, and `executeTool` prefers a constructed override over the registry, so a restricted tool set (`toolNames` without them, or `restrictToolNames`) still got a working `pi_edit`/`pi_grep` — native frames arrive regardless of the advertised catalog. Both are now gated on the session having actually granted the tool, matching the `delete` frame's existing check (issue #5680).
## [17.1.5] - 2026-07-27
+15 -4
View File
@@ -2598,10 +2598,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// the session's configured mode, so the bridge builds its own.
let cursorBridgeEditTool: AgentTool | undefined;
if (model?.provider === "cursor") {
const bridgeEdit: Tool = new EditTool(toolSession, "replace");
cursorBridgeEditTool = new ExtensionToolWrapper(bridgeEdit, extensionRunner);
// Only when the session actually granted `edit`. `createTools` omits
// it entirely for a restricted tool set, and the bridge answers native
// frames that arrive regardless of the advertised catalog — so
// building one unconditionally would hand a read-only agent a
// mutating tool it was denied (the issue #5680 escalation).
const editWasGranted = toolRegistry.has("edit");
toolRegistry.delete("edit");
builtInRegistryToolNames.delete("edit");
if (editWasGranted) {
const bridgeEdit: Tool = new EditTool(toolSession, "replace");
cursorBridgeEditTool = new ExtensionToolWrapper(bridgeEdit, extensionRunner);
}
}
let writeRegistration: Promise<boolean> | undefined;
@@ -2654,8 +2662,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
setTodoPhases: phases => session.setTodoPhases(phases),
persistTodoPhases: phases => sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases }),
// `pi_grep` carries its own context width and match cap, which the
// shared grep instance fixed at construction cannot express.
createGrepTool: createBridgeGrepFactory(toolSession, extensionRunner),
// shared grep instance fixed at construction cannot express. Gated on
// the grant: the factory builds a fresh tool and `executeTool` prefers
// it over the registry, so installing it unconditionally would let a
// session without `grep` search anyway.
createGrepTool: toolRegistry.has("grep") ? createBridgeGrepFactory(toolSession, extensionRunner) : undefined,
});
// Resolve the inline-descriptors setting against the session-start model.
@@ -255,6 +255,21 @@ describe("bridge tool resolution beyond the model-facing registry", () => {
expect(await Bun.file(target).text()).toBe("alpha\nbeta\n");
});
it("refuses a scoped pi_grep when no grep tool was granted", async () => {
// The factory builds a fresh tool and `executeTool` prefers that override
// over the registry, so a session that withheld `grep` must not install
// one — otherwise a frame carrying `context`/`limit` searches anyway.
await Bun.write(path.join(cwd, "hit.txt"), "needle\n");
const denied = new CursorExecHandlers({ cwd, tools: new Map<string, Tool>() });
const result = await denied.piGrep({
toolCallId: "g0",
args: { pattern: "needle", path: cwd, limit: 5 },
} as never);
expect(result.isError).toBe(true);
expect(result.content.map(c => (c.type === "text" ? c.text : "")).join("")).toContain("not available");
});
it("wraps the per-call grep the real bridge factory builds", async () => {
// The reviewed bypass was in the factory the session hands the bridge,
// not in the bridge: a raw `new GrepTool(...)` there skips the approval