Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/adapters/cursor/native-exec-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,20 @@ import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common
const backgroundShells = new Map<number, { child: ChildProcessWithoutNullStreams; outputLength: number }>();
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."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

function rejectedShellResult(command: string, cwd: string, started: number) {
return create(ShellResultSchema, {
Expand All @@ -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,
}),
Expand Down Expand Up @@ -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 }) },
Expand Down Expand Up @@ -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() }) },
}));
}

Expand Down Expand Up @@ -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() }) },
}));
}

Expand Down
8 changes: 7 additions & 1 deletion src/adapters/cursor/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<<EOF`); `&&`/`||` 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 `&&`. POSIX: use portable commands. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
: undefined;
const notes = [
`Cursor tool calls: available tool names are exactly ${listedNames}.`,
"Use the current tool catalog as ground truth and call only those exact names with their listed argument keys.",
Expand All @@ -432,6 +437,7 @@ export function buildCursorToolGuidanceSystemNote(
hasBareExec
? "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Prefer the bridge over Cursor-native Shell/Read; do not narrate phrases like \"Native shell access is blocked\" — silently call `shell_command` / `exec_command`."
: undefined,
hostShellNote,
"Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.",
hasBareExec
? `For file read/search/listing, use ${shellBridgeLabel} when no more specific listed tool is available.`
Expand All @@ -451,7 +457,7 @@ export function buildCursorToolGuidanceSystemNote(
: undefined,
"Do not count or report a tool call unless a tool result was actually returned.",
hasBareExec
? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
: undefined,
].filter((note): note is string => typeof note === "string");
return notes.join(" ");
Expand Down
6 changes: 6 additions & 0 deletions tests/cursor-native-exec-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(deniedShellText).not.toContain("disabled by OpenCodex policy");
expect(deniedShellText).not.toContain("sandbox denial");
expect(deniedShell.message.case).toBe("shellResult");
Expand Down
17 changes: 17 additions & 0 deletions tests/cursor-tool-definitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<<EOF");
expect(note).toContain("if ($?)");
expect(note).toContain("`&&`/`||` are unsupported parser errors");
expect(note).toContain("do not treat `;` as a substitute for `&&`");
expect(note).toContain("at most one corrected bridge attempt");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(note).toContain("Get-Content");
expect(note).toContain("`cat`/`ls`/`rg`");
expect(note).toContain("Codex client host");
});

test("adds codex-native edit guidance only when apply_patch is advertised", () => {
const tools: OcxTool[] = [
{ name: "exec_command", description: "Run", parameters: {} },
Expand Down
Loading