Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -697,6 +698,7 @@ class LiveCursorTransport implements CursorTransport {
clientToolDefs,
rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs),
};
const toolSchemas = new Map<string, unknown>();
const cursorToolNameMap = new Map<string, string>();
Expand Down
20 changes: 10 additions & 10 deletions src/adapters/cursor/native-exec-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) },
}));
}

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

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/cursor/native-exec-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) },
}));
}

Expand Down
25 changes: 13 additions & 12 deletions src/adapters/cursor/native-exec-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -108,18 +109,18 @@ function rejectedShellResult(command: string, cwd: string, started: number) {
exitCode: 1,
signal: "",
stdout: "",
stderr: nativeShellDisabledMessage(),
stderr: nativeShellDisabledMessage(hint),
executionTime: Date.now() - started,
aborted: true,
}),
},
});
}

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 {
Expand Down Expand Up @@ -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());
Expand All @@ -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),
];
}
Expand Down Expand Up @@ -263,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8
return replies;
}

export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
if (execMsg.message.case !== "backgroundShellSpawnArgs") throw new Error("invalid background shell exec");
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: nativeShellDisabledMessage() }) },
result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage(hint) }) },
}));
}

Expand Down Expand Up @@ -520,10 +521,10 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage, sessionId:
}
}

export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage, hint?: string): 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: nativeShellDisabledMessage() }) },
result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage(hint) }) },
}));
}

Expand Down
61 changes: 51 additions & 10 deletions src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common";
import type { McpToolDefinition } from "./gen/agent_pb";
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
import { cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorToolWireName } from "./tool-naming";
import type { OcxTool } from "../../types";

export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDeps;

Expand All @@ -72,6 +74,45 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps {
rejectNativeFileMutations?: boolean;
/** The synthetic exact-match edit tools (edit_file / multi_edit) are advertised this request. */
structuredEditAvailable?: boolean;
/** Catalog-aware redirect text for denied native fs/shell attempts (undefined = default bridge wording). */
nativeExecRedirectHint?: string;
}

const REDIRECT_HINT_MAX_TOOLS = 16;

/**
* 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 — 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<OcxTool, "namespace" | "name">[] | undefined,
mcpToolDefs: readonly Pick<McpToolDefinition, "name" | "providerIdentifier">[] = [],
): string | 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_<provider>_<wire name>`; 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([
...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 (
Comment thread
001005HS marked this conversation as resolved.
`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}_<name>\`; 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."
);
}

export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
Expand Down Expand Up @@ -634,16 +675,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)];
Expand Down
12 changes: 12 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ 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 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_<wire name>`,
plus any configured MCP server tools advertised through `prepareMcp` as `mcp_<provider>_<name>`. 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)

Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool
Expand Down
Loading
Loading