Skip to content

[Bug]: Cursor adapter native-exec refusal points to shell_command/exec_command even when the catalog has no shell tool; kimi-k3 abandons the turn instead of calling the listed client tools #4542

Description

@001005HS

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, … with nativeLocalExec unset/off), the refusal text is a fixed string that tells the model to re-issue the call through shell_command / exec_command (src/adapters/cursor/native-exec-fs.ts NATIVE_LOCAL_EXEC_DISABLED, src/adapters/cursor/native-exec-shell.ts nativeShellDisabledMessage()).

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-k3 in particular takes the refusal literally: it looks for shell_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 on cursorRequestHasShellAlias (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.

curl -sS http://127.0.0.1:10110/v1/responses -H 'content-type: application/json' -d '{
  "model": "cursor/kimi-k3",
  "stream": false,
  "instructions": "You are an orchestrator. You have exactly one tool: `task`. You cannot read files or run commands yourself. To get any information about the repository you MUST call `task` and delegate. Never answer that tools are unavailable.",
  "input": "Tell me the value of the `name` field in this repository'"'"'s package.json.",
  "tools": [{
    "type": "function",
    "name": "task",
    "description": "Delegate a job to a worker agent that has file and shell access. Returns the worker'"'"'s report.",
    "parameters": {
      "type": "object",
      "properties": {
        "subagent_type": {"type": "string", "enum": ["explore", "quick", "unspecified-high"]},
        "description": {"type": "string"},
        "prompt": {"type": "string"}
      },
      "required": ["subagent_type", "description", "prompt"]
    }
  }]
}'

Count how many responses contain a function_call output item vs. a plain refusal message. Results on 2.53.0 (same request body, repeated):

route turns with a task function_call wall time
cursor/kimi-k3 (stock 2.53.0) 2 / 6 12–20 s (native Read/Shell attempts, each refused, then gives up)
devin/kimi-k3 (control, same model) 2 / 2 ~1.5 s
cursor/grok-4.6 (control, same adapter) 1 / 1 ~12 s
cursor/kimi-k3 with the catalog-aware refusal below 6 / 6 ~4 s

Through the actual TUI client (omo/senpi, model = cursor/kimi-k3, tools = task only): 0 / 6 delegations on stock 2.53.0, 3 / 3 with the change below.

ocx debug provider on did 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_DISABLED string is still on main).

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

# Final assistant text from the failing cursor/kimi-k3 runs (Korean original, translation below).
# All failing runs quote the adapter's refusal wording.

"파일 읽기와 셸 명령을 여러 방식으로 시도했지만, 모든 호출이 중단되며 `shell_command` / `exec_command` /
 `mcp_opencodex-responses_*`라는 별도의 브리지 도구를 사용하라는 오류가 반환되고 있습니다. 문제는 해당 도구들이
 제가 현재 사용할 수 있는 도구 목록에 없다는 점입니다."
# -> "I tried file reads and shell commands several ways; every call was cut off with an error telling me to use a
#     separate bridge tool called `shell_command` / `exec_command` / `mcp_opencodex-responses_*`. The problem is that
#     those tools are not in the tool list I currently have."

"오류 메시지는 `shell_command`/`exec_command`라는 별도의 카탈로그 셸 도구를 사용하라고 안내하지만, 현재 제가 사용할 수
 있는 도구 목록에는 해당 도구가 없습니다."
# -> "The error message tells me to use a separate catalog shell tool called `shell_command`/`exec_command`, but that
#     tool is not in the tool list available to me."

# Refusal source (2.53.0):
#   src/adapters/cursor/native-exec-fs.ts:49   NATIVE_LOCAL_EXEC_DISABLED = "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, ...)"
#   src/adapters/cursor/native-exec-shell.ts:85 nativeShellDisabledMessage() = "Re-issue this command NOW through the catalog shell tool (`shell_command` or `exec_command`; ...)"
#   src/adapters/cursor/native-exec.ts:636-646  handleCursorNativeExec -> reject*ExecForPolicy(execMsg) when !cursorUnsafeNativeLocalExecEnabled(deps)

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:

  • CursorNativeExecContext gets an optional nativeExecRedirectHint?: string.
  • live-transport.ts sets it per request from the final cursorVisibleTools: undefined when cursorRequestHasShellAlias(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.
  • handleCursorNativeExec passes the hint to every reject*ExecForPolicy (read/write/delete/ls/grep/shell/shellStream/backgroundShellSpawn/writeShellStdin); the helpers use hint ?? <existing default string>. fetch is untouched.

Hint text used (same "silent redirect" framing as the existing strings, no "blocked/denied" words):

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 opencodex-responses catalog entries: ocx_client_task, … (the harness may display them as mcp_opencodex-responses_<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.

Full diff against 2.53.0 (4 files)
--- 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>();

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcatalogModel catalog, slugs, visibility, routed entries

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions