--- a/src/adapters/cursor/native-exec.ts
+++ b/src/adapters/cursor/native-exec.ts
@@ -53,6 +53,8 @@
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;
@@ -72,6 +74,35 @@
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 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.
+ */
+export function cursorNativeExecRedirectHint(
+ tools: readonly Pick<OcxTool, "namespace" | "name">[] | 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, or write tool. Do NOT retry Read/Glob/Grep/LS/Shell/Write. "
+ + `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}_<name>\`; 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<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
@@ -634,15 +665,15 @@
}))];
}
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 === "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)];
}
if (execCase === "readArgs") return [readExec(execMsg)];
--- a/src/adapters/cursor/native-exec-fs.ts
+++ b/src/adapters/cursor/native-exec-fs.ts
@@ -49,11 +49,11 @@
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 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 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 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 @@
}));
}
-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 {
--- a/src/adapters/cursor/native-exec-shell.ts
+++ b/src/adapters/cursor/native-exec-shell.ts
@@ -82,7 +82,8 @@
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 @@
);
}
-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 @@
exitCode: 1,
signal: "",
stdout: "",
- stderr: nativeShellDisabledMessage(),
+ stderr: nativeShellDisabledMessage(hint),
executionTime: Date.now() - started,
aborted: true,
}),
@@ -116,10 +117,10 @@
});
}
-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 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 @@
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 @@
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) }) },
}));
}
@@ -520,10 +521,10 @@
}
}
-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) }) },
}));
}
--- a/src/adapters/cursor/live-transport.ts
+++ b/src/adapters/cursor/live-transport.ts
@@ -56,6 +56,7 @@
import { mcpArgsFromToolCall } from "./protobuf-events";
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
import {
+ cursorNativeExecRedirectHint,
handleCursorNativeExec,
handleCursorNativeKv,
releaseCursorBlobRequestScope,
@@ -697,6 +698,7 @@
clientToolDefs,
rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
+ nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools),
};
const toolSchemas = new Map<string, unknown>();
const cursorToolNameMap = new Map<string, string>();
Client or integration
Direct HTTP/API client (
POST /v1/responses). Also reproduced through a Responses-API TUI client (omo / senpi) that exposes only delegation tools to the model.Area
Provider adapter (Cursor)
Summary
When the Cursor adapter denies a Cursor-native fs/shell exec (
readArgs,lsArgs,grepArgs,shellArgs, … withnativeLocalExecunset/off), the refusal text is a fixed string that tells the model to re-issue the call throughshell_command/exec_command(src/adapters/cursor/native-exec-fs.tsNATIVE_LOCAL_EXEC_DISABLED,src/adapters/cursor/native-exec-shell.tsnativeShellDisabledMessage()).That wording is correct for a Codex-style catalog that carries a shell bridge. It is wrong for a client whose catalog has no shell/execution-path tool at all, e.g. an orchestrator client that only exposes a
task(delegate) tool.cursor/kimi-k3in particular takes the refusal literally: it looks forshell_command/exec_command, does not find them, and ends the turn with "the tools I was told to use are not in my tool list" instead of calling the client tool that is listed (ocx_client_task). Other routes with the same model and the same catalog delegate immediately (devin/kimi-k3: 2/2), so this is adapter wording, not the model or the client.Expected: when the catalog has no shell alias / execution path, the refusal should name the real catalog (
ocx_client_*wire names,mcp_opencodex-responses_*display form) so the model can recover. The adapter already gates the shell-alias system note oncursorRequestHasShellAlias(tool-guidance.ts/protobuf-request.ts), but the exec-channel refusal is not gated the same way.Reproduction
Minimal, no client involved. One function tool named
task, no shell tool, model routed through Cursor.Count how many responses contain a
function_calloutput item vs. a plain refusal message. Results on 2.53.0 (same request body, repeated):taskfunction_callcursor/kimi-k3(stock 2.53.0)devin/kimi-k3(control, same model)cursor/grok-4.6(control, same adapter)cursor/kimi-k3with the catalog-aware refusal belowThrough the actual TUI client (omo/senpi, model =
cursor/kimi-k3, tools =taskonly): 0 / 6 delegations on stock 2.53.0, 3 / 3 with the change below.ocx debug provider ondid not surface the exec-channel frames for this, so the evidence is the model's own final text (below), which quotes the refusal wording.Version
2.53.0 (npm latest as of 2026-09-14; the same
NATIVE_LOCAL_EXEC_DISABLEDstring is still onmain).Operating system
Ubuntu 24.04.4 LTS (kernel 6.8.0-101-generic), Node v24.18.1, bundled Bun 1.4.2.
Provider and model
cursor / kimi-k3 (Cursor Pro account). Controls: devin / kimi-k3, cursor / grok-4.6.
Logs or error output
Screenshots and supporting files
Local change I am running to confirm the diagnosis (against 2.53.0). Happy to turn this into a PR with regression tests if the approach is acceptable; note the published npm package ships no test files, so I could not run the existing suite locally.
Shape of the change:
CursorNativeExecContextgets an optionalnativeExecRedirectHint?: string.live-transport.tssets it per request from the finalcursorVisibleTools:undefinedwhencursorRequestHasShellAlias(tools) || cursorRequestHasExecutionPath(tools)(so Codex-style catalogs keep today's wording), otherwise a message that lists the catalog's real wire names and tells the model to call one of them.handleCursorNativeExecpasses the hint to everyreject*ExecForPolicy(read/write/delete/ls/grep/shell/shellStream/backgroundShellSpawn/writeShellStdin); the helpers usehint ?? <existing default string>.fetchis untouched.Hint text used (same "silent redirect" framing as the existing strings, no "blocked/denied" words):
Full diff against 2.53.0 (4 files)
Redacted configuration
{ "providers": { "cursor": { "type": "cursor", "nativeLocalExec": "(unset, i.e. off)" } }, "client": { "endpoint": "/v1/responses", "tools": ["task (type: function) — no shell_command / exec_command / apply_patch / exec in the catalog"], "tool_choice": "auto" } }Checks