From d0dae1187fa957ca90e49ed249656e9bad0eb1c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 12:50:21 +0900 Subject: [PATCH 1/3] fix(cursor): surface the data-policy action required instead of a bare failed_precondition An account that has not acknowledged the Fable data-retention policy got only "Cursor Connect error failed_precondition: Error". The upstream Connect end-stream frame carries details[] with type aiserver.v1.ErrorDetails and a base64 protobuf value naming the gate; parseConnectEndStreamError read code and message and discarded details entirely, so the user never learned what to approve or where. Recognize that one known gate from a bounded read-only projection of the protobuf (MODEL_BLOCKED plus the exact policy title and detail) and return code-owned text with Cursor's own dashboard review URL. Recognition is deliberately narrow: <=8 detail entries, <=16 KiB of base64 with a strict round-trip check, <=128 fields, <=256-byte strings, group wire types and duplicate fields refused. Unknown or malformed details keep today's exact generic Connect error. Nothing upstream is forwarded. The buttons, URLs, analytics, consent actions and the optional debug representation are all skipped rather than interpreted, so an attacker-controlled upstream string cannot reach the client or perturb downstream keyword classification. Accepting the policy remains a user action in Cursor, and the failure stays a non-retryable 400. Closes #4508 Co-authored-by: HeiTuz <79418013+eusine@users.noreply.github.com> --- src/adapters/cursor/cursor-errors.ts | 2 +- src/adapters/cursor/policy-error.ts | 75 +++++++++++++++++ .../cursor/cursor-live-transport.test.ts | 80 +++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/adapters/cursor/policy-error.ts diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index fba44b2f99..c06039e467 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -298,7 +298,7 @@ export function classifyCursorError(message: string, sizeContext?: CursorSizeCon ) return "Cursor authentication failed"; // gRPC FAILED_PRECONDITION is deterministic and non-retryable (unlike UNAVAILABLE): - // the backend rejected the call because the account/plan state does not allow it — + // the backend rejected the call because account/plan or policy-consent state does not allow it — // seen live when a plan-gated model (e.g. claude-fable-5) runs on a plan without it. // Leaving it as "Cursor upstream error" (502) made clients retry it as overload. // diff --git a/src/adapters/cursor/policy-error.ts b/src/adapters/cursor/policy-error.ts new file mode 100644 index 0000000000..060ea586d9 --- /dev/null +++ b/src/adapters/cursor/policy-error.ts @@ -0,0 +1,75 @@ +import { BinaryReader, WireType } from "@bufbuild/protobuf/wire"; + +const POLICY_TITLE = "Review Data Policy"; +const POLICY_DETAIL = "You must acknowledge Claude Fable 5's data retention policy to use the model."; +const POLICY_REVIEW_URL = "https://cursor.com/dashboard/restricted_models/claude-fable-5"; +const MAX_VALUE_CHARS = 16_384; +const MAX_FIELDS = 128; + +/** + * Minimal read-only projection of Cursor's aiserver.v1.ErrorDetails: + * error=1 (MODEL_BLOCKED=58), details=2; CustomErrorDetails title=1, detail=2. + * Verified against the native CLI schema and the #4508 Connect binary response. + * Skip buttons, URLs, analytics and dashboardAction rather than interpreting them. + */ +function isFablePolicyError(bytes: Uint8Array, custom = false): boolean { + const reader = new BinaryReader(bytes); + let error: number | undefined; + let details: Uint8Array | undefined; + let title: string | undefined; + let detail: string | undefined; + let fields = 0; + while (reader.pos < reader.len) { + if (++fields > MAX_FIELDS) return false; + const [field, wire] = reader.tag(); + // Groups are not part of this proto3 projection; avoid recursive unknown-field skips. + if (wire === WireType.StartGroup || wire === WireType.EndGroup) return false; + if (!custom && field === 1) { + if (wire !== WireType.Varint || error !== undefined) return false; + error = reader.uint32(); + } else if (!custom && field === 2) { + if (wire !== WireType.LengthDelimited || details !== undefined) return false; + details = reader.bytes(); + } else if (custom && (field === 1 || field === 2)) { + if (wire !== WireType.LengthDelimited) return false; + const value = reader.bytes(); + if (value.length > 256) return false; + const text = new TextDecoder("utf-8", { fatal: true }).decode(value); + if (field === 1) { + if (title !== undefined) return false; + title = text; + } else { + if (detail !== undefined) return false; + detail = text; + } + } else { + reader.skip(wire); + } + } + return custom + ? title === POLICY_TITLE && detail === POLICY_DETAIL + : error === 58 && details !== undefined && isFablePolicyError(details, true); +} + +/** Recognize this policy gate, but never forward arbitrary upstream text or consent actions. */ +export function cursorPolicyErrorExplanation(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const envelope = error as { code?: unknown; details?: unknown }; + if (envelope.code !== "failed_precondition" || !Array.isArray(envelope.details)) return undefined; + for (const entry of envelope.details.slice(0, 8)) { + if (!entry || typeof entry !== "object") continue; + const { type, value } = entry as { type?: unknown; value?: unknown }; + if (type !== "aiserver.v1.ErrorDetails" || typeof value !== "string" + || value.length === 0 || value.length > MAX_VALUE_CHARS + || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) continue; + try { + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) continue; + if (isFablePolicyError(bytes)) { + // Code-owned copy cannot inject credentials or alter downstream keyword classification. + return `${POLICY_TITLE}: ${POLICY_DETAIL} Review and accept using the same Cursor account at ${POLICY_REVIEW_URL}, then retry.`; + } + } catch { /* Unknown/malformed details retain the existing generic Connect error. */ } + } + return undefined; +} diff --git a/tests/providers/cursor/cursor-live-transport.test.ts b/tests/providers/cursor/cursor-live-transport.test.ts index 134fcad1c4..ce82f7d5a9 100644 --- a/tests/providers/cursor/cursor-live-transport.test.ts +++ b/tests/providers/cursor/cursor-live-transport.test.ts @@ -2,10 +2,14 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { BinaryWriter } from "@bufbuild/protobuf/wire"; import { afterEach, describe, expect, test } from "bun:test"; import { createLiveCursorTransport, CursorMissingCredentialError, parseConnectEndStreamError, resolveCursorToken } from "../../../src/adapters/cursor/live-transport"; +import { safeCursorErrorMessage } from "../../../src/adapters/cursor/cursor-errors"; +import { isRetryableCursorError } from "../../../src/adapters/cursor/transport-retry"; import { createTestTranslatorBudget } from "../../helpers/translator-budget"; import { CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, prepareCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { classifyError, inferHttpStatusFromAdapterMessage } from "../../../src/lib/errors"; import { estimateTokens } from "../../../src/lib/token-estimate"; import type { OcxMessage } from "../../../src/types"; import type { CursorRunRequest } from "../../../src/adapters/cursor/types"; @@ -26,6 +30,82 @@ import { import { AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, ConversationStateStructureSchema, ExecServerMessageSchema, GetBlobArgsSchema, KvServerMessageSchema, type AgentRunRequest } from "../../../src/adapters/cursor/gen/agent_pb"; import type { CursorProtobufEventState } from "../../../src/adapters/cursor/protobuf-events"; +describe("Cursor Fable policy gate details (#4508)", () => { + const title = "Review Data Policy"; + const detail = "You must acknowledge Claude Fable 5's data retention policy to use the model."; + const reviewUrl = "https://cursor.com/dashboard/restricted_models/claude-fable-5"; + function binary(overrides: { title?: string; detail?: string; error?: number; extras?: Uint8Array } = {}): string { + const custom = new BinaryWriter().uint32(10).string(overrides.title ?? title) + .uint32(18).string(overrides.detail ?? detail) + .uint32(32).bool(false).uint32(40).bool(false); + if (overrides.extras) custom.raw(overrides.extras); + return Buffer.from(new BinaryWriter().uint32(8).uint32(overrides.error ?? 58) + .uint32(18).bytes(custom.finish()).uint32(24).bool(true).finish()).toString("base64"); + } + function parse(details: unknown, code = "failed_precondition", message = "Error") { + return parseConnectEndStreamError(new TextEncoder().encode(JSON.stringify({ error: { code, message, details } })))!; + } + function entry(value = binary()) { return { type: "aiserver.v1.ErrorDetails", value }; } + const fallback = "Cursor Connect error failed_precondition: Error"; + + test("binary without debug produces the review path and preserves 400/non-retryable behaviour", () => { + const error = parse([entry()]); + const message = safeCursorErrorMessage(error.message); + expect(message).toContain(title); + expect(message).toContain(detail); + expect(message).toContain(reviewUrl); + expect(message.length).toBeLessThan(500); + expect(inferHttpStatusFromAdapterMessage(message)).toBe(400); + expect(classifyError(400, "", message)).toMatchObject({ type: "invalid_request_error", code: "invalid_request_error" }); + expect(isRetryableCursorError(error)).toBe(false); + }); + + test("does not forward upstream messages, buttons, actions, URLs or debug text", () => { + const extras = new BinaryWriter().uint32(66).bytes(new TextEncoder().encode("secret-token consent-action https://untrusted.invalid")) + .uint32(82).bytes(new TextEncoder().encode("private-analytics")).finish(); + const error = parse([{ ...entry(binary({ extras })), debug: { title: "rate limit Bearer secret-token" } }], "failed_precondition", "Bearer another-secret"); + expect(error.message).toContain(reviewUrl); + for (const text of ["secret-token", "another-secret", "consent-action", "untrusted.invalid", "private-analytics", "rate limit"]) { + expect(error.message).not.toContain(text); + } + }); + + test("does not trust debug in place of the binary value", () => { + expect(parse([{ type: "aiserver.v1.ErrorDetails", debug: { error: "ERROR_MODEL_BLOCKED", details: { title, detail } } }]).message).toBe(fallback); + }); + + test("unknown type, error kind, policy text and other Connect codes retain generic behaviour", () => { + expect(parse([{ type: "other.ErrorDetails", value: binary() }]).message).toBe(fallback); + expect(parse([entry(binary({ error: 1 }))]).message).toBe(fallback); + expect(parse([entry(binary({ title: "Another policy" }))]).message).toBe(fallback); + expect(parse([entry(binary({ detail: "rate limit or quota exhausted" }))]).message).toBe(fallback); + expect(parse([entry()], "resource_exhausted").message).toBe("Cursor Connect error resource_exhausted: Error"); + expect(parse([entry()], "unauthenticated").message).toBe("Cursor Connect error unauthenticated: Error"); + }); + + test("malformed and oversized protobuf/base64 values fall back without throwing", () => { + for (const value of ["!invalid!", "", "Cg==", "A".repeat(16385), "Cg////8P", "Cw==", "AA==", "____"]) { + expect(parse([entry(value)]).message).toBe(fallback); + } + expect(parse([entry(binary({ title: "a".repeat(257) }))]).message).toBe(fallback); + expect(parse([entry(binary({ extras: new Uint8Array([0x0a, 0x00]) }))]).message).toBe(fallback); + expect(parse([entry(binary({ extras: new Uint8Array([0x0b]) }))]).message).toBe(fallback); + }); + + test("limits scanned fields and entries and skips unrecognized entries", () => { + const extras = new BinaryWriter(); + for (let i = 0; i < 129; i++) extras.uint32(80).uint32(0); + expect(parse([entry(binary({ extras: extras.finish() }))]).message).toBe(fallback); + expect(parse([null, {}, entry()]).message).toContain(reviewUrl); + expect(parse([...Array(8).fill(null), entry()]).message).toBe(fallback); + for (const details of [null, {}, "not-an-array"]) expect(parse(details).message).toBe(fallback); + }); + + test("accepts equivalent unpadded base64", () => { + expect(parse([entry(binary().replace(/=+$/, ""))]).message).toContain(reviewUrl); + }); +}); + class TransportFakeChild extends EventEmitter { readonly stdin = new PassThrough(); readonly stdout = new PassThrough(); From 633865fb278d492cf6963a02c0fccf7d236d1e99 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 12:50:21 +0900 Subject: [PATCH 2/3] fix(cursor): name the real catalog in native-exec denials when no shell bridge exists With nativeLocalExec off, every denied Cursor-native fs/shell/fetch exec returned a fixed string telling the model to re-issue through shell_command / exec_command. That is right for a Codex-style catalog carrying a shell bridge and wrong for a client whose catalog has no execution path at all. cursor/kimi-k3 takes it literally: it looks for the named bridge, does not find it, and ends the turn reporting that the tools it was told to use are missing, instead of calling the delegation tool that is listed. The shell-alias system note is already gated on cursorRequestHasShellAlias; the exec-channel refusal was not. Add cursorNativeExecRedirectHint: when the turn's visible catalog carries neither a shell alias nor an execution path, build a redirect that names the request's actual wire names, client tools as ocx_client_* or their namespaced form and configured MCP tools as mcp__, capped at 16 with a (+N more) suffix. Every reject*ExecForPolicy helper takes the hint and falls back to its existing text, so a catalog that does carry exec_command or shell_command is byte-identical to before. The wording stays neutral about capability and never asserts the catalog lacks a read, grep or fetch tool, so a listed MCP read_file is not contradicted, and it keeps the silent-redirect vocabulary contract. Closes #4542 Co-authored-by: 001005HS <99410048+001005HS@users.noreply.github.com> --- src/adapters/cursor/native-exec-fs.ts | 20 +-- src/adapters/cursor/native-exec-network.ts | 4 +- src/adapters/cursor/native-exec-shell.ts | 25 +-- src/adapters/cursor/native-exec.ts | 61 +++++-- .../cursor/cursor-native-exec-policy.test.ts | 153 +++++++++++++++++- 5 files changed, 227 insertions(+), 36 deletions(-) diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 265bfc7b92..2558f91fac 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -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 }) }, })); } @@ -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.` }), }, })); } @@ -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.` }), }, })); } @@ -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 }) }, })); } @@ -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 { diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index f259f284ef..49f1142e97 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -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 }) }, })); } diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 6fb7688302..411aa7f07c 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -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 @@ -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", @@ -108,7 +109,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: nativeShellDisabledMessage(), + stderr: nativeShellDisabledMessage(hint), executionTime: Date.now() - started, aborted: true, }), @@ -116,10 +117,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) { }); } -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 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()); @@ -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), ]; } @@ -263,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise[] | undefined, + mcpToolDefs: readonly Pick[] = [], +): 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__`; 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 ( + `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}_\`; 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 = {}): boolean { @@ -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)]; diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 24b63e28ea..4fddf2e533 100644 --- a/tests/providers/cursor/cursor-native-exec-policy.test.ts +++ b/tests/providers/cursor/cursor-native-exec-policy.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { create, fromBinary } from "@bufbuild/protobuf"; @@ -12,13 +12,22 @@ import { import { AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, + DeleteArgsSchema, ExecServerMessageSchema, FetchArgsSchema, + GrepArgsSchema, + LsArgsSchema, ReadArgsSchema, ShellArgsSchema, + WriteArgsSchema, + WriteShellStdinArgsSchema, } from "../../../src/adapters/cursor/gen/agent_pb"; -import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; import { + cursorNativeExecRedirectHint, + handleCursorNativeExec, +} from "../../../src/adapters/cursor/native-exec"; +import { + nativeShellDisabledMessage, resetBackgroundShellStateForTests, setBackgroundShellRuntimeForTests, } from "../../../src/adapters/cursor/native-exec-shell"; @@ -384,3 +393,143 @@ describe("Cursor native exec sandbox policy", () => { }); }); + +/** + * A delegation-only client (an orchestrator that exposes nothing but its own Responses tools — + * no shell bridge, no unified exec) still gets Cursor-native Read/Shell attempts from the model. + * The default denial steers the model to `shell_command` / `exec_command`; when those are not in + * the catalog the model concludes every tool is unavailable and gives up. The hint names the + * catalog that actually exists instead. + */ +describe("Cursor native exec catalog-aware redirect hint", () => { + const SILENT_REDIRECT_FORBIDDEN = [/blocked/i, /\bdisabled\b/i, /not executed/i, /\bdenied\b/i, /cannot execute/i, /차단/]; + type CatalogTool = { name: string; namespace?: string; freeform?: boolean }; + const delegationOnlyCatalog: CatalogTool[] = [{ name: "task" }, { name: "ask_user" }]; + + function stringifyReplies(replies: Uint8Array[]): string { + return replies.map(bytes => stringify(fromBinary(AgentClientMessageSchema, bytes))).join("\n"); + } + + test("names the request's client wire names when the catalog has no shell bridge or execution path", () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`ocx_client_ask_user`"); + expect(hint).toContain("mcp_opencodex-responses_"); + expect(hint).toContain("Do NOT narrate"); + expect(hint).not.toContain("shell_command"); + expect(hint).not.toContain("exec_command"); + // Neutral about capabilities: a listed file/search/fetch tool must never be contradicted. + expect(hint).not.toMatch(/no (shell|read|grep|ls|write|fetch) tool/i); + expect(hint).not.toMatch(/ONLY callable/i); + for (const pattern of SILENT_REDIRECT_FORBIDDEN) expect(hint).not.toMatch(pattern); + }); + + test("names configured MCP tools advertised for the turn by their harness display form", () => { + const hint = cursorNativeExecRedirectHint( + [{ name: "task" }], + [{ name: "read_file", providerIdentifier: "opencodex" }], + ) ?? ""; + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`mcp_opencodex_read_file`"); + // No client tools at all, but configured MCP tools: those are the catalog, so name them. + const mcpOnly = cursorNativeExecRedirectHint(undefined, [{ name: "read_file", providerIdentifier: "opencodex" }]) ?? ""; + expect(mcpOnly).toContain("`mcp_opencodex_read_file`"); + expect(mcpOnly).not.toContain("ocx_client_"); + expect(mcpOnly).not.toContain("shell_command"); + // Nothing advertised anywhere keeps the default bridge wording. + expect(cursorNativeExecRedirectHint(undefined, [])).toBeUndefined(); + expect(cursorNativeExecRedirectHint([], [])).toBeUndefined(); + }); + + test.each<[string, CatalogTool[] | undefined]>([ + ["an undefined catalog", undefined], + ["an empty catalog", []], + ["a bare exec_command bridge", [{ name: "exec_command" }]], + ["a bare shell_command bridge next to client tools", [{ name: "task" }, { name: "shell_command" }]], + ["unified exec next to client tools", [{ name: "task" }, { name: "exec", freeform: true }]], + ])("keeps the default bridge wording for %s", (_name, tools) => { + expect(cursorNativeExecRedirectHint(tools)).toBeUndefined(); + }); + + test("lists namespaced tools by wire name and caps a long catalog", () => { + const hint = cursorNativeExecRedirectHint([{ namespace: "mcp__docker", name: "ps" }, { name: "task" }]) ?? ""; + expect(hint).toContain("`mcp__docker__ps`"); + expect(hint).toContain("`ocx_client_task`"); + const capped = cursorNativeExecRedirectHint(Array.from({ length: 20 }, (_, index) => ({ name: `tool_${index}` }))) ?? ""; + expect(capped).toContain("`ocx_client_tool_15`"); + expect(capped).not.toContain("`ocx_client_tool_16`"); + expect(capped).toContain("(+4 more)"); + }); + + test("without a hint the bridge wording is unchanged", () => { + expect(nativeShellDisabledMessage()).toContain("shell_command"); + expect(nativeShellDisabledMessage("custom hint")).toBe("custom hint"); + }); + + test("every denied native fs, shell, and fetch frame carries the hint and executes nothing", async () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-hint-")); + const existing = join(dir, "grounding.txt"); + const content = "HINT-GROUNDING-01 must not leak"; + writeFileSync(existing, content); + const newPath = join(dir, "must-not-exist.txt"); + let fetchCalled = false; + const deps = { + unsafeAllowNativeLocalExec: false, + nativeExecRedirectHint: hint, + fetch: async () => { + fetchCalled = true; + return new Response("SHOULD_NOT_FETCH"); + }, + }; + const frames = [ + execMessage({ case: "readArgs", value: create(ReadArgsSchema, { path: existing }) }), + execMessage({ case: "lsArgs", value: create(LsArgsSchema, { path: dir }) }), + execMessage({ case: "grepArgs", value: create(GrepArgsSchema, { pattern: "HINT", path: dir }) }), + execMessage({ case: "writeArgs", value: create(WriteArgsSchema, { path: newPath, fileText: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "deleteArgs", value: create(DeleteArgsSchema, { path: existing }) }), + execMessage({ case: "shellArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir, hardTimeout: 2000 }) }), + execMessage({ case: "shellStreamArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "backgroundShellSpawnArgs", value: create(BackgroundShellSpawnArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "writeShellStdinArgs", value: create(WriteShellStdinArgsSchema, { shellId: 999, chars: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "fetchArgs", value: create(FetchArgsSchema, { url: "https://metadata.invalid/latest" }) }), + ]; + for (const frame of frames) { + const text = stringifyReplies(await handleCursorNativeExec(frame, deps)); + expect(text).toContain("`ocx_client_task`"); + expect(text).toContain("Do NOT narrate"); + expect(text).not.toContain("shell_command"); + expect(text).not.toContain("exec_command"); + expect(text).not.toContain(content); + // Denied shell frames echo the command text; only an executed command could produce the joined marker. + expect(text).not.toContain("RAN_MARKER"); + expect(text).not.toContain("SHOULD_NOT_WRITE"); + expect(text).not.toContain("SHOULD_NOT_FETCH"); + } + expect(fetchCalled).toBe(false); + expect(existsSync(existing)).toBe(true); + expect(existsSync(newPath)).toBe(false); + }); + + // The hint only helps if the live transport actually derives it per request. Asserting that + // through LiveCursorTransport means stubbing a private method, which pins a seam rather than + // the production path; read the production path instead. Both carried contributor PRs were + // drafts whose hosted suite never ran, so nothing else proves this line exists. + test("the live transport derives the hint from each turn's visible catalog", async () => { + const { repoPath } = await import("../../helpers/repo-root"); + const source = readFileSync(repoPath("src/adapters/cursor/live-transport.ts"), "utf8"); + const assignment = source.match(/nativeExecRedirectHint:\s*cursorNativeExecRedirectHint\(([^)]*)\)/)?.[1]; + expect(assignment).toBeDefined(); + // Derived from THIS turn's visible catalog and advertised MCP tools, not from the raw request + // or a value cached across turns: a catalog that gains or loses a shell alias must re-derive. + expect(assignment).toContain("cursorVisibleTools"); + expect(assignment).toContain("mcpToolDefs"); + // Inside the per-request execContext assignment, not module or constructor scope. + const perRequest = source.indexOf("rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch"); + const hint = source.indexOf("nativeExecRedirectHint: cursorNativeExecRedirectHint"); + expect(perRequest).toBeGreaterThan(-1); + expect(Math.abs(hint - perRequest)).toBeLessThan(400); + }); +}); From c19cdde9f2b72304757ff170e3cbcb303376eb20 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 12:50:21 +0900 Subject: [PATCH 3/3] fix(cursor): derive the native-exec redirect hint per request and document both surfaces Wire cursorNativeExecRedirectHint into the per-request exec context from that turn's visible catalog and advertised MCP tools, so a catalog that gains or loses a shell alias between turns re-derives rather than reusing a stale hint, and prefer the recognized policy explanation in parseConnectEndStreamError. Co-authored-by: HeiTuz <79418013+eusine@users.noreply.github.com> Co-authored-by: 001005HS <99410048+001005HS@users.noreply.github.com> --- docs-site/src/content/docs/reference/adapters.md | 10 ++++++++++ src/adapters/cursor/live-transport.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1f480bc109..715555f847 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -414,6 +414,16 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and and `desktopExecutor` integrations have separate opt-ins; `nativeLocalExec: "on"` enables the broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. +- The denial reply is a silent redirect whose wording follows the request catalog. A catalog that + carries `shell_command`/`exec_command` or a unified `exec` keeps the bridge wording; a catalog + that carries neither — an orchestrator client exposing only its own Responses tools, for example — + is redirected to the request's actual wire names, so the model is pointed at a tool that exists + rather than at an alias it cannot see. +- A recognized Cursor data-policy gate is reported with its title, the action it requires, and the + Cursor Dashboard review URL instead of a bare `failed_precondition: Error`. Recognition is limited + to the known structured detail: unknown or malformed details keep the generic Connect error, no + upstream text, button, URL, or consent action is forwarded or executed, and the failure stays + non-retryable. Reviewing and accepting a data policy remains a user action in Cursor itself. Codex-compatible shell schemas retain sandbox permissions, justification, reusable prefix rules and login mode. Freeform tools expose one required string `input` diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 2d6385ae77..cff471ba0d 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -53,9 +53,11 @@ import { } from "./gen/agent_pb"; import { debugProviderDiagnostic } from "../../lib/debug"; import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors"; +import { cursorPolicyErrorExplanation } from "./policy-error"; import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { + cursorNativeExecRedirectHint, handleCursorNativeExec, handleCursorNativeKv, releaseCursorBlobRequestScope, @@ -209,7 +211,8 @@ export function parseConnectEndStreamError(payload: Uint8Array): Error | null { try { const parsed = JSON.parse(new TextDecoder().decode(payload)) as { error?: { code?: string; message?: string } }; if (parsed?.error) { - return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${parsed.error.message ?? "Unknown error"}`); + const explanation = cursorPolicyErrorExplanation(parsed.error); + return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${explanation ?? parsed.error.message ?? "Unknown error"}`); } return null; } catch { @@ -697,6 +700,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(); const cursorToolNameMap = new Map();