diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index cc74c11e40..f2e08105e5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -519,7 +519,7 @@ and `fast=true` `requested_model` parameters; flattened `cursor-grok-{version}-{ are discovery and picker identities only. Cursor serves Kimi K3 only as effort-suffixed wire ids, so `cursor/kimi-k3` exposes a `low` / `high` / `max` ladder and defaults to `max`, matching the model's documented API default. Cursor server-driven native read/write/delete/ls/grep/shell/fetch execution -is disabled by default because it bypasses Codex's approval and sandbox path; set +is disabled by default because it bypasses Codex's approval and sandbox path. Denied native calls are rerouted to the Codex tools advertised on that turn — a flat `exec_command` / `shell_command` bridge, or nested `await tools.exec_command(...)` helpers inside Desktop code-mode `exec`. Set `unsafeAllowNativeLocalExec: true` on the `providers.cursor` object in `~/.opencodex/config.json` only for trusted local experiments (or via **Providers → Cursor → Edit JSON** in the dashboard). See the [Configuration reference](/reference/configuration/#cursor-provider-adapter-cursor) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 1fde7c4297..7c8332cb18 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -286,7 +286,7 @@ Explicit variants send Cursor's `default` model with its `optimization` paramete selection on every request. They remain available when live discovery omits `default`. Cursor server-driven local tools are disabled by default. Codex continues using its own tools such as -`apply_patch` and `exec_command` with its own approval and sandbox policy: +`apply_patch`, `exec_command`, and Desktop code-mode `exec` (nested `await tools.exec_command(...)` helpers) with its own approval and sandbox policy: - `"off"` (default) rejects Cursor-native `read`, `write`, `delete`, `ls`, `grep`, `shell`, and `fetch` execution. diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index afc411e243..eae3fc2481 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -59,6 +59,7 @@ import { type CursorNativeExecContext, } from "./native-exec"; import { effectiveCursorNativeExecAllow } from "./exec-policy"; +import { rewriteNativeExecToCodexBridge, type NativeExecRewrite } from "./native-exec-bridge"; import { resolveMcpServers } from "./mcp-config"; import { CursorMcpManager } from "./mcp-manager"; import { buildMcpToolDefinitions, mcpDepsFromManager } from "./native-exec-mcp"; @@ -256,6 +257,42 @@ export function planMcpArgsHandling( }; } +/** + * Decide how to surface a rewritten native Shell/Read/… exec as a Codex `exec` client tool. + * + * Same Responses-bridge contract as `planMcpArgsHandling`: emit the tool call, then end turn 1 + * through the grace timer (`done` + cancel). Immediate `cancelCursorRun()` is wrong here — it sets + * `expectedClose` and makes `scheduleClientToolFinalize` a no-op, so turn 1 never emits `done`, + * the conversation id is dropped, and the rewritten exec result never returns on the next request. + * + * Pure (no I/O) so the decision is unit-testable. `handleServerMessage` performs the side effects. + */ +export interface NativeExecRewritePlan { + handled: boolean; + events: CursorServerMessage[]; + cancelCursorRun: boolean; + finalizeWhenDrained: boolean; +} + +export function planNativeExecRewrite( + rewrite: NativeExecRewrite, + state: ReturnType, +): NativeExecRewritePlan { + if (rewrite.kind !== "exec") { + return { handled: false, events: [], cancelCursorRun: false, finalizeWhenDrained: false }; + } + return { + handled: true, + events: [ + { type: "tool_call_start", id: rewrite.callId, name: "exec" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: rewrite.js }) }, + { type: "tool_call_end", id: rewrite.callId }, + ], + cancelCursorRun: false, + finalizeWhenDrained: state.openToolCalls.size === 0, + }; +} + /** * Build the `interactionResponse` reply for a server `interactionQuery`. Cursor's server-side agent * BLOCKS on these queries until the client answers (matching `id`); an unanswered query is the @@ -1069,6 +1106,19 @@ class LiveCursorTransport implements CursorTransport { return; } } + const rewrite = rewriteNativeExecToCodexBridge( + execMsg.message.case, + nativeExecRewriteArgs(execMsg), + { clientToolNames: this.execContext.clientToolDefs?.map(tool => tool.toolName || tool.name) ?? [] }, + ); + const rewritePlan = planNativeExecRewrite(rewrite, state); + if (rewritePlan.handled) { + this.noteClientToolActivity(); + for (const event of rewritePlan.events) push(event); + if (rewritePlan.cancelCursorRun) this.cancelCursorRun(); + else if (rewritePlan.finalizeWhenDrained) this.scheduleClientToolFinalize(state, push); + return; + } // Native exec/MCP is handled inside this transport and can mutate files/process state // without emitting a Responses tool event. Mark the turn replay-unsafe before executing so // an eventual invalid_argument cannot cause the adapter's fresh-conversation fallback to @@ -1214,6 +1264,22 @@ function isCursorProgressFrame(message: AgentServerMessage): boolean { * count: Cursor-native tool frames (readToolCall/editToolCall/...) are display-plane and must not * revoke a pending client-tool finalize. Exported for unit testing. */ + +function nativeExecRewriteArgs(execMsg: { message: { case?: string; value?: unknown }; execId?: string }) { + const value = execMsg.message.value; + const record = value && typeof value === "object" ? value as Record : {}; + const text = (key: string): string | undefined => { + const found = record[key]; + return typeof found === "string" ? found : undefined; + }; + return { + command: text("command"), + path: text("path"), + url: text("url"), + pattern: text("pattern"), + toolCallId: text("toolCallId") ?? execMsg.execId, + }; +} export function isClientToolFrame(message: AgentServerMessage): boolean { if (message.message.case !== "interactionUpdate") return false; const update = message.message.value.message; diff --git a/src/adapters/cursor/native-exec-bridge.ts b/src/adapters/cursor/native-exec-bridge.ts new file mode 100644 index 0000000000..b23131bf61 --- /dev/null +++ b/src/adapters/cursor/native-exec-bridge.ts @@ -0,0 +1,151 @@ +import { + CODEX_APPLY_PATCH_TOOL, + CODEX_EXEC_COMMAND_TOOL, + CODEX_SHELL_COMMAND_TOOL, + CODEX_UNIFIED_EXEC_TOOL, + isCodexShellBridgeToolName, + OCX_RESPONSES_TOOL_PROVIDER, +} from "./tool-definitions"; + +export interface CursorNativeExecBridgeCatalog { + clientToolNames?: readonly string[]; +} + +const CODE_MODE_DISPLAY_NAME = `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_${CODEX_UNIFIED_EXEC_TOOL}`; + +const CODE_MODE_SHELL_HINT = + `the Codex code-mode tool \`${CODEX_UNIFIED_EXEC_TOOL}\` (Cursor may list it as \`${CODE_MODE_DISPLAY_NAME}\`) and call a nested helper INSIDE its JavaScript body, for example \`await tools.${CODEX_EXEC_COMMAND_TOOL}({cmd: "pwd"})\`. If the catalog shows \`${CODE_MODE_DISPLAY_NAME}\`, call that name. Do not invent a top-level \`${CODEX_SHELL_COMMAND_TOOL}\` / \`${CODEX_EXEC_COMMAND_TOOL}\` call`; + +const FLAT_SHELL_HINT = + `the Codex bridge shell tool from the current catalog (\`${CODEX_SHELL_COMMAND_TOOL}\` or \`${CODEX_EXEC_COMMAND_TOOL}\`, including the long \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_*\` display name if listed)`; + +function advertisedNames(catalog?: CursorNativeExecBridgeCatalog): string[] { + return (catalog?.clientToolNames ?? []) + .map(name => name.trim()) + .filter(name => name.length > 0); +} + +function hasAdvertisedName(names: readonly string[], expected: string): boolean { + return names.some(name => name === expected || name.endsWith(`_${expected}`) || name.endsWith(`__${expected}`)); +} + +/** True when this turn advertised freeform/code-mode exec and no flat shell bridge. */ +export function cursorNativeExecUsesCodeModeBridge(catalog?: CursorNativeExecBridgeCatalog): boolean { + const names = advertisedNames(catalog); + if (names.length === 0) return false; + const hasExec = hasAdvertisedName(names, CODEX_UNIFIED_EXEC_TOOL); + const hasFlatShell = names.some(name => + isCodexShellBridgeToolName(name) + || name.endsWith(`_${CODEX_EXEC_COMMAND_TOOL}`) + || name.endsWith(`_${CODEX_SHELL_COMMAND_TOOL}`) + || name.endsWith(`__${CODEX_EXEC_COMMAND_TOOL}`) + || name.endsWith(`__${CODEX_SHELL_COMMAND_TOOL}`), + ); + return hasExec && !hasFlatShell; +} + +function bridgeTarget(catalog?: CursorNativeExecBridgeCatalog): string { + return cursorNativeExecUsesCodeModeBridge(catalog) ? CODE_MODE_SHELL_HINT : FLAT_SHELL_HINT; +} + +function silenceClause(kind: "shell" | "filesystem" | "network"): string { + if (kind === "filesystem") { + return "Do not tell the user shell or read access is blocked, disabled, or denied — silently call that bridge tool."; + } + if (kind === "network") { + return "Do not tell the user network or shell access is blocked, disabled, or denied — silently call that bridge tool."; + } + return "Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool."; +} + +export function nativeShellDisabledMessage(catalog?: CursorNativeExecBridgeCatalog): string { + // 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 + // client that executes the bridge (LAN/SSH remote-proxy). + return ( + `Route this through ${bridgeTarget(catalog)}. ` + + `${silenceClause("shell")} ` + + "Adapt the command for the Codex client host shell before calling the bridge " + + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " + + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." + ); +} + +export function nativeFilesystemDisabledMessage(catalog?: CursorNativeExecBridgeCatalog): string { + const editHint = cursorNativeExecUsesCodeModeBridge(catalog) + ? `or a nested \`await tools.${CODEX_APPLY_PATCH_TOOL}(...)\` helper inside \`${CODEX_UNIFIED_EXEC_TOOL}\` for file edits` + : "or `apply_patch` for file edits"; + return ( + `Route filesystem work through ${bridgeTarget(catalog)} with equivalent shell commands (cat, head, ls, rg, grep), ${editHint}. ` + + silenceClause("filesystem") + ); +} + +export function nativeFetchDisabledMessage(catalog?: CursorNativeExecBridgeCatalog): string { + if (cursorNativeExecUsesCodeModeBridge(catalog)) { + return ( + `Route this through ${CODE_MODE_SHELL_HINT} with a nested \`await tools.${CODEX_EXEC_COMMAND_TOOL}({cmd: "curl ..."})\` helper. ` + + silenceClause("network") + ); + } + return ( + "Route this through the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. " + + silenceClause("network") + ); +} + +export type NativeExecRewrite = + | { kind: "none" } + | { kind: "exec"; callId: string; source: string; js: string } + | { kind: "unsupported"; reason: string }; + +function quotedShell(value: string): string { + return JSON.stringify(value); +} + +function shellCommand(parts: readonly string[]): string { + return parts.map(part => /[\s"'`$]/.test(part) ? quotedShell(part) : part).join(" "); +} + +export function rewriteNativeExecToCodexBridge( + execCase: string | undefined, + args: { command?: string; path?: string; url?: string; pattern?: string; toolCallId?: string }, + catalog?: CursorNativeExecBridgeCatalog, +): NativeExecRewrite { + if (!cursorNativeExecUsesCodeModeBridge(catalog)) return { kind: "none" }; + const callId = args.toolCallId?.trim() || `cursor_native_${execCase || "exec"}`; + const wrap = (cmd: string): NativeExecRewrite => ({ + kind: "exec", + callId, + source: execCase ?? "unknown", + js: `const result = await tools.exec_command({cmd: ${quotedShell(cmd)}}); text(typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)));`, + }); + if (execCase === "shellArgs" || execCase === "shellStreamArgs" || execCase === "backgroundShellSpawnArgs") { + const command = args.command?.trim(); + if (!command) return { kind: "unsupported", reason: "empty shell command" }; + return wrap(command); + } + if (execCase === "readArgs") { + const path = args.path?.trim(); + if (!path) return { kind: "unsupported", reason: "empty path" }; + return wrap(shellCommand(["cat", "--", path])); + } + if (execCase === "lsArgs") { + const path = args.path?.trim(); + if (!path) return { kind: "unsupported", reason: "empty path" }; + return wrap(shellCommand(["ls", "--", path])); + } + if (execCase === "grepArgs") { + const pattern = args.pattern?.trim(); + const path = args.path?.trim() || "."; + if (!pattern) return { kind: "unsupported", reason: "empty grep pattern" }; + return wrap(shellCommand(["rg", "--", pattern, path])); + } + if (execCase === "fetchArgs") { + const url = args.url?.trim(); + if (!url) return { kind: "unsupported", reason: "empty url" }; + return wrap(shellCommand(["curl", "-fsSL", "--", url])); + } + return { kind: "none" }; +} diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index fb22826912..34752dc5cf 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -34,6 +34,7 @@ import { type GrepArgs, } from "./gen/agent_pb"; import { errorText, execBytes, lineCount, textDecoder, textEncoder } from "./native-exec-common"; +import { nativeFilesystemDisabledMessage, type CursorNativeExecBridgeCatalog } from "./native-exec-bridge"; const MAX_GREP_FILES = 500; const MAX_GREP_RESULTS = 200; @@ -46,14 +47,15 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`; } -const NATIVE_LOCAL_EXEC_DISABLED = - "Route filesystem work through the Codex Responses bridge shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with equivalent shell commands (cat, head, ls, rg, grep), or `apply_patch` for file edits. Do not tell the user shell or read access is blocked, disabled, or denied — silently call that bridge tool."; +function nativeLocalExecDisabled(catalog?: CursorNativeExecBridgeCatalog): string { + return nativeFilesystemDisabledMessage(catalog); +} -export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectReadExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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: nativeLocalExecDisabled(catalog) }) }, })); } @@ -98,13 +100,13 @@ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structu })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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: `${nativeLocalExecDisabled(catalog)} No file was changed.` }), }, })); } @@ -147,13 +149,13 @@ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, struct })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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: `${nativeLocalExecDisabled(catalog)} No file was changed.` }), }, })); } @@ -188,11 +190,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectLsExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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: nativeLocalExecDisabled(catalog) }) }, })); } @@ -256,8 +258,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, catalog?: CursorNativeExecBridgeCatalog): Uint8Array { + return grepError(execMsg, nativeLocalExecDisabled(catalog)); } 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 7d5b9c6617..4b8781aae9 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -1,19 +1,21 @@ import { create } from "@bufbuild/protobuf"; import { FetchErrorSchema, FetchResultSchema, FetchSuccessSchema, type ExecServerMessage } from "./gen/agent_pb"; import { errorText, execBytes } from "./native-exec-common"; +import { nativeFetchDisabledMessage, type CursorNativeExecBridgeCatalog } from "./native-exec-bridge"; export interface CursorNativeNetworkDeps { fetch?: typeof fetch; } -const NATIVE_FETCH_DISABLED = - "Route this through the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do not tell the user network or shell access is blocked, disabled, or denied — silently call that bridge tool."; +function nativeFetchDisabled(catalog?: CursorNativeExecBridgeCatalog): string { + return nativeFetchDisabledMessage(catalog); +} -export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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: nativeFetchDisabled(catalog) }) }, })); } diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index a022f18339..34120c8fa2 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -19,6 +19,7 @@ import { type ExecServerMessage, } from "./gen/agent_pb"; import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common"; +import { nativeShellDisabledMessage as catalogNativeShellDisabledMessage, type CursorNativeExecBridgeCatalog } from "./native-exec-bridge"; import { createAdmissionGate, type AdmissionLease, @@ -82,21 +83,11 @@ let unresolvedKills = 0; let killFailures = 0; /** Rejection text when Cursor-native shell is denied by policy (issue #604). */ -export function nativeShellDisabledMessage(): string { - // 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 - // client that executes the bridge (LAN/SSH remote-proxy). - return ( - "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). " - + "Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool. " - + "Adapt the command for the Codex client host shell before calling the bridge " - + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " - + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." - ); +export function nativeShellDisabledMessage(catalog?: CursorNativeExecBridgeCatalog): string { + return catalogNativeShellDisabledMessage(catalog); } -function rejectedShellResult(command: string, cwd: string, started: number) { +function rejectedShellResult(command: string, cwd: string, started: number, catalog?: CursorNativeExecBridgeCatalog) { return create(ShellResultSchema, { result: { case: "failure", @@ -106,7 +97,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: nativeShellDisabledMessage(), + stderr: nativeShellDisabledMessage(catalog), executionTime: Date.now() - started, aborted: true, }), @@ -114,10 +105,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) { }); } -export function rejectShellExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectShellExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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(), catalog)); } export function shellExec(execMsg: ExecServerMessage): Uint8Array { @@ -155,7 +146,7 @@ export function shellExec(execMsg: ExecServerMessage): Uint8Array { })); } -export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint8Array[] { +export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, catalog?: CursorNativeExecBridgeCatalog): 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()); @@ -165,12 +156,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(catalog) }) }, })), 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, catalog)), execStreamCloseBytes(execMsg), ]; } @@ -261,12 +252,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise tool.toolName || tool.name) + .filter((name): name is string => typeof name === "string" && name.length > 0); +} + +function nativeExecBridgeCatalog(deps: CursorNativeExecContext) { + return { clientToolNames: advertisedClientToolNames(deps) }; +} + /** * Content-addressed blob store shared across streams. Bounded: without eviction a long-running * proxy accumulates every conversation's prompt blobs forever (unbounded memory) and any stale @@ -504,16 +514,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, nativeExecBridgeCatalog(deps))]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg, nativeExecBridgeCatalog(deps)); + if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; + if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, nativeExecBridgeCatalog(deps))]; } if (execCase === "readArgs") return [readExec(execMsg)]; if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 057399dacf..1dd5114dd0 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -615,11 +615,14 @@ export function buildCursorToolGuidanceSystemNote( // Code mode: shell/edit/MCP live inside freeform `exec` as nested helpers. Without this the // model probes for a top-level shell tool that is not there. codeMode - ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description for the exact nested helpers this turn provides. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\`, \`shell_command\`, or \`apply_patch\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.` + ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` (Cursor may list it as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_${CODEX_UNIFIED_EXEC_TOOL}\`) is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description for the exact nested helpers this turn provides. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\`, \`shell_command\`, or \`apply_patch\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.` : undefined, codeMode ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers." : undefined, + codeMode + ? "Never tell the user that shell or read access is blocked, disabled, or denied unless the nested helper inside `exec` itself fails. If a Cursor-native file read, directory listing, grep, or shell operation is rejected, silently retry through `exec` with `await tools.exec_command({cmd: \"...\"})` (POSIX: `cat`/`ls`/`rg`; Windows PowerShell: `Get-Content`/`Get-ChildItem`/`Select-String`). Do not invent a top-level `shell_command` / `exec_command` call, and do not narrate phrases like \"Native shell access is blocked\"." + : undefined, hasBareExec ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.` : undefined, diff --git a/tests/cursor-native-exec-policy.test.ts b/tests/cursor-native-exec-policy.test.ts index 33dfb54d99..3d6e9714f6 100644 --- a/tests/cursor-native-exec-policy.test.ts +++ b/tests/cursor-native-exec-policy.test.ts @@ -14,10 +14,14 @@ import { BackgroundShellSpawnArgsSchema, ExecServerMessageSchema, FetchArgsSchema, + McpToolDefinitionSchema, ReadArgsSchema, ShellArgsSchema, } from "../src/adapters/cursor/gen/agent_pb"; +import { finalizeAfterDrain, planNativeExecRewrite } from "../src/adapters/cursor/live-transport"; import { handleCursorNativeExec } from "../src/adapters/cursor/native-exec"; +import { rewriteNativeExecToCodexBridge } from "../src/adapters/cursor/native-exec-bridge"; +import { createCursorProtobufEventState } from "../src/adapters/cursor/protobuf-events"; import { resetBackgroundShellStateForTests, setBackgroundShellRuntimeForTests, @@ -379,4 +383,106 @@ describe("Cursor native exec sandbox policy", () => { expect(spawnCalls).toBe(1); }); + test("code-mode catalog reroutes denied native exec into nested exec helpers", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-code-mode-")); + const path = join(dir, "grounding.txt"); + writeFileSync(path, "should-not-read"); + const catalog = { + clientToolDefs: [create(McpToolDefinitionSchema, { + name: "exec", + toolName: "exec", + providerIdentifier: "opencodex-responses", + description: "Run JavaScript", + inputSchema: new TextEncoder().encode("{}"), + })], + }; + + const denied = decode((await handleCursorNativeExec(execMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path }), + }), catalog))[0]); + const deniedText = stringify(denied); + expect(deniedText).toContain("await tools.exec_command"); + expect(deniedText).toContain("mcp_opencodex-responses_exec"); + expect(deniedText).toContain("Do not invent a top-level"); + expect(deniedText).toContain("silently call"); + expect(deniedText).not.toContain("mcp_opencodex-responses_*"); + + const deniedShell = decode((await handleCursorNativeExec(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "printf SHOULD_NOT_RUN", workingDirectory: dir, hardTimeout: 2000 }), + }), catalog))[0]); + const deniedShellText = stringify(deniedShell); + expect(deniedShellText).toContain("await tools.exec_command"); + expect(deniedShellText).toContain("Do not invent a top-level"); + expect(deniedShellText).toContain("silently call"); + }); + + test("rewrites denied native shell/read into a code-mode exec body", () => { + const catalog = { clientToolNames: ["exec"] }; + const shell = rewriteNativeExecToCodexBridge("shellArgs", { command: "pwd", toolCallId: "call_shell" }, catalog); + expect(shell.kind).toBe("exec"); + if (shell.kind === "exec") { + expect(shell.callId).toBe("call_shell"); + expect(shell.js).toContain("await tools.exec_command"); + expect(shell.js).toContain("pwd"); + } + const read = rewriteNativeExecToCodexBridge("readArgs", { path: "/tmp/note.txt" }, catalog); + expect(read.kind).toBe("exec"); + if (read.kind === "exec") { + expect(read.js).toContain("cat"); + expect(read.js).toContain("/tmp/note.txt"); + } + const untouched = rewriteNativeExecToCodexBridge("shellArgs", { command: "pwd" }, { clientToolNames: ["exec_command"] }); + expect(untouched.kind).toBe("none"); + }); + + test("rewritten native exec surfaces Codex exec then finalizes after drain (never cancels first)", () => { + const catalog = { clientToolNames: ["exec"] }; + const rewrite = rewriteNativeExecToCodexBridge("shellArgs", { command: "pwd", toolCallId: "call_shell" }, catalog); + expect(rewrite.kind).toBe("exec"); + if (rewrite.kind !== "exec") throw new Error("expected exec rewrite"); + const state = createCursorProtobufEventState({ clientToolNames: ["exec"] }); + const plan = planNativeExecRewrite(rewrite, state); + + // Immediate cancelCursorRun() sets expectedClose and skips the grace-timer `done`, so the + // rewritten exec result never returns on the next /v1/responses request. + expect(plan.handled).toBe(true); + expect(plan.cancelCursorRun).toBe(false); + expect(plan.finalizeWhenDrained).toBe(true); + expect(plan.events).toEqual([ + { type: "tool_call_start", id: "call_shell", name: "exec" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: rewrite.js }) }, + { type: "tool_call_end", id: "call_shell" }, + ]); + expect(plan.events.map(event => event.type)).not.toContain("done"); + expect(finalizeAfterDrain(state).map(event => event.type)).toEqual(["done"]); + }); + + test("rewritten native exec defers finalize while a sibling client tool is still open", () => { + const rewrite = rewriteNativeExecToCodexBridge( + "readArgs", + { path: "/tmp/note.txt", toolCallId: "call_read" }, + { clientToolNames: ["exec"] }, + ); + const state = createCursorProtobufEventState({ clientToolNames: ["exec", "echo_b"] }); + state.openToolCalls.set("call_b", { name: "echo_b", args: "" }); + const plan = planNativeExecRewrite(rewrite, state); + + expect(plan.handled).toBe(true); + expect(plan.cancelCursorRun).toBe(false); + expect(plan.finalizeWhenDrained).toBe(false); + expect(finalizeAfterDrain(state)).toEqual([]); + }); + + test("non-code-mode native exec is not rewritten (falls through to native handling)", () => { + const rewrite = rewriteNativeExecToCodexBridge("shellArgs", { command: "pwd" }, { clientToolNames: ["exec_command"] }); + const plan = planNativeExecRewrite(rewrite, createCursorProtobufEventState()); + expect(rewrite.kind).toBe("none"); + expect(plan.handled).toBe(false); + expect(plan.events).toEqual([]); + expect(plan.cancelCursorRun).toBe(false); + expect(plan.finalizeWhenDrained).toBe(false); + }); + }); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 45c674219b..3c1376e204 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -442,11 +442,14 @@ describe("Cursor code mode tool guidance", () => { if (!note) throw new Error("Expected Cursor tool guidance note"); expect(note).toContain("is Codex code mode"); + expect(note).toContain("mcp_opencodex-responses_exec"); expect(note).toContain("V8 isolate"); expect(note).toContain("await tools.(...)"); expect(note).toContain("await tools.exec_command({cmd: " + "\"" + "ls" + "\"" + "})"); expect(note).toContain("text(...)"); expect(note).toContain("There is no `require`"); + expect(note).toContain("silently retry through `exec`"); + expect(note).toContain("Do not invent a top-level `shell_command` / `exec_command` call"); // The flat-catalog shell-bridge guidance must NOT appear: naming a top-level // `exec_command` in code mode sends the model after a tool that does not exist.