diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 523ab8ced..379c4140e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b56291cb3..10e7a9f6a 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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 | 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. diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index a64095ad2..b0e29a7b8 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -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() }); + 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