From 4f07562a542ee50bdabf982dc44da1ca36c8c1e7 Mon Sep 17 00:00:00 2001 From: 001005HS <99410048+001005HS@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:24:20 +0900 Subject: [PATCH 1/3] fix(cursor): name the real catalog in native-exec denials when no shell bridge exists When the Cursor adapter denies a server-driven native read/ls/grep/write/delete/ shell/fetch exec, the refusal text is a fixed string that redirects the model to `shell_command` / `exec_command`. That is right for a Codex-style catalog, but a delegation-only client (an orchestrator exposing nothing but its own Responses tools, e.g. a single `task` tool) has neither alias in its catalog. kimi-k3 takes the refusal literally, looks for the named bridge, and ends the turn with "those tools are not in my list" instead of calling `ocx_client_task`, which is listed. Add `cursorNativeExecRedirectHint`: when the request's visible catalog carries no shell alias and no execution-path tool, build a silent-redirect message that names the request's actual wire names (`ocx_client_*`, with the `mcp_opencodex-responses_` display form). The live transport sets it on the per-request exec context as `nativeExecRedirectHint`, and `handleCursorNativeExec` passes it to every policy-denial helper, which fall back to today's wording when it is undefined. Catalogs with `exec_command` / `shell_command` / unified `exec` are unchanged. Measured on 2.53.0 with the same single-tool request repeated: cursor/kimi-k3 called the delegation tool in 2/6 turns before and 6/6 after; the same model via another provider delegated 2/2 throughout, so the wording, not the model, was the difference. Through the orchestrator TUI: 0/6 before, 3/3 after. Fixes #4542 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y9KEo9eSg5n58ShbrkgSCf --- src/adapters/cursor/live-transport.ts | 2 + src/adapters/cursor/native-exec-fs.ts | 20 +-- src/adapters/cursor/native-exec-network.ts | 4 +- src/adapters/cursor/native-exec-shell.ts | 25 +-- src/adapters/cursor/native-exec.ts | 51 ++++-- structure/providers/cursor.md | 8 + .../cursor/cursor-native-exec-policy.test.ts | 163 +++++++++++++++++- 7 files changed, 236 insertions(+), 37 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 2d6385ae77..3296948c63 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -56,6 +56,7 @@ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, i import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { + cursorNativeExecRedirectHint, handleCursorNativeExec, handleCursorNativeKv, releaseCursorBlobRequestScope, @@ -697,6 +698,7 @@ class LiveCursorTransport implements CursorTransport { clientToolDefs, rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, + nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools), }; const toolSchemas = new Map(); const cursorToolNameMap = new Map(); diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 265bfc7b92..2558f91fac 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -49,11 +49,11 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi const NATIVE_LOCAL_EXEC_DISABLED = "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call."; -export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectReadExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "readResult", create(ReadResultSchema, { - result: { case: "error", value: create(ReadErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) }, + result: { case: "error", value: create(ReadErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) }, })); } @@ -98,13 +98,13 @@ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structu })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(WriteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -147,13 +147,13 @@ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, struct })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(DeleteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -188,11 +188,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectLsExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "lsArgs") throw new Error("invalid ls exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "lsResult", create(LsResultSchema, { - result: { case: "error", value: create(LsErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) }, + result: { case: "error", value: create(LsErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) }, })); } @@ -256,8 +256,8 @@ function grepError(execMsg: ExecServerMessage, error: string): Uint8Array { })); } -export function rejectGrepExecForPolicy(execMsg: ExecServerMessage): Uint8Array { - return grepError(execMsg, NATIVE_LOCAL_EXEC_DISABLED); +export function rejectGrepExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { + return grepError(execMsg, hint ?? NATIVE_LOCAL_EXEC_DISABLED); } export function grepExec(execMsg: ExecServerMessage): Uint8Array { diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index f259f284ef..49f1142e97 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -9,11 +9,11 @@ export interface CursorNativeNetworkDeps { const NATIVE_FETCH_DISABLED = "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call."; -export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec"); const args = execMsg.message.value; return execBytes(execMsg, "fetchResult", create(FetchResultSchema, { - result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: NATIVE_FETCH_DISABLED }) }, + result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: hint ?? NATIVE_FETCH_DISABLED }) }, })); } diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 6fb7688302..411aa7f07c 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -82,7 +82,8 @@ let unresolvedKills = 0; let killFailures = 0; /** Rejection text when Cursor-native shell is denied by policy (issue #604). */ -export function nativeShellDisabledMessage(): string { +export function nativeShellDisabledMessage(hint?: string): string { + if (hint) return hint; // Do not insist on "the same command" — that steers models into replaying bash/CMD // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604). // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex @@ -98,7 +99,7 @@ export function nativeShellDisabledMessage(): string { ); } -function rejectedShellResult(command: string, cwd: string, started: number) { +function rejectedShellResult(command: string, cwd: string, started: number, hint?: string) { return create(ShellResultSchema, { result: { case: "failure", @@ -108,7 +109,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: nativeShellDisabledMessage(), + stderr: nativeShellDisabledMessage(hint), executionTime: Date.now() - started, aborted: true, }), @@ -116,10 +117,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) { }); } -export function rejectShellExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectShellExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "shellArgs") throw new Error("invalid shell exec"); const args = execMsg.message.value; - return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now())); + return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now(), hint)); } export function shellExec(execMsg: ExecServerMessage): Uint8Array { @@ -157,7 +158,7 @@ export function shellExec(execMsg: ExecServerMessage): Uint8Array { })); } -export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint8Array[] { +export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array[] { if (execMsg.message.case !== "shellStreamArgs") throw new Error("invalid shell stream exec"); const args = execMsg.message.value; const cwd = resolve(args.workingDirectory || process.cwd()); @@ -167,12 +168,12 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { - event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) }, + event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage(hint) }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) }, })), - execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started)), + execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started, hint)), execStreamCloseBytes(execMsg), ]; } @@ -263,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise[] | undefined, +): string | undefined { + if (!tools || tools.length === 0) return undefined; + if (cursorRequestHasShellAlias(tools) || cursorRequestHasExecutionPath(tools)) return undefined; + const names = [...new Set(tools.map(cursorToolWireName))]; + const shown = names.slice(0, REDIRECT_HINT_MAX_TOOLS).map(name => `\`${name}\``).join(", "); + const more = names.length > REDIRECT_HINT_MAX_TOOLS ? ` (+${names.length - REDIRECT_HINT_MAX_TOOLS} more)` : ""; + return ( + "This request has no shell, read, grep, ls, write, or fetch tool. Do NOT retry Read/Glob/Grep/LS/Shell/Write/Fetch. " + + `The ONLY callable tools this turn are the \`${OCX_RESPONSES_TOOL_PROVIDER}\` catalog entries: ${shown}${more} ` + + `(the harness may display them as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool). ` + + "Accomplish this operation NOW by calling one of those listed tools — if it needs file or shell access, delegate it through the listed tool that runs work on your behalf. " + + "Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call." + ); } export function cursorUnsafeNativeLocalExecEnabled(input: Pick = {}): boolean { @@ -634,16 +665,16 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C }))]; } if (!cursorUnsafeNativeLocalExecEnabled(deps)) { - if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg)]; - if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg)]; - if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg)]; - if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg)]; - if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg)]; - if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg)]; - if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg); - if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg)]; - if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg)]; - if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg)]; + if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg, deps.nativeExecRedirectHint); + if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; } if (execCase === "readArgs") return [readExec(execMsg)]; if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 55f017685b..c028b56471 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -20,6 +20,14 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +The denial payload is a silent redirect whose wording follows the request catalog. When the catalog +carries a shell bridge or unified `exec`, the model is redirected to `shell_command` / +`exec_command`. When it carries neither — a delegation-only client that exposes nothing but its own +Responses tools — `cursorNativeExecRedirectHint` in `src/adapters/cursor/native-exec.ts` names the +request's actual `ocx_client_*` wire names instead, and the live transport injects that text into +the per-request exec context, so a model that tried Cursor-native Read/Shell is steered to a tool +that exists rather than to an alias it cannot see. + > Decision record: [ADR-0047](../decisions/ADR-0047-cursor-native-exec.md) Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 24b63e28ea..54034a16fb 100644 --- a/tests/providers/cursor/cursor-native-exec-policy.test.ts +++ b/tests/providers/cursor/cursor-native-exec-policy.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { create, fromBinary } from "@bufbuild/protobuf"; @@ -12,19 +12,31 @@ import { import { AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, + DeleteArgsSchema, ExecServerMessageSchema, FetchArgsSchema, + GrepArgsSchema, + LsArgsSchema, ReadArgsSchema, ShellArgsSchema, + WriteArgsSchema, + WriteShellStdinArgsSchema, } from "../../../src/adapters/cursor/gen/agent_pb"; -import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; +import { createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; import { + cursorNativeExecRedirectHint, + handleCursorNativeExec, + resetCursorBlobStateForTests, +} from "../../../src/adapters/cursor/native-exec"; +import { + nativeShellDisabledMessage, resetBackgroundShellStateForTests, setBackgroundShellRuntimeForTests, } from "../../../src/adapters/cursor/native-exec-shell"; import type { CursorTransportFactoryInput } from "../../../src/adapters/cursor/transport"; import { parseRequest } from "../../../src/responses/parser"; -import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import type { OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; const fullAccessDeclaration = "`sandbox_mode` is `danger-full-access`"; @@ -384,3 +396,148 @@ describe("Cursor native exec sandbox policy", () => { }); }); + +/** + * A delegation-only client (an orchestrator that exposes nothing but its own Responses tools — + * no shell bridge, no unified exec) still gets Cursor-native Read/Shell attempts from the model. + * The default denial steers the model to `shell_command` / `exec_command`; when those are not in + * the catalog the model concludes every tool is unavailable and gives up. The hint names the + * catalog that actually exists instead. + */ +describe("Cursor native exec catalog-aware redirect hint", () => { + const SILENT_REDIRECT_FORBIDDEN = [/blocked/i, /\bdisabled\b/i, /not executed/i, /\bdenied\b/i, /cannot execute/i, /차단/]; + type CatalogTool = { name: string; namespace?: string; freeform?: boolean }; + const delegationOnlyCatalog: CatalogTool[] = [{ name: "task" }, { name: "ask_user" }]; + + function stringifyReplies(replies: Uint8Array[]): string { + return replies.map(bytes => stringify(fromBinary(AgentClientMessageSchema, bytes))).join("\n"); + } + + test("names the request's client wire names when the catalog has no shell bridge or execution path", () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`ocx_client_ask_user`"); + expect(hint).toContain("mcp_opencodex-responses_"); + expect(hint).toContain("Do NOT narrate"); + expect(hint).not.toContain("shell_command"); + expect(hint).not.toContain("exec_command"); + for (const pattern of SILENT_REDIRECT_FORBIDDEN) expect(hint).not.toMatch(pattern); + }); + + test.each<[string, CatalogTool[] | undefined]>([ + ["an undefined catalog", undefined], + ["an empty catalog", []], + ["a bare exec_command bridge", [{ name: "exec_command" }]], + ["a bare shell_command bridge next to client tools", [{ name: "task" }, { name: "shell_command" }]], + ["unified exec next to client tools", [{ name: "task" }, { name: "exec", freeform: true }]], + ])("keeps the default bridge wording for %s", (_name, tools) => { + expect(cursorNativeExecRedirectHint(tools)).toBeUndefined(); + }); + + test("lists namespaced tools by wire name and caps a long catalog", () => { + const hint = cursorNativeExecRedirectHint([{ namespace: "mcp__docker", name: "ps" }, { name: "task" }]) ?? ""; + expect(hint).toContain("`mcp__docker__ps`"); + expect(hint).toContain("`ocx_client_task`"); + const capped = cursorNativeExecRedirectHint(Array.from({ length: 20 }, (_, index) => ({ name: `tool_${index}` }))) ?? ""; + expect(capped).toContain("`ocx_client_tool_15`"); + expect(capped).not.toContain("`ocx_client_tool_16`"); + expect(capped).toContain("(+4 more)"); + }); + + test("without a hint the bridge wording is unchanged", () => { + expect(nativeShellDisabledMessage()).toContain("shell_command"); + expect(nativeShellDisabledMessage("custom hint")).toBe("custom hint"); + }); + + test("every denied native fs, shell, and fetch frame carries the hint and executes nothing", async () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-hint-")); + const existing = join(dir, "grounding.txt"); + const content = "HINT-GROUNDING-01 must not leak"; + writeFileSync(existing, content); + const newPath = join(dir, "must-not-exist.txt"); + let fetchCalled = false; + const deps = { + unsafeAllowNativeLocalExec: false, + nativeExecRedirectHint: hint, + fetch: async () => { + fetchCalled = true; + return new Response("SHOULD_NOT_FETCH"); + }, + }; + const frames = [ + execMessage({ case: "readArgs", value: create(ReadArgsSchema, { path: existing }) }), + execMessage({ case: "lsArgs", value: create(LsArgsSchema, { path: dir }) }), + execMessage({ case: "grepArgs", value: create(GrepArgsSchema, { pattern: "HINT", path: dir }) }), + execMessage({ case: "writeArgs", value: create(WriteArgsSchema, { path: newPath, fileText: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "deleteArgs", value: create(DeleteArgsSchema, { path: existing }) }), + execMessage({ case: "shellArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir, hardTimeout: 2000 }) }), + execMessage({ case: "shellStreamArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "backgroundShellSpawnArgs", value: create(BackgroundShellSpawnArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "writeShellStdinArgs", value: create(WriteShellStdinArgsSchema, { shellId: 999, chars: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "fetchArgs", value: create(FetchArgsSchema, { url: "https://metadata.invalid/latest" }) }), + ]; + for (const frame of frames) { + const text = stringifyReplies(await handleCursorNativeExec(frame, deps)); + expect(text).toContain("`ocx_client_task`"); + expect(text).toContain("Do NOT narrate"); + expect(text).not.toContain("shell_command"); + expect(text).not.toContain("exec_command"); + expect(text).not.toContain(content); + // Denied shell frames echo the command text; only an executed command could produce the joined marker. + expect(text).not.toContain("RAN_MARKER"); + expect(text).not.toContain("SHOULD_NOT_WRITE"); + expect(text).not.toContain("SHOULD_NOT_FETCH"); + } + expect(fetchCalled).toBe(false); + expect(existsSync(existing)).toBe(true); + expect(existsSync(newPath)).toBe(false); + }); + + test("the live transport derives the hint from each turn's visible catalog", async () => { + type OpenFn = ( + encoded: Uint8Array, + signal: AbortSignal | undefined, + state: unknown, + push: unknown, + fail: (error: Error) => void, + finish: () => void, + ) => void; + const runWithTools = async (tools: OcxTool[]): Promise => { + resetCursorBlobStateForTests(); + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + }); + let failTurn!: (error: Error) => void; + let onOpened!: () => void; + const opened = new Promise(resolve => { onOpened = resolve; }); + (transport as unknown as { open: OpenFn }).open = (_encoded, _signal, _state, _push, fail) => { + failTurn = fail; + onOpened(); + }; + const iterator = transport.run({ + modelId: "composer-2.5", + conversationId: `redirect-hint-${tools.length}`, + system: [], + messages: [{ role: "user", content: "hi" }], + tools, + })[Symbol.asyncIterator](); + const pending = iterator.next(); + await opened; + const hint = (transport as unknown as { execContext: { nativeExecRedirectHint?: string } }).execContext.nativeExecRedirectHint; + failTurn(new Error("fixture closed")); + await pending.catch(() => {}); + await transport.close?.(); + resetCursorBlobStateForTests(); + return hint; + }; + const task: OcxTool = { name: "task", description: "Delegate work to a worker agent.", parameters: { type: "object" } }; + const bridge: OcxTool = { name: "exec_command", description: "Run a shell command.", parameters: { type: "object" } }; + expect(await runWithTools([task])).toContain("`ocx_client_task`"); + expect(await runWithTools([task, bridge])).toBeUndefined(); + }); +}); From 369d498fbdf9a166386358c6aa487d210a7bb34a Mon Sep 17 00:00:00 2001 From: 001005HS <99410048+001005HS@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:47:09 +0900 Subject: [PATCH 2/3] fix(cursor): keep the redirect hint neutral about catalog capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for #4544. - The hint no longer claims the request has "no read/grep/ls/write/fetch tool" or that the Responses entries are the only callable tools. It lists the catalog and tells the model to pick the listed tool that fits — a listed file/search/fetch tool if there is one, otherwise the delegating tool — so a direct non-shell tool such as an MCP read_file is never contradicted. - Configured MCP server tools advertised through prepareMcp are listed too, by their harness display form (mcp__); the live transport passes execContext.mcpToolDefs alongside the visible client catalog. - structure/providers/cursor.md names the namespaced wire-name form and the opencodex-responses display prefix next to ocx_client_*. - Drop a personal patch note from the JSDoc. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y9KEo9eSg5n58ShbrkgSCf --- src/adapters/cursor/live-transport.ts | 2 +- src/adapters/cursor/native-exec.ts | 23 ++++++++++++------- structure/providers/cursor.md | 10 +++++--- .../cursor/cursor-native-exec-policy.test.ts | 19 ++++++++++++++- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 3296948c63..33bf4cf9f1 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -698,7 +698,7 @@ class LiveCursorTransport implements CursorTransport { clientToolDefs, rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, - nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools), + nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs), }; const toolSchemas = new Map(); const cursorToolNameMap = new Map(); diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index a924136af5..8efb3b30f8 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -81,26 +81,33 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps { const REDIRECT_HINT_MAX_TOOLS = 16; /** - * Redirect text for Cursor-native fs/shell attempts when the request catalog carries NO shell + * Redirect text for Cursor-native fs/shell/fetch attempts when the request catalog carries NO shell * bridge or other execution-path tool (an orchestrator client that only exposes delegation tools, * for example). The default refusal steers the model to `shell_command` / `exec_command`; when those * are not in the catalog some models (kimi-k3 observed) conclude every tool is unavailable and give - * up instead of using the tools that ARE listed. Name the real catalog instead. - * Local patch (hs, 2026-09-14) on top of 2.53.0 — see ~/.opencodex/patches. + * up instead of using the tools that ARE listed. Name the real catalog instead — the client tools + * plus any configured MCP tools advertised this turn — and stay neutral about what those tools can + * do, so a listed file/search/fetch tool is never contradicted. */ export function cursorNativeExecRedirectHint( tools: readonly Pick[] | undefined, + mcpToolDefs: readonly Pick[] = [], ): string | undefined { if (!tools || tools.length === 0) return undefined; if (cursorRequestHasShellAlias(tools) || cursorRequestHasExecutionPath(tools)) return undefined; - const names = [...new Set(tools.map(cursorToolWireName))]; + // Client tools are advertised under OCX_RESPONSES_TOOL_PROVIDER, so the harness shows them as + // `mcp__`; configured MCP servers are advertised under their own provider id. + const names = [...new Set([ + ...tools.map(cursorToolWireName), + ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), + ])]; const shown = names.slice(0, REDIRECT_HINT_MAX_TOOLS).map(name => `\`${name}\``).join(", "); const more = names.length > REDIRECT_HINT_MAX_TOOLS ? ` (+${names.length - REDIRECT_HINT_MAX_TOOLS} more)` : ""; return ( - "This request has no shell, read, grep, ls, write, or fetch tool. Do NOT retry Read/Glob/Grep/LS/Shell/Write/Fetch. " - + `The ONLY callable tools this turn are the \`${OCX_RESPONSES_TOOL_PROVIDER}\` catalog entries: ${shown}${more} ` - + `(the harness may display them as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool). ` - + "Accomplish this operation NOW by calling one of those listed tools — if it needs file or shell access, delegate it through the listed tool that runs work on your behalf. " + `Re-issue this operation NOW through one of the tools listed in this request's catalog: ${shown}${more} ` + + `(the harness displays a \`${OCX_RESPONSES_TOOL_PROVIDER}\` entry as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool). ` + + "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. " + + "Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. " + "Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call." ); } diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index c028b56471..0cef62c03e 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -24,9 +24,13 @@ The denial payload is a silent redirect whose wording follows the request catalo carries a shell bridge or unified `exec`, the model is redirected to `shell_command` / `exec_command`. When it carries neither — a delegation-only client that exposes nothing but its own Responses tools — `cursorNativeExecRedirectHint` in `src/adapters/cursor/native-exec.ts` names the -request's actual `ocx_client_*` wire names instead, and the live transport injects that text into -the per-request exec context, so a model that tried Cursor-native Read/Shell is steered to a tool -that exists rather than to an alias it cannot see. +request's actual wire names instead: bare client tools as `ocx_client_*`, namespaced tools as the +`namespacedToolName` form (for example `mcp__docker__ps`), both registered under the +`opencodex-responses` provider so the harness displays them as `mcp_opencodex-responses_`, +plus any configured MCP server tools advertised through `prepareMcp` as `mcp__`. The +text stays neutral about what those tools can do — it never claims the request has no read or fetch +tool — and the live transport injects it into the per-request exec context, so a model that tried +Cursor-native Read/Shell is steered to a tool that exists rather than to an alias it cannot see. > Decision record: [ADR-0047](../decisions/ADR-0047-cursor-native-exec.md) diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 54034a16fb..967eee14ce 100644 --- a/tests/providers/cursor/cursor-native-exec-policy.test.ts +++ b/tests/providers/cursor/cursor-native-exec-policy.test.ts @@ -422,9 +422,21 @@ describe("Cursor native exec catalog-aware redirect hint", () => { expect(hint).toContain("Do NOT narrate"); expect(hint).not.toContain("shell_command"); expect(hint).not.toContain("exec_command"); + // Neutral about capabilities: a listed file/search/fetch tool must never be contradicted. + expect(hint).not.toMatch(/no (shell|read|grep|ls|write|fetch) tool/i); + expect(hint).not.toMatch(/ONLY callable/i); for (const pattern of SILENT_REDIRECT_FORBIDDEN) expect(hint).not.toMatch(pattern); }); + test("names configured MCP tools advertised for the turn by their harness display form", () => { + const hint = cursorNativeExecRedirectHint( + [{ name: "task" }], + [{ name: "read_file", providerIdentifier: "opencodex" }], + ) ?? ""; + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`mcp_opencodex_read_file`"); + }); + test.each<[string, CatalogTool[] | undefined]>([ ["an undefined catalog", undefined], ["an empty catalog", []], @@ -519,6 +531,9 @@ describe("Cursor native exec catalog-aware redirect hint", () => { failTurn = fail; onOpened(); }; + (transport as unknown as { execContext: { mcpToolDefs?: unknown[] } }).execContext.mcpToolDefs = [ + { name: "read_file", providerIdentifier: "opencodex" }, + ]; const iterator = transport.run({ modelId: "composer-2.5", conversationId: `redirect-hint-${tools.length}`, @@ -537,7 +552,9 @@ describe("Cursor native exec catalog-aware redirect hint", () => { }; const task: OcxTool = { name: "task", description: "Delegate work to a worker agent.", parameters: { type: "object" } }; const bridge: OcxTool = { name: "exec_command", description: "Run a shell command.", parameters: { type: "object" } }; - expect(await runWithTools([task])).toContain("`ocx_client_task`"); + const hint = await runWithTools([task]); + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`mcp_opencodex_read_file`"); expect(await runWithTools([task, bridge])).toBeUndefined(); }); }); From 5777e9d7200c0f9eb440993106c7066107f1ce52 Mon Sep 17 00:00:00 2001 From: 001005HS <99410048+001005HS@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:54:50 +0900 Subject: [PATCH 3/3] fix(cursor): name configured MCP tools in the hint even without client tools Review follow-up for #4544 (CodeRabbit): a request with no client tools but configured MCP tools on execContext.mcpToolDefs returned before building the hint, so the denial fell back to the shell_command / exec_command wording although those aliases were not visible either. Build the name list from both sources and keep the default wording only when nothing is advertised at all. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y9KEo9eSg5n58ShbrkgSCf --- src/adapters/cursor/native-exec.ts | 9 ++++++--- tests/providers/cursor/cursor-native-exec-policy.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index 8efb3b30f8..c1c9d9a1b2 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -93,14 +93,17 @@ export function cursorNativeExecRedirectHint( tools: readonly Pick[] | undefined, mcpToolDefs: readonly Pick[] = [], ): string | undefined { - if (!tools || tools.length === 0) return undefined; - if (cursorRequestHasShellAlias(tools) || cursorRequestHasExecutionPath(tools)) return undefined; + const clientTools = tools ?? []; + if (cursorRequestHasShellAlias(clientTools) || cursorRequestHasExecutionPath(clientTools)) return undefined; // Client tools are advertised under OCX_RESPONSES_TOOL_PROVIDER, so the harness shows them as // `mcp__`; configured MCP servers are advertised under their own provider id. + // A request with no client tools but configured MCP tools still gets those named; a request that + // advertises nothing at all keeps the default bridge wording. const names = [...new Set([ - ...tools.map(cursorToolWireName), + ...clientTools.map(cursorToolWireName), ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), ])]; + if (names.length === 0) return undefined; const shown = names.slice(0, REDIRECT_HINT_MAX_TOOLS).map(name => `\`${name}\``).join(", "); const more = names.length > REDIRECT_HINT_MAX_TOOLS ? ` (+${names.length - REDIRECT_HINT_MAX_TOOLS} more)` : ""; return ( diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 967eee14ce..0d66e704cd 100644 --- a/tests/providers/cursor/cursor-native-exec-policy.test.ts +++ b/tests/providers/cursor/cursor-native-exec-policy.test.ts @@ -435,6 +435,14 @@ describe("Cursor native exec catalog-aware redirect hint", () => { ) ?? ""; expect(hint).toContain("`ocx_client_task`"); expect(hint).toContain("`mcp_opencodex_read_file`"); + // No client tools at all, but configured MCP tools: those are the catalog, so name them. + const mcpOnly = cursorNativeExecRedirectHint(undefined, [{ name: "read_file", providerIdentifier: "opencodex" }]) ?? ""; + expect(mcpOnly).toContain("`mcp_opencodex_read_file`"); + expect(mcpOnly).not.toContain("ocx_client_"); + expect(mcpOnly).not.toContain("shell_command"); + // Nothing advertised anywhere keeps the default bridge wording. + expect(cursorNativeExecRedirectHint(undefined, [])).toBeUndefined(); + expect(cursorNativeExecRedirectHint([], [])).toBeUndefined(); }); test.each<[string, CatalogTool[] | undefined]>([