diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 27c07fd71..cda058fca 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -23,8 +23,20 @@ import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common const backgroundShells = new Map(); let nextShellId = 1; -const NATIVE_SHELL_DISABLED = - "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 with the same command."; +/** 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." + ); +} function rejectedShellResult(command: string, cwd: string, started: number) { return create(ShellResultSchema, { @@ -36,7 +48,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: NATIVE_SHELL_DISABLED, + stderr: nativeShellDisabledMessage(), executionTime: Date.now() - started, aborted: true, }), @@ -95,7 +107,7 @@ 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: NATIVE_SHELL_DISABLED }) }, + event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) }, @@ -196,7 +208,7 @@ export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessa const args = execMsg.message.value; const cwd = resolve(args.workingDirectory || process.cwd()); return execBytes(execMsg, "backgroundShellSpawnResult", create(BackgroundShellSpawnResultSchema, { - result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage() }) }, })); } @@ -233,7 +245,7 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage): Uint8Array export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "writeShellStdinArgs") throw new Error("invalid shell stdin exec"); return execBytes(execMsg, "writeShellStdinResult", create(WriteShellStdinResultSchema, { - result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage() }) }, })); } diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 274cda914..7688f209a 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -417,6 +417,11 @@ export function buildCursorToolGuidanceSystemNote( const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice); const discoveryTools = discoveryToolLabel(wireNames); const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames); + // Host-shell-neutral: the Codex client executes bridge commands, and may differ from + // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls. + const hostShellNote = hasBareExec + ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`< typeof note === "string"); return notes.join(" "); diff --git a/tests/cursor-native-exec-policy.test.ts b/tests/cursor-native-exec-policy.test.ts index e745cdec3..80ecee7c7 100644 --- a/tests/cursor-native-exec-policy.test.ts +++ b/tests/cursor-native-exec-policy.test.ts @@ -142,6 +142,12 @@ describe("Cursor native exec sandbox policy", () => { expect(deniedShellText).toContain("exec_command"); expect(deniedShellText).toContain("mcp_opencodex-responses_*"); expect(deniedShellText).toContain("Do not tell the user"); + expect(deniedShellText).not.toContain("with the same command"); + expect(deniedShellText).toContain("at most one corrected bridge attempt"); + expect(deniedShellText).toContain("if ($?)"); + expect(deniedShellText).toContain("`&&`/`||` are unsupported parser errors"); + expect(deniedShellText).toContain("do not treat `;` as a substitute for `&&`"); + expect(deniedShellText).toContain("Windows PowerShell 5.1"); expect(deniedShellText).not.toContain("disabled by OpenCodex policy"); expect(deniedShellText).not.toContain("sandbox denial"); expect(deniedShell.message.case).toBe("shellResult"); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 652a93515..d418b304b 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -324,6 +324,23 @@ describe("Cursor tool definitions", () => { expect(note).toContain("Never tell the user that shell or read access is blocked"); }); + test("adds host-shell-neutral PowerShell and one-retry-stop guidance (#604)", () => { + const note = buildCursorToolGuidanceSystemNote([{ name: "shell_command", description: "Run", parameters: {} }]); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + + expect(note).toContain("Windows PowerShell 5.1"); + expect(note).toContain("cd /d"); + expect(note).toContain("< { const tools: OcxTool[] = [ { name: "exec_command", description: "Run", parameters: {} },