From 7544a38e3859e23520b610c8d1a794de3a20f816 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sat, 15 Aug 2026 20:39:25 +0900 Subject: [PATCH 1/2] fix(agent,ai): repair benign snapshot roots and seal the cursor payload leak The #4580 circuit breaker surfaced local snapshot failures once, but the producers that trip the circuit were still live. Two root causes are fixed so the circuit does not fire on benign payloads at all: packages/agent: a payload class carrying assistant/message-event fields on prototype getters clones into an empty record (structuredClone copies only own enumerable properties), so the live role/type checks passed while the detached snapshot lost the identity and deterministically failed as shell.role / event.unknownType. The shell and event snapshots now repair such roots (and readable proxies) through the existing guarded-read path, and the run-loop message_update replay normalizes through the managed event snapshot instead of a naive spread that dropped prototype-carried fields. packages/ai: cursor native tool calls attached raw protobuf-es payloads (bigint fields, $typeName markers, byte arrays) as toolCall arguments, defeating JSON.stringify in snapshot staging, transcript persistence, and replay. Arguments are now converted to plain JSON-safe data at the provider boundary. Lore-id: 61d94fea Constraint: hostile shapes (throwing get traps, sentinel-degraded content, non-string event types) keep named fail-fast diagnostics with no retry authority Constraint: repair reads stay guarded (managedProperty) so a hostile trap can only degrade a field to undefined Rejected: widening the sanitizer to accept unserializable staged values | hides producer defects behind lossy placeholders Rejected: repairing hostile get-trap proxies | unreadable roots must not gain retry authority Confidence: high Scope-risk: medium Reversibility: clean Tested: payload-class end-to-end managed run; descriptor-trap proxy repair; cursor protobuf argument conversion; full agent suite (811 pass) Not-tested: live Cursor provider session --- packages/agent/CHANGELOG.md | 2 + packages/agent/src/agent-loop.ts | 62 +++++++--- .../test/managed-attempt-transaction.test.ts | 114 ++++++++++++++++-- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cursor.ts | 64 +++++++++- ...cursor-native-toolcall-json-safety.test.ts | 78 ++++++++++++ 6 files changed, 291 insertions(+), 30 deletions(-) create mode 100644 packages/ai/test/cursor-native-toolcall-json-safety.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 25b07cd334..b432493352 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Managed snapshot machinery no longer fails runs on benign payload-class or readable-proxy roots: an assistant message or stream event whose fields live on prototype getters (which `structuredClone` drops — it copies only own enumerable properties) or behind a proxy whose gets are readable is repaired through the existing guarded-read path instead of throwing a deterministic `shell.role`/`event.unknownType`/`event.snapshot` local snapshot failure. The run-loop message_update replay also builds its event through the managed event snapshot instead of a naive `{ ...event }` spread, which silently dropped prototype-carried fields before the snapshot boundary could see them. Hostile shapes (throwing get traps, sentinel-marked degraded content, malformed non-string event types) keep their named fail-fast diagnostics with no retry authority. + - Managed fallback now validates and byte-measures the detached event snapshot rather than trusting the live payload's JSON result. Custom payload classes whose prototype `toJSON()` hides bigint state are sanitized after `structuredClone` removes that serializer, so every accepted snapshot stays detached, JSON-serializable, and bounded; residual typed `local_snapshot_failure` diagnostics remain outside provider fallback authority and surface without deterministic retry amplification. - Managed fallback buffer overflows now retain a typed `local_buffer_overflow` error kind on the terminal assistant message, so session retry policy surfaces them immediately without provider-fallback attribution instead of admitting them to the bounded `unknown` retry class. - Managed local-failure diagnostics: `ManagedAttemptSnapshotError` and `ManagedAttemptBufferOverflowError` now carry a stable `stage` discriminator naming the exact rejecting site (`shell.role`, `shell.content`, `event.snapshot`, `event.contentIndex`, `event.delta`, `event.content`, `event.toolcall`, `event.done.reason`, `event.error.reason`, `event.unknownType`, `staging.losslessSnapshot`, `staging.measure`, `staging.sanitize`, `staging.overflow`, `overflow.preMeasure`, `overflow.staged`), and the run-loop failure boundary emits ONE bounded shape-only `logger.warn` per stream invocation (stage, error kind, model, provider, snapshot mode, staged event count/bytes, and content block count for the content stage). The diagnostic is gated on the module-private local error identities and its stage is whitelisted against the closed vocabulary, so neither a foreign error that self-labels a local failure kind nor an in-module regression can route arbitrary text into the log; it never records raw text, thinking, tool arguments, or any provider payload, and the user-facing message string is unchanged so session-side classification keeps matching. Previously all 14 rejecting sites shared one static message, leaving no way to identify which provider shape a normalizer must be taught to accept. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index dfce803bbc..976edd93c0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -923,13 +923,20 @@ function losslessDetachedClone(value: T): T { */ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage { const detailed = managedAttemptSnapshotDetailed(value); - const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value; - // A root that could not be snapshotted into a plain record is a live - // Proxy (the sanitizer collapses proxies to a placeholder). Benign - // proxy-wrapped provider messages are repaired by reading through the - // proxy — the provider's own view — with every read guarded so a hostile - // trap can only degrade to undefined, never escape. `managedProperty` - // is exactly that guarded read. + const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined; + // Two benign root degradations are repaired by reading through the + // original object — the provider's own view — with every read guarded so + // a hostile trap can only degrade to undefined, never escape + // (`managedProperty` is exactly that guarded read): + // - a root that could not be snapshotted into a plain record is a live + // Proxy (the sanitizer collapses proxies to a placeholder); + // - a plain-record snapshot that lost `role: "assistant"` came from a + // payload class whose fields live on its prototype: `structuredClone` + // copies only own enumerable properties, so the caller's live + // `message.role === "assistant"` check passes while the detached + // snapshot retains none of the message identity. + const source = + snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value; if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role"); const rawContent = managedAttemptSnapshot(managedProperty(source, "content")); // Benign providers occasionally deliver a string or missing content value. @@ -1071,10 +1078,22 @@ export function managedAssistantEventSnapshot( event: AssistantMessageEvent, message: AssistantMessage, ): AssistantMessageEvent { - const snapshot = managedAttemptSnapshot(event); - if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError("event.snapshot"); - const type = managedProperty(snapshot, "type"); - const contentIndex = managedProperty(snapshot, "contentIndex"); + const detached = managedAttemptSnapshot(event); + const record = isManagedPlainRecord(detached) ? detached : undefined; + // Root repair, mirroring the shell: two benign degradations are re-read + // through the original event with guarded reads (`managedProperty`) — + // - a proxy root (structuredClone rejects proxies; the sanitizer collapses + // them to a placeholder) whose gets are readable, and + // - a payload class whose event fields live on prototype getters + // (`structuredClone` copies only own enumerable properties). + // A hostile trap or throwing getter can only degrade a field to undefined, + // which keeps the named fail-fast diagnostics below; a root that is + // neither snapshottable nor readable as an event keeps the dedicated root + // diagnostic. + const source: unknown = record !== undefined && typeof managedProperty(record, "type") === "string" ? record : event; + const type = managedProperty(source, "type"); + if (record === undefined && typeof type !== "string") throw new ManagedAttemptSnapshotError("event.snapshot"); + const contentIndex = managedProperty(source, "contentIndex"); const indexed = () => { if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) { throw new ManagedAttemptSnapshotError("event.contentIndex"); @@ -1095,29 +1114,29 @@ export function managedAssistantEventSnapshot( type === "reasoning_summary_delta" || type === "toolcall_delta" ) { - const delta = managedProperty(snapshot, "delta"); + const delta = managedProperty(source, "delta"); if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta"); return { type, contentIndex: indexed(), delta, partial: message }; } if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") { - const content = managedProperty(snapshot, "content"); + const content = managedProperty(source, "content"); if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content"); return { type, contentIndex: indexed(), content, partial: message }; } if (type === "toolcall_end") { - const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall")); + const toolCall = managedAssistantContent(managedAttemptSnapshot(managedProperty(source, "toolCall"))); if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError("event.toolcall"); return { type, contentIndex: indexed(), toolCall, partial: message }; } if (type === "done") { - const reason = managedProperty(snapshot, "reason"); + const reason = managedProperty(source, "reason"); // Degrade out-of-vocabulary done reasons to "stop", matching the closed // StopReason vocabulary already normalized by managedAssistantShell. const normalized = reason === "stop" || reason === "length" || reason === "toolUse" ? reason : "stop"; return { type, reason: normalized, message }; } if (type === "error") { - const reason = managedProperty(snapshot, "reason"); + const reason = managedProperty(source, "reason"); const normalized = reason === "aborted" || reason === "error" ? reason : "error"; return { type, reason: normalized, error: message }; } @@ -2978,7 +2997,16 @@ async function streamAssistantResponse( partialMessage = config.fallbackManaged ? managedAssistantShell(event.partial, config.model) : event.partial; - const partialEvent = config.fallbackManaged ? { ...event, partial: partialMessage } : event; + // Normalize through the managed event snapshot instead of a + // naive `{ ...event }` spread: spreading copies only own + // enumerable properties, so a payload-class event carrying its + // fields on prototype getters would lose `type`/`delta` here and + // deterministically fail the whole run as `event.unknownType` + // downstream. The snapshot repairs benign class/prototype shapes + // and keeps the named fail-fast diagnostics for hostile ones. + const partialEvent = config.fallbackManaged + ? managedAssistantEventSnapshot(event, partialMessage) + : event; context.messages[context.messages.length - 1] = partialMessage; if (provisionalToolTransaction) { config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent); diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 8af0eb0895..776996843d 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -1847,6 +1847,94 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { expect(deltas[0]).toMatchObject({ type: "text_delta", delta: "x" }); }); + it("repairs payload-class messages and events whose fields live on the prototype", async () => { + // A provider payload class keeps its fields as prototype getters: + // `message.role === "assistant"` reads fine live, but `structuredClone` + // copies only own enumerable properties, so the detached snapshot loses + // every field. Before the repair this failed the whole managed run as a + // deterministic `shell.role` local snapshot error (issue #4578 class). + const mock = createMockModel(); + const base = assistantMessage(mock.model); + base.content.push({ type: "text", text: "prototype accepted" }); + class PayloadClassAssistantMessage { + get role(): "assistant" { + return "assistant"; + } + get content(): AssistantMessage["content"] { + return base.content; + } + get api(): AssistantMessage["api"] { + return base.api; + } + get provider(): string { + return base.provider; + } + get model(): string { + return base.model; + } + get usage(): AssistantMessage["usage"] { + return base.usage; + } + get stopReason(): AssistantMessage["stopReason"] { + return base.stopReason; + } + get timestamp(): number { + return base.timestamp; + } + } + const partial = new PayloadClassAssistantMessage() as unknown as AssistantMessage; + class PayloadClassTextEndEvent { + get type(): "text_end" { + return "text_end"; + } + get contentIndex(): number { + return 0; + } + get content(): string { + return "prototype accepted"; + } + get partial(): AssistantMessage { + return partial; + } + } + const eventTypes: string[] = []; + let terminalAssistant: AssistantMessage | undefined; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: () => { + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial }); + stream.push({ type: "text_start", contentIndex: 0, partial }); + stream.push(new PayloadClassTextEndEvent() as unknown as AssistantMessageEvent); + stream.push({ type: "done", reason: "stop", message: partial }); + }); + return stream; + }, + }); + agent.subscribe(event => { + eventTypes.push(event.type); + if (event.type === "agent_end") { + terminalAssistant = event.messages.findLast( + (candidate): candidate is AssistantMessage => candidate.role === "assistant", + ); + } + }); + await agent.prompt("run", { fallbackManaged: true }); + expect(agent.state.error).toBeUndefined(); + expectManagedRunStart(eventTypes); + expect(terminalAssistant).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "prototype accepted" }], + stopReason: "stop", + }); + // The repaired shell must be fully detached and JSON-serializable. + expect(JSON.parse(JSON.stringify(terminalAssistant))).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "prototype accepted" }], + }); + }); + it("normalizes malformed terminal and unknown-typed events at the staged snapshot boundary", () => { // Terminal done/error events are consumed by streamAssistantResponse // before the staged-event callback fires, so the normalization contract @@ -1922,8 +2010,16 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { expect(agent.state.error).toBeDefined(); }); - it("keeps hostile getOwnPropertyDescriptor-trap events failing fast", async () => { + it("repairs descriptor-trap proxy events whose guarded gets stay readable", async () => { + // A proxy whose only hostility is a throwing getOwnPropertyDescriptor + // trap defeats structuredClone (and the pre-repair `{ ...event }` + // spread), but its [[Get]]s deliver a well-formed event. The root + // repair reads it through guarded gets, so the run completes instead + // of failing as a deterministic local snapshot error. Truly unreadable + // proxies (throwing get traps) stay fail-closed — see the + // collapsed-root-proxy test above. const mock = createMockModel(); + const callbacks: AssistantMessageEvent[] = []; const agent = new Agent({ initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, streamFn: () => { @@ -1942,17 +2038,13 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { }); return stream; }, + onAssistantMessageEvent: (_message, event) => callbacks.push(event), }); - let outcomes = 0; - await agent.prompt("run", { - fallbackManaged: true, - onManagedAttemptOutcome: () => { - outcomes += 1; - return { type: "retry", continuation: () => {} }; - }, - }); - expect(outcomes).toBe(0); - expect(agent.state.error).toBeDefined(); + await agent.prompt("run", { fallbackManaged: true }); + expect(agent.state.error).toBeUndefined(); + const deltas = callbacks.filter(event => event.type === "text_delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "text_delta", delta: "x" }); }); it("keeps non-string event types failing fast as malformed provider output", async () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2ee1641e2b..53ebe7d3ff 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary. - Generic OpenAI-compatible `/v1/models` discovery now reads served context-window and output-limit metadata instead of defaulting every dynamically listed model to the unknown-window sentinel. `max_model_len` (vLLM/SGLang/oMLX), `context_length`, `context_window`, `max_context_length` (LM Studio), and `max_position_embeddings` populate `contextWindow` in that precedence order, while `max_tokens`/`max_output_tokens` populate `maxTokens`; total-window fields never leak into the output-token ceiling. Malformed values (non-finite, zero, negative, non-numeric) are rejected per-field with fallback to the next candidate, so a `1e400`-style catalog entry can no longer poison compaction thresholds or compact-input budgets. - Refreshed the bundled ZAI catalog with GLM-5.3 and made it the provider's default model. - Added the typed `local_snapshot_failure` and `local_buffer_overflow` assistant error kinds so downstream retry policy can distinguish local event-snapshot and staging-buffer failures from provider failures. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 9a8a0b27b7..9801225250 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1916,7 +1916,63 @@ function cursorNativeToolName(kindKey: string): string { // do not otherwise handle (everything except mcpToolCall / updateTodosToolCall), so // without this they are silently dropped and never render. Build a generic toolCall // block from whichever *ToolCall field is set so the call (and its result) is shown. -function buildNativeToolCallBlock( + +/** Hard node budget for one native-payload conversion; bounds hostile or cyclic graphs. */ +const CURSOR_JSON_SAFE_MAX_NODES = 10_000; + +/** + * Total conversion of a Cursor protobuf payload into plain JSON-safe data. + * + * protobuf-es v2 messages are plain objects, but they carry `$typeName` + * markers, `bigint` fields (e.g. `fileSize`, `durationMs`, `timestampMs`, + * `fileOutputThresholdBytes`), and `Uint8Array` blobs. None of those may leak + * into assistant message content: toolCall `arguments` are staged into managed + * snapshots, persisted to the JSONL transcript, and replayed to providers — + * all of which require `JSON.stringify`-safe values. Attaching the raw payload + * is exactly the local-snapshot producer defect class behind issue #4578. + * + * Rules: `$typeName` is stripped, safe-range bigints become numbers (decimal + * strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 + * strings, dates become ISO strings, functions/symbols are dropped, cycles + * collapse to null, and everything past the node budget is truncated to null. + */ +function cursorJsonSafeValue(value: unknown, path?: Set, budget?: { remaining: number }): unknown { + const seen = path ?? new Set(); + const nodes = budget ?? { remaining: CURSOR_JSON_SAFE_MAX_NODES }; + if (nodes.remaining-- <= 0) return null; + if (typeof value === "bigint") { + return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) + ? Number(value) + : value.toString(); + } + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return null; + if (value === null || typeof value !== "object") return value; + if (seen.has(value)) return null; + if (value instanceof Uint8Array) + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64"); + if (value instanceof Date) return value.toISOString(); + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map(entry => cursorJsonSafeValue(entry, seen, nodes)); + } + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === "$typeName") continue; + record[key] = cursorJsonSafeValue(entry, seen, nodes); + } + return record; + } finally { + seen.delete(value); + } +} + +/** Exported for direct regression coverage of the JSON-safety boundary. */ +export function cursorJsonSafeValueForTest(value: unknown): unknown { + return cursorJsonSafeValue(value); +} + +export function buildNativeToolCallBlock( toolCall: Record, callId: string, index: number, @@ -1925,11 +1981,15 @@ function buildNativeToolCallBlock( if (!/ToolCall$/.test(key) || !payload || typeof payload !== "object") continue; if (key === "mcpToolCall" || key === "updateTodosToolCall") continue; const args = (payload as { args?: unknown }).args; + const safeArguments = + args && typeof args === "object" + ? (cursorJsonSafeValue(args) as Record) + : { raw: cursorJsonSafeValue(payload) }; return { type: "toolCall", id: callId, name: cursorNativeToolName(key), - arguments: args && typeof args === "object" ? (args as Record) : { raw: payload }, + arguments: safeArguments, index, kind: "native", }; diff --git a/packages/ai/test/cursor-native-toolcall-json-safety.test.ts b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts new file mode 100644 index 0000000000..822b5ef99e --- /dev/null +++ b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { buildNativeToolCallBlock, cursorJsonSafeValueForTest } from "../src/providers/cursor"; + +/** + * Cursor native tool calls arrive as protobuf-es payloads carrying + * `$typeName` markers, `bigint` fields, and `Uint8Array` blobs. Those values + * must never leak into assistant toolCall `arguments`: staged managed + * snapshots, JSONL transcript persistence, and provider replay all require + * plain `JSON.stringify`-safe data (issue #4578 producer boundary). + */ +describe("cursor native toolCall JSON safety", () => { + it("converts protobuf payload values into plain JSON-safe data", () => { + const converted = cursorJsonSafeValueForTest({ + $typeName: "agent.v1.ShellToolCallArgs", + command: "ls -la", + fileOutputThresholdBytes: 4096n, + fileSize: BigInt(Number.MAX_SAFE_INTEGER) + 1n, + blob: Uint8Array.from([104, 105]), + nested: [{ $typeName: "agent.v1.Inner", durationMs: 12n }], + when: new Date(1755216000000), + }) as Record; + expect(converted).toEqual({ + command: "ls -la", + fileOutputThresholdBytes: 4096, + fileSize: "9007199254740992", + blob: Buffer.from("hi").toString("base64"), + nested: [{ durationMs: 12 }], + when: new Date(1755216000000).toISOString(), + }); + expect(JSON.parse(JSON.stringify(converted))).toEqual(converted); + }); + + it("collapses cycles and non-data leaves instead of throwing", () => { + const cyclic: Record = { fn: () => "x" }; + cyclic.self = cyclic; + const converted = cursorJsonSafeValueForTest(cyclic) as Record; + expect(converted).toEqual({ fn: null, self: null }); + }); + + it("builds native toolCall blocks with JSON-serializable arguments", () => { + const block = buildNativeToolCallBlock( + { + shellToolCall: { + $typeName: "agent.v1.ShellToolCall", + args: { + $typeName: "agent.v1.ShellToolCallArgs", + command: "echo hello", + timeoutMs: 30000, + fileOutputThresholdBytes: 65536n, + }, + }, + }, + "call-1", + 0, + ); + expect(block).toMatchObject({ + type: "toolCall", + id: "call-1", + name: "bash", + arguments: { command: "echo hello", timeoutMs: 30000, fileOutputThresholdBytes: 65536 }, + }); + expect(JSON.parse(JSON.stringify(block?.arguments))).toEqual(block?.arguments); + }); + + it("wraps argument-less payloads as JSON-safe raw records", () => { + const block = buildNativeToolCallBlock( + { readLintsToolCall: { $typeName: "agent.v1.ReadLintsToolCall", sizeBytes: 12n } }, + "call-2", + 1, + ); + expect(block).toMatchObject({ + type: "toolCall", + id: "call-2", + name: "read_lints", + }); + expect(JSON.parse(JSON.stringify(block?.arguments))).toEqual(block?.arguments); + }); +}); From be83d77e46a8c92fdef1b6bb16f66b7969402d55 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Sat, 15 Aug 2026 11:48:54 +0000 Subject: [PATCH 2/2] fix(ai): bound cursor payload conversion The initial producer-boundary conversion still walked oversized containers and could throw on deeply nested or unreadable payload objects. Stop traversal at explicit node/depth limits and contain hostile enumeration while preserving object-shaped tool arguments. Lore-id: 92d31e6a Constraint: native tool arguments must remain plain JSON-safe records Rejected: rely on protobuf payloads always being shallow and readable | leaves the advertised total conversion vulnerable to malformed provider data Confidence: high Scope-risk: low Reversibility: clean Tested: cursor native toolcall JSON safety suite --- packages/ai/src/providers/cursor.ts | 33 ++++++++++++++----- ...cursor-native-toolcall-json-safety.test.ts | 31 +++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 9801225250..e9008be353 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1919,6 +1919,7 @@ function cursorNativeToolName(kindKey: string): string { /** Hard node budget for one native-payload conversion; bounds hostile or cyclic graphs. */ const CURSOR_JSON_SAFE_MAX_NODES = 10_000; +const CURSOR_JSON_SAFE_MAX_DEPTH = 100; /** * Total conversion of a Cursor protobuf payload into plain JSON-safe data. @@ -1934,34 +1935,45 @@ const CURSOR_JSON_SAFE_MAX_NODES = 10_000; * Rules: `$typeName` is stripped, safe-range bigints become numbers (decimal * strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 * strings, dates become ISO strings, functions/symbols are dropped, cycles - * collapse to null, and everything past the node budget is truncated to null. + * and over-depth values collapse to null, and containers stop accepting + * entries once the shared node budget is exhausted. */ -function cursorJsonSafeValue(value: unknown, path?: Set, budget?: { remaining: number }): unknown { +function cursorJsonSafeValue(value: unknown, path?: Set, budget?: { remaining: number }, depth = 0): unknown { const seen = path ?? new Set(); const nodes = budget ?? { remaining: CURSOR_JSON_SAFE_MAX_NODES }; if (nodes.remaining-- <= 0) return null; + if (depth >= CURSOR_JSON_SAFE_MAX_DEPTH) return null; if (typeof value === "bigint") { return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString(); } if (typeof value === "function" || typeof value === "symbol" || value === undefined) return null; + if (typeof value === "number" && !Number.isFinite(value)) return null; if (value === null || typeof value !== "object") return value; if (seen.has(value)) return null; if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64"); - if (value instanceof Date) return value.toISOString(); + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null; seen.add(value); try { if (Array.isArray(value)) { - return value.map(entry => cursorJsonSafeValue(entry, seen, nodes)); + const array: unknown[] = []; + for (const entry of value) { + if (nodes.remaining <= 0) break; + array.push(cursorJsonSafeValue(entry, seen, nodes, depth + 1)); + } + return array; } const record: Record = {}; for (const [key, entry] of Object.entries(value)) { if (key === "$typeName") continue; - record[key] = cursorJsonSafeValue(entry, seen, nodes); + if (nodes.remaining <= 0) break; + record[key] = cursorJsonSafeValue(entry, seen, nodes, depth + 1); } return record; + } catch { + return null; } finally { seen.delete(value); } @@ -1981,10 +1993,15 @@ export function buildNativeToolCallBlock( if (!/ToolCall$/.test(key) || !payload || typeof payload !== "object") continue; if (key === "mcpToolCall" || key === "updateTodosToolCall") continue; const args = (payload as { args?: unknown }).args; + const hasObjectArgs = args !== null && typeof args === "object"; + const convertedArgs = hasObjectArgs ? cursorJsonSafeValue(args) : undefined; const safeArguments = - args && typeof args === "object" - ? (cursorJsonSafeValue(args) as Record) - : { raw: cursorJsonSafeValue(payload) }; + convertedArgs !== undefined && + convertedArgs !== null && + typeof convertedArgs === "object" && + !Array.isArray(convertedArgs) + ? (convertedArgs as Record) + : { raw: hasObjectArgs ? convertedArgs : cursorJsonSafeValue(payload) }; return { type: "toolCall", id: callId, diff --git a/packages/ai/test/cursor-native-toolcall-json-safety.test.ts b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts index 822b5ef99e..654896cb82 100644 --- a/packages/ai/test/cursor-native-toolcall-json-safety.test.ts +++ b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts @@ -37,6 +37,37 @@ describe("cursor native toolCall JSON safety", () => { expect(converted).toEqual({ fn: null, self: null }); }); + it("bounds hostile graph traversal by node count and depth", () => { + const wide = Object.fromEntries(Array.from({ length: 10_050 }, (_, index) => [`key${index}`, index])); + const convertedWide = cursorJsonSafeValueForTest(wide) as Record; + expect(Object.keys(convertedWide).length).toBeLessThan(Object.keys(wide).length); + expect(JSON.parse(JSON.stringify(convertedWide))).toEqual(convertedWide); + + const deep: Record = {}; + let cursor = deep; + for (let index = 0; index < 500; index++) { + const next: Record = {}; + cursor.next = next; + cursor = next; + } + expect(() => JSON.stringify(cursorJsonSafeValueForTest(deep))).not.toThrow(); + }); + + it("contains unreadable payload objects at the provider boundary", () => { + const unreadable = new Proxy( + {}, + { + ownKeys() { + throw new Error("unreadable payload"); + }, + }, + ); + expect(cursorJsonSafeValueForTest(unreadable)).toBeNull(); + expect(buildNativeToolCallBlock({ shellToolCall: { args: unreadable } }, "call-proxy", 0)?.arguments).toEqual({ + raw: null, + }); + }); + it("builds native toolCall blocks with JSON-serializable arguments", () => { const block = buildNativeToolCallBlock( {