From 7cb059a6bc664b6c448be0ed32cb360c21136648 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 12:55:18 -0600 Subject: [PATCH 01/11] feat(provider): add cursor cli provider --- README.md | 1 + docs/providers.md | 46 +++++- src/exec.ts | 14 +- src/provider.test.ts | 181 ++++++++++++++++++++++++ src/provider.ts | 329 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 567 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b22269f..f04e779 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Supported provider names today: - `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx - `grok`: local Grok Build CLI - `opencode`: local OpenCode CLI +- `cursor`: local Cursor Agent CLI - `mock`: deterministic test provider - `mock-fail`: failure test provider diff --git a/docs/providers.md b/docs/providers.md index 9453888..6082be5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -18,6 +18,7 @@ Provider names today: - `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`) - `opencode`: shells out to `opencode run --format json` - `pi`: shells out to `pi -p` (non-interactive print mode) +- `cursor`: shells out to `cursor-agent -p --output-format json` - `mock`: deterministic provider for tests and fixtures - `mock-fail`: failure provider for tests @@ -195,7 +196,50 @@ review and revalidate, but enforcement depends on pi honoring the tool allowlist For write operations during `fix`, the agent has full filesystem and shell access. For untrusted code, run `clawpatch fix --provider pi` inside an isolated checkout. +## Cursor + +The `cursor` provider shells out to the local Cursor Agent CLI in headless print +mode. + +Verify local availability: + +```bash +cursor-agent --version +clawpatch doctor --provider cursor +``` + +Provider selection: + +```bash +clawpatch review --provider cursor +CLAWPATCH_PROVIDER=cursor clawpatch review +clawpatch fix --finding --provider cursor --model +clawpatch doctor --provider cursor +``` + +How the Cursor provider works: + +- Headless mode: `cursor-agent --trust -p --output-format json ""` +- Output: parses Cursor's `type: "result"` JSON envelope and then extracts the + Clawpatch JSON object from the `result` text +- Prompt delivery: currently uses the positional prompt path, capped at 128000 + UTF-8 bytes +- Model selection: passes `--model ` when configured +- Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags +- Timeout: 180 seconds by default, override with + `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` +- Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for + CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r + +Permission caveat: Cursor's JSON output is documented for print mode, but this +provider does not claim provider-enforced read-only review/revalidate behavior. +The implementation uses `--trust` for the explicit trusted-workspace path and +never uses `--force` or `--yolo`. Complete the linked HITL verification before +using this provider as evidence for an upstream provider PR, especially for +ambient rules, MCP configuration, positional prompt exposure, and any claimed +read-only mode. + Direct OpenAI API, local-model, and multi-model panel providers are not implemented yet. The `acpx` provider is the generic route for ACP-compatible -agents; the `grok`, `opencode`, and `pi` providers are direct integrations +agents; the `grok`, `opencode`, `pi`, and `cursor` providers are direct integrations for local CLIs. diff --git a/src/exec.ts b/src/exec.ts index 5e339af..1c73747 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -66,13 +66,23 @@ export async function runCommandArgs( args: string[], cwd: string, input?: string, - options: { trimOutput?: boolean; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, + options: { + trimOutput?: boolean; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + replaceEnv?: boolean; + } = {}, ): Promise { const started = Date.now(); const spawnSpec = commandSpawnSpec(program, args); const child = spawn(spawnSpec.program, spawnSpec.args, { cwd, - env: options.env === undefined ? process.env : { ...process.env, ...options.env }, + env: + options.env === undefined + ? process.env + : options.replaceEnv === true + ? options.env + : { ...process.env, ...options.env }, detached: process.platform !== "win32" && options.timeoutMs !== undefined, shell: false, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/provider.test.ts b/src/provider.test.ts index 239185e..cfc07ca 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -9,11 +9,17 @@ const { addCodexSandboxArgs, addCodexModelArgs, acpxFailureMessage, + assertCursorRuntimeVersionAllowed, codexFailureMessage, + cursorAgentArgs, + cursorEnv, + cursorFailureMessage, extractAcpxJson, + extractCursorJson, extractOpencodeJson, parseAcpxAgent, parseCodexJson, + parseSemver, piThinkingLevel, providerJsonSchema, } = __testing; @@ -248,6 +254,180 @@ describe("piThinkingLevel", () => { }); }); +describe("Cursor provider", () => { + it("builds the verified trusted print JSON command shape", () => { + const args = cursorAgentArgs("prompt", { + model: "cursor-model", + reasoningEffort: "xhigh", + skipGitRepoCheck: true, + }); + + expect(args).toEqual([ + "--trust", + "-p", + "--output-format", + "json", + "--model", + "cursor-model", + "prompt", + ]); + expect(args).not.toContain("--force"); + expect(args).not.toContain("--yolo"); + expect(args).not.toContain("--mode"); + }); + + it("extracts Clawpatch JSON from the Cursor success envelope result", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: '```json\n{"findings":[],"inspected":{"files":[],"symbols":[],"notes":[]}}\n```', + }); + + expect(extractCursorJson(stdout)).toEqual({ + findings: [], + inspected: { files: [], symbols: [], notes: [] }, + }); + }); + + it("accepts Cursor success envelopes without a subtype", () => { + const stdout = JSON.stringify({ + type: "result", + is_error: false, + result: '{"outcome":"fixed","reasoning":"ok","commands":[]}', + }); + + expect(extractCursorJson(stdout)).toEqual({ + outcome: "fixed", + reasoning: "ok", + commands: [], + }); + }); + + it("rejects Cursor error envelopes", () => { + expect(() => + extractCursorJson( + JSON.stringify({ + type: "result", + subtype: "error", + is_error: true, + result: "auth required", + }), + ), + ).toThrow(/cursor provider returned an error envelope/u); + }); + + it("rejects missing result text", () => { + expectMalformed( + () => + extractCursorJson(JSON.stringify({ type: "result", subtype: "success", is_error: false })), + /missing result text/u, + ); + }); + + it("does not preview malformed Cursor result text", () => { + const secretPrompt = "SOURCE_CONTEXT_SECRET"; + + expect(() => + extractCursorJson( + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: `not json ${secretPrompt}`, + }), + ), + ).toThrow(/result chars=\d+/u); + expect(() => + extractCursorJson( + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: `not json ${secretPrompt}`, + }), + ), + ).not.toThrow(secretPrompt); + }); + + it("rejects multiple Cursor JSON envelopes", () => { + const stdout = [ + JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "{}" }), + JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "{}" }), + ].join("\n"); + + expectMalformed(() => extractCursorJson(stdout), /produced 2 JSON envelopes/u); + }); + + it("does not preview stdout unless it looks like auth or quota output", () => { + const secretPrompt = "SOURCE_CONTEXT_SECRET"; + + expect(cursorFailureMessage(secretPrompt, "", 1)).not.toContain(secretPrompt); + expect(cursorFailureMessage("login required", "", 1)).toContain("authentication required"); + }); + + it("does not preview Cursor stderr on failure", () => { + const secretPrompt = "SOURCE_CONTEXT_SECRET"; + + expect(cursorFailureMessage("", secretPrompt, 1)).not.toContain(secretPrompt); + }); + + it("keeps Cursor subprocess environment to an exact allowlist", () => { + const originalSecret = process.env["CURSOR_RANDOM_SECRET"]; + const originalApiKey = process.env["CURSOR_AGENT_API_KEY"]; + const originalHome = process.env["HOME"]; + process.env["CURSOR_RANDOM_SECRET"] = "nope"; + process.env["CURSOR_AGENT_API_KEY"] = "ok"; + try { + const env = cursorEnv({ + home: "/tmp/clawpatch-cursor/home", + xdgConfig: "/tmp/clawpatch-cursor/xdg-config", + xdgCache: "/tmp/clawpatch-cursor/xdg-cache", + xdgData: "/tmp/clawpatch-cursor/xdg-data", + temp: "/tmp/clawpatch-cursor/tmp", + }); + + expect(env["CURSOR_AGENT_API_KEY"]).toBe("ok"); + expect(env["CURSOR_RANDOM_SECRET"]).toBeUndefined(); + expect(env["HOME"]).toBe("/tmp/clawpatch-cursor/home"); + expect(env["HOME"]).not.toBe(originalHome); + expect(env["XDG_CONFIG_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-config"); + expect(env["XDG_CACHE_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-cache"); + expect(env["XDG_DATA_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-data"); + } finally { + if (originalSecret === undefined) { + delete process.env["CURSOR_RANDOM_SECRET"]; + } else { + process.env["CURSOR_RANDOM_SECRET"] = originalSecret; + } + if (originalApiKey === undefined) { + delete process.env["CURSOR_AGENT_API_KEY"]; + } else { + process.env["CURSOR_AGENT_API_KEY"] = originalApiKey; + } + } + }); + + it("parses semver for Cursor advisory checks", () => { + expect(parseSemver("2.4.9")).toEqual([2, 4, 9]); + expect(parseSemver("v2.5")).toEqual([2, 5, 0]); + expect(parseSemver("2026.05.16-0338208")).toEqual([2026, 5, 16]); + }); + + it("uses Cursor app version for date-formatted CLI builds", () => { + expect(() => assertCursorRuntimeVersionAllowed("2026.05.16-0338208", "3.2.16")).not.toThrow(); + expect(() => assertCursorRuntimeVersionAllowed("2026.05.16-0338208", "2.4.9")).toThrow( + /blocked vulnerable Cursor version/u, + ); + }); + + it("does not treat date-formatted CLI builds as advisory proof by themselves", () => { + expect(() => assertCursorRuntimeVersionAllowed("2026.05.16-0338208", null)).toThrow( + /could not verify Cursor app\/runtime version/u, + ); + }); +}); + function schemaKeys(value: unknown): string[] { if (Array.isArray(value)) { return value.flatMap(schemaKeys); @@ -561,6 +741,7 @@ describe("providerByName", () => { expect(providerByName("grok").name).toBe("grok"); expect(providerByName("opencode").name).toBe("opencode"); expect(providerByName("pi").name).toBe("pi"); + expect(providerByName("cursor").name).toBe("cursor"); }); it("still supports codex, mock, and mock-fail", () => { diff --git a/src/provider.ts b/src/provider.ts index 3bc387d..344d7ff 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCommandArgs } from "./exec.js"; @@ -13,6 +13,7 @@ import { import { extractJson, parseCodexJson, safeProviderPreview } from "./provider-json.js"; import { AgentMapOutput, + CommandResult, FixPlanOutput, ReviewOutput, RevalidateOutput, @@ -55,6 +56,9 @@ export function providerByName(name: string): Provider { if (name === "pi") { return piProvider; } + if (name === "cursor") { + return cursorProvider; + } if (name === "mock") { return mockProvider; } @@ -197,6 +201,10 @@ const grokProvider: Provider = { }; const PI_DEFAULT_TIMEOUT_MS = 180_000; +const CURSOR_DEFAULT_TIMEOUT_MS = 180_000; +const CURSOR_POSITIONAL_PROMPT_MAX_BYTES = 128_000; +const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; +const CURSOR_DARWIN_INFO_PLIST = "/Applications/Cursor.app/Contents/Info.plist"; const piProvider: Provider = { name: "pi", @@ -229,6 +237,319 @@ const piProvider: Provider = { }, }; +const cursorProvider: Provider = { + name: "cursor", + async check(root: string): Promise { + const result = await runCursorAgent(root, ["--version"]); + if (result.exitCode !== 0) { + throw new ClawpatchError( + "cursor-agent CLI not available or not authenticated", + 4, + "provider-auth", + ); + } + const version = result.stdout.trim(); + const appVersion = await cursorAppVersion(); + assertCursorRuntimeVersionAllowed(version, appVersion); + return appVersion === null ? version : `${version} (Cursor app ${appVersion})`; + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runCursorJson(root, prompt, options, agentMapJsonSchema, true); + return agentMapOutputSchema.parse(output); + }, + async review(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runCursorJson(root, prompt, options, reviewJsonSchema, true); + return reviewOutputSchema.parse(output); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runCursorJson(root, prompt, options, fixPlanJsonSchema, false); + return fixPlanOutputSchema.parse(output); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runCursorJson(root, prompt, options, revalidateJsonSchema, true); + return revalidateOutputSchema.parse(output); + }, +}; + +async function runCursorJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): Promise { + const fullPrompt = cursorPrompt(prompt, schema, readOnly); + if (Buffer.byteLength(fullPrompt, "utf8") > CURSOR_POSITIONAL_PROMPT_MAX_BYTES) { + throw new ClawpatchError( + `cursor provider prompt exceeds ${CURSOR_POSITIONAL_PROMPT_MAX_BYTES} byte positional prompt limit`, + 2, + "invalid-usage", + ); + } + const args = cursorAgentArgs(fullPrompt, options); + const result = await runCursorAgent(root, args); + if (result.exitCode !== 0) { + throw new ClawpatchError( + cursorFailureMessage(result.stdout, result.stderr, result.exitCode), + providerExitCode(`${result.stderr}\n${result.stdout}`), + "provider-failure", + ); + } + return extractCursorJson(result.stdout); +} + +function cursorAgentArgs(prompt: string, options: ProviderOptions): string[] { + const args = ["--trust", "-p", "--output-format", "json"]; + if (options.model !== null) { + args.push("--model", options.model); + } + args.push(prompt); + return args; +} + +async function runCursorAgent(root: string, args: string[]): Promise { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-cursor-")); + try { + const home = join(dir, "home"); + const xdgConfig = join(dir, "xdg-config"); + const xdgCache = join(dir, "xdg-cache"); + const xdgData = join(dir, "xdg-data"); + const temp = join(dir, "tmp"); + await Promise.all([home, xdgConfig, xdgCache, xdgData, temp].map((path) => mkdir(path))); + return await runCommandArgs("cursor-agent", args, root, undefined, { + trimOutput: false, + timeoutMs: cursorTimeoutMs(), + env: cursorEnv({ home, xdgConfig, xdgCache, xdgData, temp }), + replaceEnv: true, + }); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +function cursorPrompt(prompt: string, schema: object, readOnly: boolean): string { + const promptBody = readOnly + ? "READ-ONLY REVIEW MODE.\n" + + "Do not modify, create, or delete any files.\n" + + "Do not run shell commands.\n" + + "Cursor CLI headless read-only behavior is not provider-enforced unless HITL verification " + + "proves an installed-version read-only mode.\n\n" + + prompt + : prompt; + return `${promptBody} + +Provider output schema: +${JSON.stringify(schema, null, 2)} + +Return only one JSON object matching the schema.`; +} + +function extractCursorJson(stdout: string): unknown { + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + throw new ClawpatchError("cursor provider produced no JSON envelope", 8, "malformed-output"); + } + const envelope = parseSingleCursorEnvelope(trimmed); + if (typeof envelope !== "object" || envelope === null) { + throw new ClawpatchError( + "cursor provider produced a non-object JSON envelope", + 8, + "malformed-output", + ); + } + const record = envelope as Record; + if (record["type"] !== "result") { + throw new ClawpatchError( + "cursor provider produced a non-result JSON envelope", + 8, + "malformed-output", + ); + } + const subtype = record["subtype"]; + if (record["is_error"] === true || (subtype !== undefined && subtype !== "success")) { + const subtypePreview = typeof subtype === "string" ? subtype : "unknown"; + throw new ClawpatchError( + `cursor provider returned an error envelope (subtype=${safeProviderPreview( + subtypePreview, + 80, + )}, is_error=${String(record["is_error"])})`, + 1, + "provider-failure", + ); + } + if (typeof record["result"] !== "string" || record["result"].trim().length === 0) { + throw new ClawpatchError( + "cursor provider result envelope is missing result text", + 8, + "malformed-output", + ); + } + const parsed = extractJson(record["result"]); + if (parsed === null) { + throw new ClawpatchError( + `cursor provider result contained no Clawpatch JSON (result chars=${record["result"].length})`, + 8, + "malformed-output", + ); + } + return parsed; +} + +function parseSingleCursorEnvelope(stdout: string): unknown { + try { + return JSON.parse(stdout) as unknown; + } catch {} + const parsedLines: unknown[] = []; + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + try { + parsedLines.push(JSON.parse(trimmed) as unknown); + } catch { + throw new ClawpatchError( + "cursor provider produced malformed JSON envelope", + 8, + "malformed-output", + ); + } + } + if (parsedLines.length === 1) { + return parsedLines[0]; + } + throw new ClawpatchError( + `cursor provider produced ${parsedLines.length} JSON envelopes; expected exactly one`, + 8, + "malformed-output", + ); +} + +function cursorFailureMessage(stdout: string, stderr: string, exitCode: number | null): string { + const combined = `${stderr}\n${stdout}`; + if (/auth|login|not authenticated|keychain|unauthorized/iu.test(combined)) { + return "cursor provider failed: authentication required or unavailable"; + } + if (/quota|rate.?limit/iu.test(combined)) { + return "cursor provider failed: quota or rate limit"; + } + return `cursor provider failed with exit code ${exitCode ?? "unknown"}`; +} + +function cursorTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) { + return CURSOR_DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : CURSOR_DEFAULT_TIMEOUT_MS; +} + +function cursorEnv(paths: { + home: string; + xdgConfig: string; + xdgCache: string; + xdgData: string; + temp: string; +}): NodeJS.ProcessEnv { + const allowed = [ + "PATH", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "CURSOR_API_KEY", + "CURSOR_AGENT_API_KEY", + ]; + return { + ...Object.fromEntries( + allowed.flatMap((name) => { + const value = process.env[name]; + return value === undefined ? [] : [[name, value]]; + }), + ), + HOME: paths.home, + TMPDIR: paths.temp, + TEMP: paths.temp, + TMP: paths.temp, + XDG_CONFIG_HOME: paths.xdgConfig, + XDG_CACHE_HOME: paths.xdgCache, + XDG_DATA_HOME: paths.xdgData, + }; +} + +async function cursorAppVersion(): Promise { + if (process.platform !== "darwin") { + return null; + } + const plist = await readFile(CURSOR_DARWIN_INFO_PLIST, "utf8").catch(() => null); + if (plist === null) { + return null; + } + const match = + /CFBundleShortVersionString<\/key>\s*([^<]+)<\/string>/u.exec(plist) ?? + /CFBundleVersion<\/key>\s*([^<]+)<\/string>/u.exec(plist); + return match?.[1]?.trim() ?? null; +} + +function assertCursorRuntimeVersionAllowed(cliVersion: string, appVersion: string | null): void { + const parsedCli = parseSemver(cliVersion); + const parsedApp = appVersion === null ? null : parseSemver(appVersion); + if (appVersion !== null && parsedApp !== null) { + assertCursorVersionAllowed(appVersion, parsedApp); + return; + } + if (parsedCli !== null && !isCursorDateBuildVersion(cliVersion)) { + assertCursorVersionAllowed(cliVersion, parsedCli); + return; + } + throw new ClawpatchError( + "cursor provider could not verify Cursor app/runtime version for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r", + 4, + "provider-auth", + ); +} + +function assertCursorVersionAllowed(version: string, parsed: [number, number, number]): void { + const minimum = parseSemver(CURSOR_MIN_SAFE_APP_VERSION); + if (minimum === null || compareSemver(parsed, minimum) >= 0) { + return; + } + throw new ClawpatchError( + `cursor provider blocked vulnerable Cursor version ${version}; upgrade to ${CURSOR_MIN_SAFE_APP_VERSION} or newer for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r`, + 4, + "provider-auth", + ); +} + +function isCursorDateBuildVersion(version: string): boolean { + return /^\d{4}\.\d{2}\.\d{2}(?:[-+].*)?$/u.test(version.trim()); +} + +function parseSemver(version: string): [number, number, number] | null { + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?/u.exec(version.trim()); + if (match === null) { + return null; + } + return [Number(match[1]), Number(match[2]), Number(match[3] ?? "0")]; +} + +function compareSemver(left: [number, number, number], right: [number, number, number]): number { + for (let index = 0; index < left.length; index += 1) { + const diff = left[index]! - right[index]!; + if (diff !== 0) { + return diff; + } + } + return 0; +} + async function runPiJson( root: string, prompt: string, @@ -1068,8 +1389,14 @@ export const __testing = { addCodexModelArgs, addCodexSandboxArgs, codexFailureMessage, + cursorAgentArgs, + cursorEnv, + cursorFailureMessage, + extractCursorJson, extractAcpxJson, extractOpencodeJson, + assertCursorRuntimeVersionAllowed, + parseSemver, parseAcpxAgent, parseCodexJson, piThinkingLevel, From a33c3e3468a5ea1215799a3c874ce03ea355bf45 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 13:32:09 -0600 Subject: [PATCH 02/11] fix(provider): gate cursor execution pending verification --- README.md | 2 +- docs/providers.md | 32 +++++++++++-------- src/provider.test.ts | 76 +++++++++++++++++++++++++++++++++++++------- src/provider.ts | 69 +++++++++++++++++++++++++++++++--------- 4 files changed, 138 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f04e779..7a42b0f 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Supported provider names today: - `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx - `grok`: local Grok Build CLI - `opencode`: local OpenCode CLI -- `cursor`: local Cursor Agent CLI +- `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default) - `mock`: deterministic test provider - `mock-fail`: failure test provider diff --git a/docs/providers.md b/docs/providers.md index 6082be5..8e95a98 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -199,7 +199,8 @@ For untrusted code, run `clawpatch fix --provider pi` inside an isolated checkou ## Cursor The `cursor` provider shells out to the local Cursor Agent CLI in headless print -mode. +mode. It is experimental and disabled for `map`, `review`, `fix`, and +`revalidate` by default while HITL verification is incomplete. Verify local availability: @@ -208,36 +209,41 @@ cursor-agent --version clawpatch doctor --provider cursor ``` -Provider selection: +Experimental provider selection: ```bash -clawpatch review --provider cursor -CLAWPATCH_PROVIDER=cursor clawpatch review -clawpatch fix --finding --provider cursor --model +CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 clawpatch review --provider cursor +CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 CLAWPATCH_PROVIDER=cursor clawpatch review +CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 CLAWPATCH_CURSOR_ALLOW_WRITE=1 clawpatch fix --finding --provider cursor --model clawpatch doctor --provider cursor ``` How the Cursor provider works: -- Headless mode: `cursor-agent --trust -p --output-format json ""` +- Headless mode: `cursor-agent --trust -p --output-format json --workspace ""` +- Read-only operations: also pass Cursor's documented `--mode ask` - Output: parses Cursor's `type: "result"` JSON envelope and then extracts the Clawpatch JSON object from the `result` text - Prompt delivery: currently uses the positional prompt path, capped at 128000 UTF-8 bytes - Model selection: passes `--model ` when configured - Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags +- Authentication: experimental execution requires `CURSOR_API_KEY`; Clawpatch + intentionally runs Cursor with an isolated temporary `HOME` and does not use + host-home Cursor login state - Timeout: 180 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` - Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r -Permission caveat: Cursor's JSON output is documented for print mode, but this -provider does not claim provider-enforced read-only review/revalidate behavior. -The implementation uses `--trust` for the explicit trusted-workspace path and -never uses `--force` or `--yolo`. Complete the linked HITL verification before -using this provider as evidence for an upstream provider PR, especially for -ambient rules, MCP configuration, positional prompt exposure, and any claimed -read-only mode. +Permission caveat: Cursor's print mode is documented as having access to tools, +including write and shell. Clawpatch therefore keeps Cursor execution behind +`CLAWPATCH_CURSOR_EXPERIMENTAL=1`, uses `--mode ask` for read-only operations, +and separately requires `CLAWPATCH_CURSOR_ALLOW_WRITE=1` for `fix`. The +implementation uses `--trust` for the explicit trusted-workspace path and never +uses `--force` or `--yolo`. Complete HITL verification before promoting this to +default provider support, especially for ambient rules, MCP configuration, +positional prompt exposure, timeout behavior, and any claimed read-only mode. Direct OpenAI API, local-model, and multi-model panel providers are not implemented yet. The `acpx` provider is the generic route for ACP-compatible diff --git a/src/provider.test.ts b/src/provider.test.ts index cfc07ca..aeaba8e 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -255,25 +255,76 @@ describe("piThinkingLevel", () => { }); describe("Cursor provider", () => { - it("builds the verified trusted print JSON command shape", () => { - const args = cursorAgentArgs("prompt", { - model: "cursor-model", - reasoningEffort: "xhigh", - skipGitRepoCheck: true, - }); + it("builds the verified trusted read-only print JSON command shape", () => { + const args = cursorAgentArgs( + "/repo", + "prompt", + { + model: "cursor-model", + reasoningEffort: "xhigh", + skipGitRepoCheck: true, + }, + true, + ); expect(args).toEqual([ "--trust", "-p", "--output-format", "json", + "--workspace", + "/repo", + "--mode", + "ask", "--model", "cursor-model", "prompt", ]); expect(args).not.toContain("--force"); expect(args).not.toContain("--yolo"); - expect(args).not.toContain("--mode"); + }); + + it("leaves write-mode Cursor execution ungated by read-only mode flags", () => { + const args = cursorAgentArgs( + "/repo", + "prompt", + { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }, + false, + ); + + expect(args).toEqual([ + "--trust", + "-p", + "--output-format", + "json", + "--workspace", + "/repo", + "prompt", + ]); + }); + + it("keeps Cursor provider execution disabled by default", async () => { + const originalExperimental = process.env["CLAWPATCH_CURSOR_EXPERIMENTAL"]; + delete process.env["CLAWPATCH_CURSOR_EXPERIMENTAL"]; + try { + await expect( + providerByName("cursor").review("/repo", "prompt", { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }), + ).rejects.toThrow(/experimental and disabled by default/u); + } finally { + if (originalExperimental === undefined) { + delete process.env["CLAWPATCH_CURSOR_EXPERIMENTAL"]; + } else { + process.env["CLAWPATCH_CURSOR_EXPERIMENTAL"] = originalExperimental; + } + } }); it("extracts Clawpatch JSON from the Cursor success envelope result", () => { @@ -374,10 +425,10 @@ describe("Cursor provider", () => { it("keeps Cursor subprocess environment to an exact allowlist", () => { const originalSecret = process.env["CURSOR_RANDOM_SECRET"]; - const originalApiKey = process.env["CURSOR_AGENT_API_KEY"]; + const originalApiKey = process.env["CURSOR_API_KEY"]; const originalHome = process.env["HOME"]; process.env["CURSOR_RANDOM_SECRET"] = "nope"; - process.env["CURSOR_AGENT_API_KEY"] = "ok"; + process.env["CURSOR_API_KEY"] = "ok"; try { const env = cursorEnv({ home: "/tmp/clawpatch-cursor/home", @@ -387,7 +438,8 @@ describe("Cursor provider", () => { temp: "/tmp/clawpatch-cursor/tmp", }); - expect(env["CURSOR_AGENT_API_KEY"]).toBe("ok"); + expect(env["CURSOR_API_KEY"]).toBe("ok"); + expect(env["CURSOR_AGENT_API_KEY"]).toBeUndefined(); expect(env["CURSOR_RANDOM_SECRET"]).toBeUndefined(); expect(env["HOME"]).toBe("/tmp/clawpatch-cursor/home"); expect(env["HOME"]).not.toBe(originalHome); @@ -401,9 +453,9 @@ describe("Cursor provider", () => { process.env["CURSOR_RANDOM_SECRET"] = originalSecret; } if (originalApiKey === undefined) { - delete process.env["CURSOR_AGENT_API_KEY"]; + delete process.env["CURSOR_API_KEY"]; } else { - process.env["CURSOR_AGENT_API_KEY"] = originalApiKey; + process.env["CURSOR_API_KEY"] = originalApiKey; } } }); diff --git a/src/provider.ts b/src/provider.ts index 344d7ff..0099506 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -205,6 +205,8 @@ const CURSOR_DEFAULT_TIMEOUT_MS = 180_000; const CURSOR_POSITIONAL_PROMPT_MAX_BYTES = 128_000; const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; const CURSOR_DARWIN_INFO_PLIST = "/Applications/Cursor.app/Contents/Info.plist"; +const CURSOR_EXPERIMENTAL_ENV = "CLAWPATCH_CURSOR_EXPERIMENTAL"; +const CURSOR_WRITE_ENV = "CLAWPATCH_CURSOR_ALLOW_WRITE"; const piProvider: Provider = { name: "pi", @@ -254,14 +256,18 @@ const cursorProvider: Provider = { return appVersion === null ? version : `${version} (Cursor app ${appVersion})`; }, async map(root: string, prompt: string, options: ProviderOptions): Promise { + assertCursorProviderEnabled("map"); const output = await runCursorJson(root, prompt, options, agentMapJsonSchema, true); return agentMapOutputSchema.parse(output); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { + assertCursorProviderEnabled("review"); const output = await runCursorJson(root, prompt, options, reviewJsonSchema, true); return reviewOutputSchema.parse(output); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { + assertCursorProviderEnabled("fix"); + assertCursorWriteEnabled(); const output = await runCursorJson(root, prompt, options, fixPlanJsonSchema, false); return fixPlanOutputSchema.parse(output); }, @@ -270,6 +276,7 @@ const cursorProvider: Provider = { prompt: string, options: ProviderOptions, ): Promise { + assertCursorProviderEnabled("revalidate"); const output = await runCursorJson(root, prompt, options, revalidateJsonSchema, true); return revalidateOutputSchema.parse(output); }, @@ -282,6 +289,7 @@ async function runCursorJson( schema: object, readOnly: boolean, ): Promise { + assertCursorApiKeyAvailable(); const fullPrompt = cursorPrompt(prompt, schema, readOnly); if (Buffer.byteLength(fullPrompt, "utf8") > CURSOR_POSITIONAL_PROMPT_MAX_BYTES) { throw new ClawpatchError( @@ -290,7 +298,7 @@ async function runCursorJson( "invalid-usage", ); } - const args = cursorAgentArgs(fullPrompt, options); + const args = cursorAgentArgs(root, fullPrompt, options, readOnly); const result = await runCursorAgent(root, args); if (result.exitCode !== 0) { throw new ClawpatchError( @@ -302,8 +310,16 @@ async function runCursorJson( return extractCursorJson(result.stdout); } -function cursorAgentArgs(prompt: string, options: ProviderOptions): string[] { - const args = ["--trust", "-p", "--output-format", "json"]; +function cursorAgentArgs( + root: string, + prompt: string, + options: ProviderOptions, + readOnly: boolean, +): string[] { + const args = ["--trust", "-p", "--output-format", "json", "--workspace", root]; + if (readOnly) { + args.push("--mode", "ask"); + } if (options.model !== null) { args.push("--model", options.model); } @@ -336,8 +352,7 @@ function cursorPrompt(prompt: string, schema: object, readOnly: boolean): string ? "READ-ONLY REVIEW MODE.\n" + "Do not modify, create, or delete any files.\n" + "Do not run shell commands.\n" + - "Cursor CLI headless read-only behavior is not provider-enforced unless HITL verification " + - "proves an installed-version read-only mode.\n\n" + + "The Cursor CLI also receives --mode ask for this read-only request.\n\n" + prompt : prompt; return `${promptBody} @@ -440,6 +455,39 @@ function cursorFailureMessage(stdout: string, stderr: string, exitCode: number | return `cursor provider failed with exit code ${exitCode ?? "unknown"}`; } +function assertCursorProviderEnabled(operation: string): void { + if (process.env[CURSOR_EXPERIMENTAL_ENV] === "1") { + return; + } + throw new ClawpatchError( + `cursor provider ${operation} is experimental and disabled by default; set ${CURSOR_EXPERIMENTAL_ENV}=1 after completing local HITL verification`, + 2, + "unsupported-provider", + ); +} + +function assertCursorWriteEnabled(): void { + if (process.env[CURSOR_WRITE_ENV] === "1") { + return; + } + throw new ClawpatchError( + `cursor provider fix is disabled until write-mode HITL verification passes; set ${CURSOR_WRITE_ENV}=1 only in an isolated checkout`, + 2, + "unsupported-provider", + ); +} + +function assertCursorApiKeyAvailable(): void { + if ((process.env["CURSOR_API_KEY"] ?? "").trim().length > 0) { + return; + } + throw new ClawpatchError( + "cursor provider requires CURSOR_API_KEY for isolated headless execution; host HOME authentication is intentionally not used", + 4, + "provider-auth", + ); +} + function cursorTimeoutMs(): number { const raw = process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; @@ -457,16 +505,7 @@ function cursorEnv(paths: { xdgData: string; temp: string; }): NodeJS.ProcessEnv { - const allowed = [ - "PATH", - "USER", - "LOGNAME", - "SHELL", - "LANG", - "LC_ALL", - "CURSOR_API_KEY", - "CURSOR_AGENT_API_KEY", - ]; + const allowed = ["PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "CURSOR_API_KEY"]; return { ...Object.fromEntries( allowed.flatMap((name) => { From 0592d29ba0754585e8c440174d7d0652257d9f40 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 13:38:55 -0600 Subject: [PATCH 03/11] fix(provider): suppress cursor headless browser prompts --- docs/providers.md | 5 ++++- src/provider.test.ts | 1 + src/provider.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/providers.md b/docs/providers.md index 8e95a98..02fc648 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -227,10 +227,13 @@ How the Cursor provider works: - Prompt delivery: currently uses the positional prompt path, capped at 128000 UTF-8 bytes - Model selection: passes `--model ` when configured +- Model names: pass Cursor model ids, for example `composer-2.5` for Composer + 2.5 without fast mode - Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags - Authentication: experimental execution requires `CURSOR_API_KEY`; Clawpatch intentionally runs Cursor with an isolated temporary `HOME` and does not use - host-home Cursor login state + host-home Cursor login state. It also sets `NO_OPEN_BROWSER=1` to keep + headless runs from opening browser or Keychain UI. - Timeout: 180 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` - Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for diff --git a/src/provider.test.ts b/src/provider.test.ts index aeaba8e..36d6854 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -443,6 +443,7 @@ describe("Cursor provider", () => { expect(env["CURSOR_RANDOM_SECRET"]).toBeUndefined(); expect(env["HOME"]).toBe("/tmp/clawpatch-cursor/home"); expect(env["HOME"]).not.toBe(originalHome); + expect(env["NO_OPEN_BROWSER"]).toBe("1"); expect(env["XDG_CONFIG_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-config"); expect(env["XDG_CACHE_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-cache"); expect(env["XDG_DATA_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-data"); diff --git a/src/provider.ts b/src/provider.ts index 0099506..ce4b4b2 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -517,6 +517,7 @@ function cursorEnv(paths: { TMPDIR: paths.temp, TEMP: paths.temp, TMP: paths.temp, + NO_OPEN_BROWSER: "1", XDG_CONFIG_HOME: paths.xdgConfig, XDG_CACHE_HOME: paths.xdgCache, XDG_DATA_HOME: paths.xdgData, From 3ff768b785b703dbbecc26d2938a19aa49313f50 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 13:54:55 -0600 Subject: [PATCH 04/11] fix(provider): use host environment for cursor --- docs/providers.md | 8 +++--- src/provider.test.ts | 40 +++--------------------------- src/provider.ts | 58 ++++++-------------------------------------- 3 files changed, 15 insertions(+), 91 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 02fc648..76ddf0b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -230,10 +230,10 @@ How the Cursor provider works: - Model names: pass Cursor model ids, for example `composer-2.5` for Composer 2.5 without fast mode - Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags -- Authentication: experimental execution requires `CURSOR_API_KEY`; Clawpatch - intentionally runs Cursor with an isolated temporary `HOME` and does not use - host-home Cursor login state. It also sets `NO_OPEN_BROWSER=1` to keep - headless runs from opening browser or Keychain UI. +- Authentication: experimental execution uses the host user environment, so + either `CURSOR_API_KEY` or the user's existing Cursor login/keychain state can + be used. Clawpatch also sets `NO_OPEN_BROWSER=1` to reduce browser prompts + during headless runs. - Timeout: 180 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` - Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for diff --git a/src/provider.test.ts b/src/provider.test.ts index 36d6854..7a65e3a 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -423,42 +423,10 @@ describe("Cursor provider", () => { expect(cursorFailureMessage("", secretPrompt, 1)).not.toContain(secretPrompt); }); - it("keeps Cursor subprocess environment to an exact allowlist", () => { - const originalSecret = process.env["CURSOR_RANDOM_SECRET"]; - const originalApiKey = process.env["CURSOR_API_KEY"]; - const originalHome = process.env["HOME"]; - process.env["CURSOR_RANDOM_SECRET"] = "nope"; - process.env["CURSOR_API_KEY"] = "ok"; - try { - const env = cursorEnv({ - home: "/tmp/clawpatch-cursor/home", - xdgConfig: "/tmp/clawpatch-cursor/xdg-config", - xdgCache: "/tmp/clawpatch-cursor/xdg-cache", - xdgData: "/tmp/clawpatch-cursor/xdg-data", - temp: "/tmp/clawpatch-cursor/tmp", - }); - - expect(env["CURSOR_API_KEY"]).toBe("ok"); - expect(env["CURSOR_AGENT_API_KEY"]).toBeUndefined(); - expect(env["CURSOR_RANDOM_SECRET"]).toBeUndefined(); - expect(env["HOME"]).toBe("/tmp/clawpatch-cursor/home"); - expect(env["HOME"]).not.toBe(originalHome); - expect(env["NO_OPEN_BROWSER"]).toBe("1"); - expect(env["XDG_CONFIG_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-config"); - expect(env["XDG_CACHE_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-cache"); - expect(env["XDG_DATA_HOME"]).toBe("/tmp/clawpatch-cursor/xdg-data"); - } finally { - if (originalSecret === undefined) { - delete process.env["CURSOR_RANDOM_SECRET"]; - } else { - process.env["CURSOR_RANDOM_SECRET"] = originalSecret; - } - if (originalApiKey === undefined) { - delete process.env["CURSOR_API_KEY"]; - } else { - process.env["CURSOR_API_KEY"] = originalApiKey; - } - } + it("sets Cursor headless browser suppression without replacing the host environment", () => { + expect(cursorEnv()).toEqual({ + NO_OPEN_BROWSER: "1", + }); }); it("parses semver for Cursor advisory checks", () => { diff --git a/src/provider.ts b/src/provider.ts index ce4b4b2..05f917f 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCommandArgs } from "./exec.js"; @@ -289,7 +289,6 @@ async function runCursorJson( schema: object, readOnly: boolean, ): Promise { - assertCursorApiKeyAvailable(); const fullPrompt = cursorPrompt(prompt, schema, readOnly); if (Buffer.byteLength(fullPrompt, "utf8") > CURSOR_POSITIONAL_PROMPT_MAX_BYTES) { throw new ClawpatchError( @@ -328,23 +327,11 @@ function cursorAgentArgs( } async function runCursorAgent(root: string, args: string[]): Promise { - const dir = await mkdtemp(join(tmpdir(), "clawpatch-cursor-")); - try { - const home = join(dir, "home"); - const xdgConfig = join(dir, "xdg-config"); - const xdgCache = join(dir, "xdg-cache"); - const xdgData = join(dir, "xdg-data"); - const temp = join(dir, "tmp"); - await Promise.all([home, xdgConfig, xdgCache, xdgData, temp].map((path) => mkdir(path))); - return await runCommandArgs("cursor-agent", args, root, undefined, { - trimOutput: false, - timeoutMs: cursorTimeoutMs(), - env: cursorEnv({ home, xdgConfig, xdgCache, xdgData, temp }), - replaceEnv: true, - }); - } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } + return await runCommandArgs("cursor-agent", args, root, undefined, { + trimOutput: false, + timeoutMs: cursorTimeoutMs(), + env: cursorEnv(), + }); } function cursorPrompt(prompt: string, schema: object, readOnly: boolean): string { @@ -477,17 +464,6 @@ function assertCursorWriteEnabled(): void { ); } -function assertCursorApiKeyAvailable(): void { - if ((process.env["CURSOR_API_KEY"] ?? "").trim().length > 0) { - return; - } - throw new ClawpatchError( - "cursor provider requires CURSOR_API_KEY for isolated headless execution; host HOME authentication is intentionally not used", - 4, - "provider-auth", - ); -} - function cursorTimeoutMs(): number { const raw = process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; @@ -498,29 +474,9 @@ function cursorTimeoutMs(): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : CURSOR_DEFAULT_TIMEOUT_MS; } -function cursorEnv(paths: { - home: string; - xdgConfig: string; - xdgCache: string; - xdgData: string; - temp: string; -}): NodeJS.ProcessEnv { - const allowed = ["PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "CURSOR_API_KEY"]; +function cursorEnv(): NodeJS.ProcessEnv { return { - ...Object.fromEntries( - allowed.flatMap((name) => { - const value = process.env[name]; - return value === undefined ? [] : [[name, value]]; - }), - ), - HOME: paths.home, - TMPDIR: paths.temp, - TEMP: paths.temp, - TMP: paths.temp, NO_OPEN_BROWSER: "1", - XDG_CONFIG_HOME: paths.xdgConfig, - XDG_CACHE_HOME: paths.xdgCache, - XDG_DATA_HOME: paths.xdgData, }; } From 7b6c5984029259ffcd44a173b8e6dd2458273b45 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 14:01:20 -0600 Subject: [PATCH 05/11] fix(provider): extend cursor default timeout --- docs/providers.md | 2 +- src/provider.test.ts | 24 ++++++++++++++++++++++++ src/provider.ts | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 76ddf0b..c7f1f20 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -234,7 +234,7 @@ How the Cursor provider works: either `CURSOR_API_KEY` or the user's existing Cursor login/keychain state can be used. Clawpatch also sets `NO_OPEN_BROWSER=1` to reduce browser prompts during headless runs. -- Timeout: 180 seconds by default, override with +- Timeout: 300 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` - Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r diff --git a/src/provider.test.ts b/src/provider.test.ts index 7a65e3a..c4ad70e 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -14,6 +14,7 @@ const { cursorAgentArgs, cursorEnv, cursorFailureMessage, + cursorTimeoutMs, extractAcpxJson, extractCursorJson, extractOpencodeJson, @@ -255,6 +256,22 @@ describe("piThinkingLevel", () => { }); describe("Cursor provider", () => { + const originalCursorTimeout = process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"]; + const originalProviderTimeout = process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + + afterEach(() => { + if (originalCursorTimeout === undefined) { + delete process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"]; + } else { + process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"] = originalCursorTimeout; + } + if (originalProviderTimeout === undefined) { + delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + } else { + process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = originalProviderTimeout; + } + }); + it("builds the verified trusted read-only print JSON command shape", () => { const args = cursorAgentArgs( "/repo", @@ -429,6 +446,13 @@ describe("Cursor provider", () => { }); }); + it("uses a 300 second default timeout for Cursor", () => { + delete process.env["CLAWPATCH_CURSOR_TIMEOUT_MS"]; + delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + + expect(cursorTimeoutMs()).toBe(300_000); + }); + it("parses semver for Cursor advisory checks", () => { expect(parseSemver("2.4.9")).toEqual([2, 4, 9]); expect(parseSemver("v2.5")).toEqual([2, 5, 0]); diff --git a/src/provider.ts b/src/provider.ts index 05f917f..efac537 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -201,7 +201,7 @@ const grokProvider: Provider = { }; const PI_DEFAULT_TIMEOUT_MS = 180_000; -const CURSOR_DEFAULT_TIMEOUT_MS = 180_000; +const CURSOR_DEFAULT_TIMEOUT_MS = 300_000; const CURSOR_POSITIONAL_PROMPT_MAX_BYTES = 128_000; const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; const CURSOR_DARWIN_INFO_PLIST = "/Applications/Cursor.app/Contents/Info.plist"; @@ -1388,6 +1388,7 @@ export const __testing = { cursorAgentArgs, cursorEnv, cursorFailureMessage, + cursorTimeoutMs, extractCursorJson, extractAcpxJson, extractOpencodeJson, From 041213291583429e8981249964b19a140e008a74 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 14:43:52 -0600 Subject: [PATCH 06/11] fix(provider): send cursor prompts over stdin --- docs/providers.md | 5 ++--- src/provider.test.ts | 13 +------------ src/provider.ts | 28 +++++++++------------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index c7f1f20..28aa896 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -220,12 +220,11 @@ clawpatch doctor --provider cursor How the Cursor provider works: -- Headless mode: `cursor-agent --trust -p --output-format json --workspace ""` +- Headless mode: `cursor-agent --trust -p --output-format json --workspace ` - Read-only operations: also pass Cursor's documented `--mode ask` - Output: parses Cursor's `type: "result"` JSON envelope and then extracts the Clawpatch JSON object from the `result` text -- Prompt delivery: currently uses the positional prompt path, capped at 128000 - UTF-8 bytes +- Prompt delivery: writes the full Clawpatch prompt to Cursor's stdin - Model selection: passes `--model ` when configured - Model names: pass Cursor model ids, for example `composer-2.5` for Composer 2.5 without fast mode diff --git a/src/provider.test.ts b/src/provider.test.ts index c4ad70e..fda6d17 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -275,7 +275,6 @@ describe("Cursor provider", () => { it("builds the verified trusted read-only print JSON command shape", () => { const args = cursorAgentArgs( "/repo", - "prompt", { model: "cursor-model", reasoningEffort: "xhigh", @@ -295,7 +294,6 @@ describe("Cursor provider", () => { "ask", "--model", "cursor-model", - "prompt", ]); expect(args).not.toContain("--force"); expect(args).not.toContain("--yolo"); @@ -304,7 +302,6 @@ describe("Cursor provider", () => { it("leaves write-mode Cursor execution ungated by read-only mode flags", () => { const args = cursorAgentArgs( "/repo", - "prompt", { model: null, reasoningEffort: null, @@ -313,15 +310,7 @@ describe("Cursor provider", () => { false, ); - expect(args).toEqual([ - "--trust", - "-p", - "--output-format", - "json", - "--workspace", - "/repo", - "prompt", - ]); + expect(args).toEqual(["--trust", "-p", "--output-format", "json", "--workspace", "/repo"]); }); it("keeps Cursor provider execution disabled by default", async () => { diff --git a/src/provider.ts b/src/provider.ts index efac537..06848a5 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -202,7 +202,6 @@ const grokProvider: Provider = { const PI_DEFAULT_TIMEOUT_MS = 180_000; const CURSOR_DEFAULT_TIMEOUT_MS = 300_000; -const CURSOR_POSITIONAL_PROMPT_MAX_BYTES = 128_000; const CURSOR_MIN_SAFE_APP_VERSION = "2.5.0"; const CURSOR_DARWIN_INFO_PLIST = "/Applications/Cursor.app/Contents/Info.plist"; const CURSOR_EXPERIMENTAL_ENV = "CLAWPATCH_CURSOR_EXPERIMENTAL"; @@ -290,15 +289,8 @@ async function runCursorJson( readOnly: boolean, ): Promise { const fullPrompt = cursorPrompt(prompt, schema, readOnly); - if (Buffer.byteLength(fullPrompt, "utf8") > CURSOR_POSITIONAL_PROMPT_MAX_BYTES) { - throw new ClawpatchError( - `cursor provider prompt exceeds ${CURSOR_POSITIONAL_PROMPT_MAX_BYTES} byte positional prompt limit`, - 2, - "invalid-usage", - ); - } - const args = cursorAgentArgs(root, fullPrompt, options, readOnly); - const result = await runCursorAgent(root, args); + const args = cursorAgentArgs(root, options, readOnly); + const result = await runCursorAgent(root, args, fullPrompt); if (result.exitCode !== 0) { throw new ClawpatchError( cursorFailureMessage(result.stdout, result.stderr, result.exitCode), @@ -309,12 +301,7 @@ async function runCursorJson( return extractCursorJson(result.stdout); } -function cursorAgentArgs( - root: string, - prompt: string, - options: ProviderOptions, - readOnly: boolean, -): string[] { +function cursorAgentArgs(root: string, options: ProviderOptions, readOnly: boolean): string[] { const args = ["--trust", "-p", "--output-format", "json", "--workspace", root]; if (readOnly) { args.push("--mode", "ask"); @@ -322,12 +309,15 @@ function cursorAgentArgs( if (options.model !== null) { args.push("--model", options.model); } - args.push(prompt); return args; } -async function runCursorAgent(root: string, args: string[]): Promise { - return await runCommandArgs("cursor-agent", args, root, undefined, { +async function runCursorAgent( + root: string, + args: string[], + input?: string, +): Promise { + return await runCommandArgs("cursor-agent", args, root, input, { trimOutput: false, timeoutMs: cursorTimeoutMs(), env: cursorEnv(), From dc954113070c11d3d3141b0855d4393a9143f850 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 15:21:24 -0600 Subject: [PATCH 07/11] fix(provider): tighten cursor review evidence guidance --- src/provider.test.ts | 16 ++++++++++++++++ src/provider.ts | 14 +++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/provider.test.ts b/src/provider.test.ts index fda6d17..9fe8c30 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { ClawpatchError } from "./errors.js"; import { __testing, extractJson, providerByName } from "./provider.js"; import { safeProviderPreview } from "./provider-json.js"; +import { agentMapJsonSchema, reviewJsonSchema } from "./provider-schema.js"; import { revalidateOutputSchema, reviewOutputSchema } from "./types.js"; // eslint-disable-next-line no-underscore-dangle @@ -14,6 +15,7 @@ const { cursorAgentArgs, cursorEnv, cursorFailureMessage, + cursorPrompt, cursorTimeoutMs, extractAcpxJson, extractCursorJson, @@ -442,6 +444,20 @@ describe("Cursor provider", () => { expect(cursorTimeoutMs()).toBe(300_000); }); + it("adds Cursor-specific strict evidence guidance for reviews", () => { + const prompt = cursorPrompt("base review prompt", reviewJsonSchema, true); + + expect(prompt).toContain("Cursor evidence rules:"); + expect(prompt).toContain('Prefer "quote": null'); + expect(prompt).toContain("evidence.path must exactly match an included file path"); + }); + + it("does not add review evidence guidance to Cursor map prompts", () => { + const prompt = cursorPrompt("base map prompt", agentMapJsonSchema, true); + + expect(prompt).not.toContain("Cursor evidence rules:"); + }); + it("parses semver for Cursor advisory checks", () => { expect(parseSemver("2.4.9")).toEqual([2, 4, 9]); expect(parseSemver("v2.5")).toEqual([2, 5, 0]); diff --git a/src/provider.ts b/src/provider.ts index 06848a5..b9d8a36 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -332,7 +332,18 @@ function cursorPrompt(prompt: string, schema: object, readOnly: boolean): string "The Cursor CLI also receives --mode ask for this read-only request.\n\n" + prompt : prompt; - return `${promptBody} + const evidenceRules = + schema === reviewJsonSchema + ? ` + +Cursor evidence rules: +- Cite only files that are explicitly included in the prompt's file blocks. +- evidence.path must exactly match an included file path. +- If you provide startLine and endLine, copy them from the included file block and keep them inside that file's shown line range. +- Prefer "quote": null. Include a quote only when you copied it exactly from the included file contents. +- If you are unsure about an evidence quote or line range, set quote, startLine, and endLine to null instead of guessing.` + : ""; + return `${promptBody}${evidenceRules} Provider output schema: ${JSON.stringify(schema, null, 2)} @@ -1378,6 +1389,7 @@ export const __testing = { cursorAgentArgs, cursorEnv, cursorFailureMessage, + cursorPrompt, cursorTimeoutMs, extractCursorJson, extractAcpxJson, From b11499a9892df6476112e215561dfe40955e6e65 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 19:29:28 -0600 Subject: [PATCH 08/11] fix(review): keep cursor evidence prompt-bound --- docs/providers.md | 13 ++- src/prompt.test.ts | 57 ++++++++++- src/prompt.ts | 177 ++++++++++++++++++++++++++++++---- src/provider.test.ts | 36 ++++++- src/provider.ts | 12 ++- src/review-validation.test.ts | 57 ++++++++++- src/review-validation.ts | 17 +++- 7 files changed, 332 insertions(+), 37 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 28aa896..7c3a26b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -229,10 +229,15 @@ How the Cursor provider works: - Model names: pass Cursor model ids, for example `composer-2.5` for Composer 2.5 without fast mode - Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags -- Authentication: experimental execution uses the host user environment, so - either `CURSOR_API_KEY` or the user's existing Cursor login/keychain state can - be used. Clawpatch also sets `NO_OPEN_BROWSER=1` to reduce browser prompts - during headless runs. +- Authentication: experimental execution uses the host user environment and + passes `CURSOR_API_KEY` through when present. Prefer API-key auth for headless + runs; relying on the user's Cursor login can touch the macOS login keychain. + Clawpatch also sets `NO_OPEN_BROWSER=1` to reduce browser prompts during + headless runs. +- Read-only guard: map, review, and revalidate pass `--mode ask` and include + read-only instructions in the prompt. Clawpatch does not set + `CURSOR_CONFIG_DIR`, because that can bypass the user's existing Cursor auth + profile and trigger browser login prompts. - Timeout: 300 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` - Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for diff --git a/src/prompt.test.ts b/src/prompt.test.ts index c078de4..a10693d 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -21,13 +21,32 @@ describe("review prompt provenance", () => { }); expect(bundle.prompt).toContain("Prompt context:"); - expect(bundle.prompt).toContain("--- src/index.ts"); - expect(bundle.prompt).toContain("--- tests/index.test.ts"); + expect(bundle.prompt).toContain("--- src/index.ts (owned, lines 1-1)"); + expect(bundle.prompt).toContain("1 | export const value = 1;"); + expect(bundle.prompt).toContain("--- tests/index.test.ts (context, lines 1-1)"); expect(bundle.prompt).not.toContain("--- src/extra.ts"); + expect(bundle.prompt).toContain("Valid evidence paths are exactly:"); + expect(bundle.prompt).toContain("- src/index.ts"); + expect(bundle.prompt).toContain("- tests/index.test.ts"); + expect(bundle.prompt).not.toContain("- src/extra.ts"); + expect(bundle.prompt).not.toContain('"analysisHistory"'); + expect(bundle.prompt).not.toContain('"lock"'); expect(bundle.manifest.includedFiles).toEqual( expect.arrayContaining([ - expect.objectContaining({ path: "src/index.ts", role: "owned", truncated: false }), - expect.objectContaining({ path: "docs/large.md", role: "context", truncated: true }), + expect.objectContaining({ + path: "src/index.ts", + role: "owned", + includedStartLine: 1, + includedEndLine: 1, + truncated: false, + }), + expect.objectContaining({ + path: "docs/large.md", + role: "context", + includedStartLine: 1, + includedEndLine: expect.any(Number), + truncated: true, + }), ]), ); expect(bundle.manifest.omittedFiles).toEqual([ @@ -51,6 +70,36 @@ describe("review prompt provenance", () => { expect(bundle.manifest.includedFiles).toEqual( expect.arrayContaining([expect.objectContaining({ path: "docs/large.md", truncated: true })]), ); + expect(bundle.prompt).toContain("--- docs/large.md (context, lines 1-1, truncated)"); + expect(bundle.prompt).toContain("...[truncated after line 1]"); + }); + + it("includes linked tests as valid review evidence", async () => { + const root = await fixtureRoot("clawpatch-prompt-linked-tests-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "src/extra.ts", "export const extra = 1;\n"); + await writeFixture(root, "tests/index.test.ts", "expect(1).toBe(1);\n"); + await writeFixture(root, "docs/large.md", "docs\n"); + const linkedTestFeature = { + ...feature(), + contextFiles: [], + tests: [{ path: "tests/index.test.ts", command: null }], + }; + + const bundle = await buildReviewPromptBundle( + root, + project(root), + linkedTestFeature, + defaultConfig(), + ); + + expect(bundle.prompt).toContain("--- tests/index.test.ts (test, lines 1-1)"); + expect(bundle.prompt).toContain("- tests/index.test.ts"); + expect(bundle.manifest.includedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "tests/index.test.ts", role: "test" }), + ]), + ); }); }); diff --git a/src/prompt.ts b/src/prompt.ts index e5078a1..9bba350 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -6,13 +6,21 @@ export type ReviewMode = "default" | "deslopify"; export const REVIEW_PROMPT_FILE_CHAR_LIMIT = 24_000; -export type ReviewPromptFileRole = "owned" | "context"; +export type ReviewPromptFileRole = "owned" | "context" | "test"; + +export type ReviewPromptLineRange = { + startLine: number; + endLine: number; +}; export type ReviewPromptFileManifest = { path: string; role: ReviewPromptFileRole; bytes: number; includedBytes: number; + includedStartLine: number | null; + includedEndLine: number | null; + includedLineRanges: ReviewPromptLineRange[]; truncated: boolean; readable: boolean; skippedReason: string | null; @@ -101,8 +109,14 @@ export async function buildReviewPromptBundle( mode: ReviewMode = "default", customPrompt: string | null = null, ): Promise { - const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles); - const context = feature.contextFiles.slice(0, config.review.maxContextFiles); + const seenPromptFiles = new Set(); + const owned = uniquePromptRefs(feature.ownedFiles, config.review.maxOwnedFiles, seenPromptFiles); + const context = uniquePromptRefs( + feature.contextFiles, + config.review.maxContextFiles, + seenPromptFiles, + ); + const tests = uniquePromptRefs(feature.tests, config.review.maxContextFiles, seenPromptFiles); const omittedFiles = [ ...feature.ownedFiles.slice(config.review.maxOwnedFiles).map((ref) => ({ path: ref.path, @@ -114,6 +128,11 @@ export async function buildReviewPromptBundle( role: "context" as const, reason: "maxContextFiles", })), + ...feature.tests.slice(config.review.maxContextFiles).map((ref) => ({ + path: ref.path, + role: "test" as const, + reason: "maxContextFiles", + })), ]; const fileBlocks: string[] = []; const includedFiles: ReviewPromptFileManifest[] = []; @@ -127,6 +146,11 @@ export async function buildReviewPromptBundle( fileBlocks.push(file.block); includedFiles.push(file.manifest); } + for (const ref of tests) { + const file = await fileBlockWithManifest(root, ref.path, "test"); + fileBlocks.push(file.block); + includedFiles.push(file.manifest); + } const customBlock = customPrompt !== null && customPrompt.trim() !== "" ? `Additional reviewer guidance (provided via --prompt-file): @@ -138,15 +162,32 @@ ${customPrompt.trim()} const promptContext = { maxOwnedFiles: config.review.maxOwnedFiles, maxContextFiles: config.review.maxContextFiles, - includedFiles: includedFiles.map(({ path, role, bytes, includedBytes, truncated }) => ({ - path, - role, - bytes, - includedBytes, - truncated, - })), + includedFiles: includedFiles.map( + ({ + path, + role, + bytes, + includedBytes, + includedStartLine, + includedEndLine, + includedLineRanges, + truncated, + }) => ({ + path, + role, + bytes, + includedBytes, + includedStartLine, + includedEndLine, + includedLineRanges, + truncated, + }), + ), omittedFiles, }; + const validEvidencePaths = [ + ...new Set(includedFiles.filter((file) => file.readable).map((file) => file.path)), + ]; const prompt = `You are reviewing one semantic feature for clawpatch. Return strict JSON only. No markdown fences. @@ -155,7 +196,7 @@ Project: ${JSON.stringify({ name: project.name, detected: project.detected }, null, 2)} Feature: -${JSON.stringify(feature, null, 2)} +${JSON.stringify(reviewFeatureView(feature), null, 2)} ${customBlock}Review categories: - correctness bugs @@ -180,6 +221,13 @@ issues: when the same bug pattern appears in multiple owned files, emit one find with multiple evidence refs instead of separate one-off findings. Avoid speculative low-evidence findings. Evidence must point at included files. +Valid evidence paths are exactly: +${validEvidencePaths.map((path) => `- ${path}`).join("\n")} +Feature metadata paths are not valid evidence unless listed above. +When providing evidence line ranges, use the line-number gutter in the Files section. +Do not inspect files beyond the shown excerpts for evidence. If an excerpt is truncated, +only cite lines that appear in the Files section. +Set evidence.quote to null; line ranges are enough for validation. Prompt context: ${JSON.stringify(promptContext, null, 2)} @@ -219,6 +267,42 @@ ${fileBlocks.join("\n\n")}`; }; } +function reviewFeatureView(feature: FeatureRecord): object { + return { + featureId: feature.featureId, + title: feature.title, + summary: feature.summary, + kind: feature.kind, + source: feature.source, + confidence: feature.confidence, + entrypoints: feature.entrypoints, + ownedFiles: feature.ownedFiles, + contextFiles: feature.contextFiles, + tests: feature.tests, + tags: feature.tags, + trustBoundaries: feature.trustBoundaries, + }; +} + +function uniquePromptRefs( + refs: readonly T[], + limit: number, + seen: Set, +): T[] { + const selected: T[] = []; + for (const ref of refs) { + if (selected.length >= limit) { + break; + } + if (seen.has(ref.path)) { + continue; + } + seen.add(ref.path); + selected.push(ref); + } + return selected; +} + function reviewModeInstructions(mode: ReviewMode): string { if (mode === "default") { return ""; @@ -356,6 +440,9 @@ async function fileBlockWithManifest( role, bytes: 0, includedBytes: 0, + includedStartLine: null, + includedEndLine: null, + includedLineRanges: [], truncated: false, readable: false, skippedReason: "unreadable", @@ -363,18 +450,20 @@ async function fileBlockWithManifest( }; } const bytes = Buffer.byteLength(contents, "utf8"); - const truncated = contents.length > REVIEW_PROMPT_FILE_CHAR_LIMIT; - const trimmed = truncated - ? `${contents.slice(0, REVIEW_PROMPT_FILE_CHAR_LIMIT)}\n...[truncated]` - : contents; + const excerpt = prefixExcerpt(contents); return { - block: `--- ${path}\n${trimmed}`, + block: `--- ${path} (${role}, ${rangeLabel(excerpt.includedLineRanges)}${ + excerpt.truncated ? ", truncated" : "" + })\n${excerpt.body}`, manifest: { path, role, bytes, - includedBytes: Buffer.byteLength(trimmed, "utf8"), - truncated, + includedBytes: Buffer.byteLength(excerpt.includedContents, "utf8"), + includedStartLine: excerpt.includedLineRanges[0]?.startLine ?? null, + includedEndLine: excerpt.includedLineRanges.at(-1)?.endLine ?? null, + includedLineRanges: excerpt.includedLineRanges, + truncated: excerpt.truncated, readable: true, skippedReason: null, }, @@ -393,6 +482,9 @@ function skippedFileBlock( role, bytes: 0, includedBytes: 0, + includedStartLine: null, + includedEndLine: null, + includedLineRanges: [], truncated: false, readable: false, skippedReason: reason, @@ -400,6 +492,55 @@ function skippedFileBlock( }; } +function prefixExcerpt(contents: string): { + body: string; + includedContents: string; + includedLineRanges: ReviewPromptLineRange[]; + truncated: boolean; +} { + const truncated = contents.length > REVIEW_PROMPT_FILE_CHAR_LIMIT; + const includedContents = truncated ? contents.slice(0, REVIEW_PROMPT_FILE_CHAR_LIMIT) : contents; + const includedEndLine = reviewLineCount(includedContents); + const body = `${numberedFileContents(includedContents, 1)}${ + truncated ? `\n...[truncated after line ${includedEndLine}]` : "" + }`; + return { + body, + includedContents, + includedLineRanges: [{ startLine: 1, endLine: includedEndLine }], + truncated, + }; +} + +function rangeLabel(ranges: readonly ReviewPromptLineRange[]): string { + if (ranges.length === 0) { + return "no lines"; + } + if (ranges.length === 1) { + const range = ranges[0]!; + return `lines ${range.startLine}-${range.endLine}`; + } + return `lines ${ranges.map((range) => `${range.startLine}-${range.endLine}`).join(", ")}`; +} + +function numberedFileContents(contents: string, startLine: number): string { + return splitReviewLines(contents) + .map((line, index) => `${startLine + index} | ${line}`) + .join("\n"); +} + +function reviewLineCount(contents: string): number { + return splitReviewLines(contents).length; +} + +function splitReviewLines(contents: string): string[] { + if (contents.length === 0) { + return [""]; + } + const lines = contents.split("\n"); + return contents.endsWith("\n") ? lines.slice(0, -1) : lines; +} + function isInside(root: string, candidate: string): boolean { const relativePath = relative(root, candidate); return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); diff --git a/src/provider.test.ts b/src/provider.test.ts index 9fe8c30..77b79d1 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -432,9 +432,36 @@ describe("Cursor provider", () => { }); it("sets Cursor headless browser suppression without replacing the host environment", () => { - expect(cursorEnv()).toEqual({ - NO_OPEN_BROWSER: "1", - }); + const previous = process.env["CURSOR_API_KEY"]; + try { + delete process.env["CURSOR_API_KEY"]; + expect(cursorEnv()).toEqual({ + NO_OPEN_BROWSER: "1", + }); + } finally { + if (previous === undefined) { + delete process.env["CURSOR_API_KEY"]; + } else { + process.env["CURSOR_API_KEY"] = previous; + } + } + }); + + it("passes CURSOR_API_KEY through the explicit Cursor env overlay when present", () => { + const previous = process.env["CURSOR_API_KEY"]; + try { + process.env["CURSOR_API_KEY"] = "cursor_test_key"; + expect(cursorEnv()).toEqual({ + NO_OPEN_BROWSER: "1", + CURSOR_API_KEY: "cursor_test_key", + }); + } finally { + if (previous === undefined) { + delete process.env["CURSOR_API_KEY"]; + } else { + process.env["CURSOR_API_KEY"] = previous; + } + } }); it("uses a 300 second default timeout for Cursor", () => { @@ -448,8 +475,9 @@ describe("Cursor provider", () => { const prompt = cursorPrompt("base review prompt", reviewJsonSchema, true); expect(prompt).toContain("Cursor evidence rules:"); - expect(prompt).toContain('Prefer "quote": null'); + expect(prompt).toContain("Always set evidence.quote to null"); expect(prompt).toContain("evidence.path must exactly match an included file path"); + expect(prompt).toContain("Do not use files outside the prompt excerpts as evidence"); }); it("does not add review evidence guidance to Cursor map prompts", () => { diff --git a/src/provider.ts b/src/provider.ts index b9d8a36..0464042 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -340,8 +340,9 @@ Cursor evidence rules: - Cite only files that are explicitly included in the prompt's file blocks. - evidence.path must exactly match an included file path. - If you provide startLine and endLine, copy them from the included file block and keep them inside that file's shown line range. -- Prefer "quote": null. Include a quote only when you copied it exactly from the included file contents. -- If you are unsure about an evidence quote or line range, set quote, startLine, and endLine to null instead of guessing.` +- Do not use files outside the prompt excerpts as evidence. +- Always set evidence.quote to null. +- If you are unsure about an evidence line range, set startLine and endLine to null instead of guessing.` : ""; return `${promptBody}${evidenceRules} @@ -476,8 +477,10 @@ function cursorTimeoutMs(): number { } function cursorEnv(): NodeJS.ProcessEnv { + const apiKey = process.env["CURSOR_API_KEY"]; return { NO_OPEN_BROWSER: "1", + ...(apiKey === undefined ? {} : { CURSOR_API_KEY: apiKey }), }; } @@ -796,7 +799,10 @@ function firstPromptFileWith(prompt: string, marker: string): string | null { if (newline === -1) { continue; } - const path = block.slice(0, newline).trim(); + const path = block + .slice(0, newline) + .replace(/ \([^)]*\)$/u, "") + .trim(); const contents = block.slice(newline + 1); if (path.length > 0 && contents.includes(marker)) { return path; diff --git a/src/review-validation.test.ts b/src/review-validation.test.ts index 27db552..89148e2 100644 --- a/src/review-validation.test.ts +++ b/src/review-validation.test.ts @@ -38,6 +38,25 @@ describe("validateReviewOutput", () => { ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); }); + it("accepts evidence from linked tests included in review context", async () => { + const root = await fixtureRoot("clawpatch-review-validation-test-file-"); + await writeFixture(root, "src/index.ts", "const value = 'safe';\n"); + await writeFixture(root, "src/index.test.ts", "const value = 'TODO_BUG';\n"); + + await expect( + validateReviewOutput( + root, + { + ...feature("src/index.ts"), + tests: [{ path: "src/index.test.ts", command: null }], + }, + defaultConfig(), + manifest("src/index.test.ts", { role: "test" }), + output("src/index.test.ts"), + ), + ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); + }); + it("rejects evidence for files that were not included in review context", async () => { const root = await fixtureRoot("clawpatch-review-validation-path-"); await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); @@ -104,6 +123,23 @@ describe("validateReviewOutput", () => { ).rejects.toMatchObject({ code: "malformed-output" }); }); + it("rejects line-only evidence outside the included prompt range", async () => { + const root = await fixtureRoot("clawpatch-review-validation-prompt-range-"); + await writeFixture(root, "src/index.ts", "const first = 'safe';\nconst second = 'TODO_BUG';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts", { + includedLineRanges: [{ startLine: 1, endLine: 1 }], + }), + output("src/index.ts", { startLine: 2, endLine: 2, quote: null }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); + it("rejects evidence that only exists beyond the truncated prompt text", async () => { const root = await fixtureRoot("clawpatch-review-validation-truncated-"); await writeFixture(root, "src/index.ts", `${"a".repeat(24_000)}\nconst value = 'TODO_TAIL';\n`); @@ -147,7 +183,11 @@ function feature(path: string): FeatureRecord { function output( path: string, - evidence: { startLine?: number | null; endLine?: number | null; quote?: string | null } = {}, + evidence: { + startLine?: number | null; + endLine?: number | null; + quote?: string | null; + } = {}, ): ReviewOutput { return { findings: [ @@ -179,18 +219,29 @@ function output( function manifest( path: string, - options: { truncated?: boolean; readable?: boolean } = {}, + options: { + truncated?: boolean; + readable?: boolean; + includedLineRanges?: Array<{ startLine: number; endLine: number }>; + role?: "owned" | "context" | "test"; + } = {}, ): ReviewPromptManifest { const readable = options.readable ?? true; + const includedLineRanges = readable + ? (options.includedLineRanges ?? [{ startLine: 1, endLine: 1 }]) + : []; return { maxOwnedFiles: defaultConfig().review.maxOwnedFiles, maxContextFiles: defaultConfig().review.maxContextFiles, includedFiles: [ { path, - role: "owned", + role: options.role ?? "owned", bytes: readable ? 1 : 0, includedBytes: readable ? 1 : 0, + includedStartLine: includedLineRanges[0]?.startLine ?? null, + includedEndLine: includedLineRanges.at(-1)?.endLine ?? null, + includedLineRanges, truncated: options.truncated ?? false, readable, skippedReason: readable ? null : "unreadable", diff --git a/src/review-validation.ts b/src/review-validation.ts index 7566a70..de531a3 100644 --- a/src/review-validation.ts +++ b/src/review-validation.ts @@ -28,7 +28,7 @@ export async function validateReviewOutput( throwMalformed(`evidence file was not readable in review context: ${evidence.path}`); } const contents = await fileContents(root, evidence.path, promptFile.truncated, cache); - assertLineRange(contents, evidence); + assertLineRange(contents, evidence, promptFile); assertQuote(contents, evidence); } } @@ -40,6 +40,7 @@ function includedReviewPaths(feature: FeatureRecord, config: ClawpatchConfig): S [ ...feature.ownedFiles.slice(0, config.review.maxOwnedFiles).map((ref) => ref.path), ...feature.contextFiles.slice(0, config.review.maxContextFiles).map((ref) => ref.path), + ...feature.tests.slice(0, config.review.maxContextFiles).map((ref) => ref.path), ].map(normalizePath), ); } @@ -92,6 +93,7 @@ async function readIncludedFile(root: string, path: string, truncated: boolean): function assertLineRange( contents: string, evidence: ReviewOutput["findings"][number]["evidence"][number], + promptFile: ReviewPromptManifest["includedFiles"][number], ): void { const { startLine, endLine } = evidence; if (startLine === null && endLine === null) { @@ -109,6 +111,19 @@ function assertLineRange( `evidence line range exceeds file length: ${evidence.path}:${startLine}-${endLine}`, ); } + if (!rangeIncluded(startLine, endLine, promptFile.includedLineRanges)) { + throwMalformed( + `evidence line range was not included in review context: ${evidence.path}:${startLine}-${endLine}`, + ); + } +} + +function rangeIncluded( + startLine: number, + endLine: number, + ranges: readonly { startLine: number; endLine: number }[], +): boolean { + return ranges.some((range) => startLine >= range.startLine && endLine <= range.endLine); } function reviewLineCount(contents: string): number { From 2fcf569ceef2a765daa053705064228227eb8316 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Wed, 20 May 2026 08:26:50 -0600 Subject: [PATCH 09/11] fix(provider): harden cursor review validation --- src/provider.test.ts | 10 +++++++++- src/provider.ts | 6 +++++- src/review-validation.test.ts | 26 ++++++++++++++++++++++++++ src/review-validation.ts | 14 +++++--------- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/provider.test.ts b/src/provider.test.ts index 77b79d1..0c7e038 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -489,7 +489,9 @@ describe("Cursor provider", () => { it("parses semver for Cursor advisory checks", () => { expect(parseSemver("2.4.9")).toEqual([2, 4, 9]); expect(parseSemver("v2.5")).toEqual([2, 5, 0]); - expect(parseSemver("2026.05.16-0338208")).toEqual([2026, 5, 16]); + expect(parseSemver("2026.05.16-0338208")).toBeNull(); + expect(parseSemver("2.5.0-beta")).toBeNull(); + expect(parseSemver("2.5beta")).toBeNull(); }); it("uses Cursor app version for date-formatted CLI builds", () => { @@ -504,6 +506,12 @@ describe("Cursor provider", () => { /could not verify Cursor app\/runtime version/u, ); }); + + it("does not treat date-formatted app builds as advisory proof", () => { + expect(() => + assertCursorRuntimeVersionAllowed("2026.05.16-0338208", "2026.05.16-0338208"), + ).toThrow(/could not verify Cursor app\/runtime version/u); + }); }); function schemaKeys(value: unknown): string[] { diff --git a/src/provider.ts b/src/provider.ts index 0464042..2c00572 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -533,7 +533,11 @@ function isCursorDateBuildVersion(version: string): boolean { } function parseSemver(version: string): [number, number, number] | null { - const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?/u.exec(version.trim()); + const trimmed = version.trim(); + if (isCursorDateBuildVersion(trimmed)) { + return null; + } + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?$/u.exec(trimmed); if (match === null) { return null; } diff --git a/src/review-validation.test.ts b/src/review-validation.test.ts index 89148e2..6ea9e5c 100644 --- a/src/review-validation.test.ts +++ b/src/review-validation.test.ts @@ -57,6 +57,32 @@ describe("validateReviewOutput", () => { ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); }); + it("accepts evidence from files selected after duplicate prompt refs are skipped", async () => { + const root = await fixtureRoot("clawpatch-review-validation-duplicate-context-"); + await writeFixture(root, "src/index.ts", "const value = 'safe';\n"); + await writeFixture(root, "src/context-one.ts", "const value = 'safe';\n"); + await writeFixture(root, "src/context-two.ts", "const value = 'TODO_BUG';\n"); + const config = defaultConfig(); + config.review.maxContextFiles = 2; + + await expect( + validateReviewOutput( + root, + { + ...feature("src/index.ts"), + contextFiles: [ + { path: "src/index.ts", reason: "duplicate owned file" }, + { path: "src/context-one.ts", reason: "context" }, + { path: "src/context-two.ts", reason: "context" }, + ], + }, + config, + manifest("src/context-two.ts", { role: "context" }), + output("src/context-two.ts"), + ), + ).resolves.toMatchObject({ findings: [{ title: "Bug" }] }); + }); + it("rejects evidence for files that were not included in review context", async () => { const root = await fixtureRoot("clawpatch-review-validation-path-"); await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); diff --git a/src/review-validation.ts b/src/review-validation.ts index de531a3..cac61eb 100644 --- a/src/review-validation.ts +++ b/src/review-validation.ts @@ -11,7 +11,9 @@ export async function validateReviewOutput( manifest: ReviewPromptManifest, output: ReviewOutput, ): Promise { - const included = includedReviewPaths(feature, config); + void feature; + void config; + const included = includedReviewPaths(manifest); const promptFiles = new Map( manifest.includedFiles.map((file) => [normalizePath(file.path), file]), ); @@ -35,14 +37,8 @@ export async function validateReviewOutput( return { ...output, findings }; } -function includedReviewPaths(feature: FeatureRecord, config: ClawpatchConfig): Set { - return new Set( - [ - ...feature.ownedFiles.slice(0, config.review.maxOwnedFiles).map((ref) => ref.path), - ...feature.contextFiles.slice(0, config.review.maxContextFiles).map((ref) => ref.path), - ...feature.tests.slice(0, config.review.maxContextFiles).map((ref) => ref.path), - ].map(normalizePath), - ); +function includedReviewPaths(manifest: ReviewPromptManifest): Set { + return new Set(manifest.includedFiles.map((file) => normalizePath(file.path))); } function assertIncludedPath(path: string, included: ReadonlySet, label: string): void { From 5709e9ae063e1ad2ff66d7d28253b88bb257ecd8 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Wed, 20 May 2026 09:47:56 -0600 Subject: [PATCH 10/11] fix(provider): harden cursor validation --- docs/providers.md | 6 ++-- src/exec.ts | 14 ++------ src/prompt.test.ts | 61 +++++++++++++++++++++++++++++++++++ src/prompt.ts | 51 +++++++++++++++++++---------- src/provider.test.ts | 8 +++++ src/provider.ts | 51 ++++++++++++++++------------- src/review-validation.test.ts | 21 ++++++++++-- src/review-validation.ts | 11 +++++++ 8 files changed, 167 insertions(+), 56 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 7c3a26b..1480941 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -240,8 +240,10 @@ How the Cursor provider works: profile and trigger browser login prompts. - Timeout: 300 seconds by default, override with `CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` -- Advisory handling: semver-like Cursor versions below `2.5.0` are blocked for - CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r +- Advisory handling: semver-like Cursor CLI versions below `2.5.0` are blocked + for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r. Date-formatted CLI builds are + allowed only when Clawpatch can verify a semver Cursor app version from the + local macOS app bundle. Permission caveat: Cursor's print mode is documented as having access to tools, including write and shell. Clawpatch therefore keeps Cursor execution behind diff --git a/src/exec.ts b/src/exec.ts index 1c73747..5e339af 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -66,23 +66,13 @@ export async function runCommandArgs( args: string[], cwd: string, input?: string, - options: { - trimOutput?: boolean; - env?: NodeJS.ProcessEnv; - timeoutMs?: number; - replaceEnv?: boolean; - } = {}, + options: { trimOutput?: boolean; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, ): Promise { const started = Date.now(); const spawnSpec = commandSpawnSpec(program, args); const child = spawn(spawnSpec.program, spawnSpec.args, { cwd, - env: - options.env === undefined - ? process.env - : options.replaceEnv === true - ? options.env - : { ...process.env, ...options.env }, + env: options.env === undefined ? process.env : { ...process.env, ...options.env }, detached: process.platform !== "win32" && options.timeoutMs !== undefined, shell: false, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/prompt.test.ts b/src/prompt.test.ts index a10693d..a58b86c 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -101,6 +101,67 @@ describe("review prompt provenance", () => { ]), ); }); + + it("does not list duplicate-skipped included files as omitted", async () => { + const root = await fixtureRoot("clawpatch-prompt-duplicate-context-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "src/context-one.ts", "export const one = 1;\n"); + await writeFixture(root, "src/context-two.ts", "export const two = 1;\n"); + await writeFixture(root, "src/context-three.ts", "export const three = 1;\n"); + const duplicateFeature = { + ...feature(), + ownedFiles: [{ path: "src/index.ts", reason: "primary" }], + contextFiles: [ + { path: "src/index.ts", reason: "duplicate owned file" }, + { path: "src/context-one.ts", reason: "context" }, + { path: "src/context-two.ts", reason: "context" }, + { path: "src/context-three.ts", reason: "overflow" }, + ], + }; + + const bundle = await buildReviewPromptBundle(root, project(root), duplicateFeature, { + ...defaultConfig(), + review: { + ...defaultConfig().review, + maxOwnedFiles: 1, + maxContextFiles: 2, + }, + }); + + expect(bundle.prompt).toContain("--- src/context-two.ts (context, lines 1-1)"); + expect(bundle.manifest.omittedFiles).toEqual([ + { path: "src/context-three.ts", role: "context", reason: "maxContextFiles" }, + ]); + }); + + it("de-duplicates equivalent prompt paths before applying limits", async () => { + const root = await fixtureRoot("clawpatch-prompt-normalized-duplicates-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "src/next.ts", "export const next = 1;\n"); + const duplicateFeature = { + ...feature(), + ownedFiles: [ + { path: "src/index.ts", reason: "primary" }, + { path: "./src/index.ts", reason: "duplicate spelling" }, + { path: "src/next.ts", reason: "next real file" }, + ], + contextFiles: [], + }; + + const bundle = await buildReviewPromptBundle(root, project(root), duplicateFeature, { + ...defaultConfig(), + review: { + ...defaultConfig().review, + maxOwnedFiles: 2, + }, + }); + + expect(bundle.manifest.includedFiles.map((file) => file.path)).toEqual([ + "src/index.ts", + "src/next.ts", + ]); + expect(bundle.manifest.omittedFiles).toEqual([]); + }); }); function project(root: string): ProjectRecord { diff --git a/src/prompt.ts b/src/prompt.ts index 9bba350..6f37d8c 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -117,22 +117,15 @@ export async function buildReviewPromptBundle( seenPromptFiles, ); const tests = uniquePromptRefs(feature.tests, config.review.maxContextFiles, seenPromptFiles); + const includedPromptPaths = new Set([ + ...owned.map((ref) => normalizePromptPath(ref.path)), + ...context.map((ref) => normalizePromptPath(ref.path)), + ...tests.map((ref) => normalizePromptPath(ref.path)), + ]); const omittedFiles = [ - ...feature.ownedFiles.slice(config.review.maxOwnedFiles).map((ref) => ({ - path: ref.path, - role: "owned" as const, - reason: "maxOwnedFiles", - })), - ...feature.contextFiles.slice(config.review.maxContextFiles).map((ref) => ({ - path: ref.path, - role: "context" as const, - reason: "maxContextFiles", - })), - ...feature.tests.slice(config.review.maxContextFiles).map((ref) => ({ - path: ref.path, - role: "test" as const, - reason: "maxContextFiles", - })), + ...omittedPromptRefs(feature.ownedFiles, "owned", "maxOwnedFiles", includedPromptPaths), + ...omittedPromptRefs(feature.contextFiles, "context", "maxContextFiles", includedPromptPaths), + ...omittedPromptRefs(feature.tests, "test", "maxContextFiles", includedPromptPaths), ]; const fileBlocks: string[] = []; const includedFiles: ReviewPromptFileManifest[] = []; @@ -294,15 +287,39 @@ function uniquePromptRefs( if (selected.length >= limit) { break; } - if (seen.has(ref.path)) { + const normalizedPath = normalizePromptPath(ref.path); + if (seen.has(normalizedPath)) { continue; } - seen.add(ref.path); + seen.add(normalizedPath); selected.push(ref); } return selected; } +function omittedPromptRefs( + refs: readonly T[], + role: ReviewPromptFileRole, + reason: string, + includedPaths: ReadonlySet, +): Array<{ path: string; role: ReviewPromptFileRole; reason: string }> { + const omitted: Array<{ path: string; role: ReviewPromptFileRole; reason: string }> = []; + const seen = new Set(); + for (const ref of refs) { + const normalizedPath = normalizePromptPath(ref.path); + if (includedPaths.has(normalizedPath) || seen.has(normalizedPath)) { + continue; + } + seen.add(normalizedPath); + omitted.push({ path: ref.path, role, reason }); + } + return omitted; +} + +function normalizePromptPath(path: string): string { + return path.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); +} + function reviewModeInstructions(mode: ReviewMode): string { if (mode === "default") { return ""; diff --git a/src/provider.test.ts b/src/provider.test.ts index afb7770..ec39d9e 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -534,6 +534,7 @@ describe("Cursor provider", () => { expect(prompt).toContain("Always set evidence.quote to null"); expect(prompt).toContain("evidence.path must exactly match an included file path"); expect(prompt).toContain("Do not use files outside the prompt excerpts as evidence"); + expect(prompt).toContain("Every evidence item must include startLine and endLine"); }); it("does not add review evidence guidance to Cursor map prompts", () => { @@ -557,6 +558,13 @@ describe("Cursor provider", () => { ); }); + it("uses semver CLI versions as the authoritative runtime version", () => { + expect(() => assertCursorRuntimeVersionAllowed("2.5.0", "2.4.9")).not.toThrow(); + expect(() => assertCursorRuntimeVersionAllowed("2.4.9", "3.2.16")).toThrow( + /blocked vulnerable Cursor version/u, + ); + }); + it("does not treat date-formatted CLI builds as advisory proof by themselves", () => { expect(() => assertCursorRuntimeVersionAllowed("2026.05.16-0338208", null)).toThrow( /could not verify Cursor app\/runtime version/u, diff --git a/src/provider.ts b/src/provider.ts index 30227e9..37d2b35 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -484,23 +484,12 @@ const piProvider: Provider = { const cursorProvider: Provider = { name: "cursor", async check(root: string): Promise { - const result = await runCursorAgent(root, ["--version"]); - if (result.exitCode !== 0) { - throw new ClawpatchError( - "cursor-agent CLI not available or not authenticated", - 4, - "provider-auth", - ); - } - const version = result.stdout.trim(); - const appVersion = await cursorAppVersion(); - assertCursorRuntimeVersionAllowed(version, appVersion); - return appVersion === null ? version : `${version} (Cursor app ${appVersion})`; + return await checkedCursorRuntimeVersion(root); }, async map(root: string, prompt: string, options: ProviderOptions): Promise { assertCursorProviderEnabled("map"); const output = await runCursorJson(root, prompt, options, agentMapJsonSchema, true); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "cursor agent-map"); }, async review( root: string, @@ -515,7 +504,7 @@ const cursorProvider: Provider = { assertCursorProviderEnabled("fix"); assertCursorWriteEnabled(); const output = await runCursorJson(root, prompt, options, fixPlanJsonSchema, false); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "cursor fix-plan"); }, async revalidate( root: string, @@ -524,7 +513,7 @@ const cursorProvider: Provider = { ): Promise { assertCursorProviderEnabled("revalidate"); const output = await runCursorJson(root, prompt, options, revalidateJsonSchema, true); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "cursor revalidate"); }, }; @@ -535,6 +524,7 @@ async function runCursorJson( schema: object, readOnly: boolean, ): Promise { + await checkedCursorRuntimeVersion(root); const fullPrompt = cursorPrompt(prompt, schema, readOnly); const args = cursorAgentArgs(root, options, readOnly); const result = await runCursorAgent(root, args, fullPrompt); @@ -548,6 +538,21 @@ async function runCursorJson( return extractCursorJson(result.stdout); } +async function checkedCursorRuntimeVersion(root: string): Promise { + const result = await runCursorAgent(root, ["--version"]); + if (result.exitCode !== 0) { + throw new ClawpatchError( + "cursor-agent CLI not available or not authenticated", + 4, + "provider-auth", + ); + } + const version = result.stdout.trim(); + const appVersion = await cursorAppVersion(); + assertCursorRuntimeVersionAllowed(version, appVersion); + return appVersion === null ? version : `${version} (Cursor app ${appVersion})`; +} + function cursorAgentArgs(root: string, options: ProviderOptions, readOnly: boolean): string[] { const args = ["--trust", "-p", "--output-format", "json", "--workspace", root]; if (readOnly) { @@ -589,7 +594,7 @@ Cursor evidence rules: - If you provide startLine and endLine, copy them from the included file block and keep them inside that file's shown line range. - Do not use files outside the prompt excerpts as evidence. - Always set evidence.quote to null. -- If you are unsure about an evidence line range, set startLine and endLine to null instead of guessing.` +- Every evidence item must include startLine and endLine from the shown file block.` : ""; return `${promptBody}${evidenceRules} @@ -747,15 +752,17 @@ async function cursorAppVersion(): Promise { function assertCursorRuntimeVersionAllowed(cliVersion: string, appVersion: string | null): void { const parsedCli = parseSemver(cliVersion); - const parsedApp = appVersion === null ? null : parseSemver(appVersion); - if (appVersion !== null && parsedApp !== null) { - assertCursorVersionAllowed(appVersion, parsedApp); - return; - } - if (parsedCli !== null && !isCursorDateBuildVersion(cliVersion)) { + if (parsedCli !== null) { assertCursorVersionAllowed(cliVersion, parsedCli); return; } + if (isCursorDateBuildVersion(cliVersion) && appVersion !== null) { + const parsedApp = parseSemver(appVersion); + if (parsedApp !== null) { + assertCursorVersionAllowed(appVersion, parsedApp); + return; + } + } throw new ClawpatchError( "cursor provider could not verify Cursor app/runtime version for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r", 4, diff --git a/src/review-validation.test.ts b/src/review-validation.test.ts index 379799b..1ad6d17 100644 --- a/src/review-validation.test.ts +++ b/src/review-validation.test.ts @@ -166,6 +166,21 @@ describe("validateReviewOutput", () => { ).rejects.toMatchObject({ code: "malformed-output" }); }); + it("rejects evidence without a line range or quote", async () => { + const root = await fixtureRoot("clawpatch-review-validation-empty-evidence-"); + await writeFixture(root, "src/index.ts", "const value = 'TODO_BUG';\n"); + + await expect( + validateReviewOutput( + root, + feature("src/index.ts"), + defaultConfig(), + manifest("src/index.ts"), + output("src/index.ts", { startLine: null, endLine: null, quote: null }), + ), + ).rejects.toMatchObject({ code: "malformed-output" }); + }); + it("rejects evidence that only exists beyond the truncated prompt text", async () => { const root = await fixtureRoot("clawpatch-review-validation-truncated-"); await writeFixture(root, "src/index.ts", `${"a".repeat(24_000)}\nconst value = 'TODO_TAIL';\n`); @@ -327,10 +342,10 @@ function output( evidence: [ { path, - startLine: evidence.startLine ?? 1, - endLine: evidence.endLine ?? 1, + startLine: "startLine" in evidence ? evidence.startLine! : 1, + endLine: "endLine" in evidence ? evidence.endLine! : 1, symbol: null, - quote: evidence.quote ?? "TODO_BUG", + quote: "quote" in evidence ? evidence.quote! : "TODO_BUG", }, ], reasoning: "Reason.", diff --git a/src/review-validation.ts b/src/review-validation.ts index 23cf68a..446acab 100644 --- a/src/review-validation.ts +++ b/src/review-validation.ts @@ -89,6 +89,7 @@ async function validateFinding( throwMalformed(`evidence file was not readable in review context: ${evidence.path}`); } const contents = await fileContents(root, evidence.path, promptFile.truncated, cache); + assertVerifiableEvidence(evidence); assertLineRange(contents, evidence, promptFile); assertQuote(contents, evidence); } @@ -119,6 +120,16 @@ function assertIncludedPath(path: string, included: ReadonlySet, label: } } +function assertVerifiableEvidence( + evidence: ReviewOutput["findings"][number]["evidence"][number], +): void { + const hasLineRange = evidence.startLine !== null || evidence.endLine !== null; + const hasQuote = evidence.quote !== null && evidence.quote.trim().length > 0; + if (!hasLineRange && !hasQuote) { + throwMalformed(`evidence must include a line range or quote: ${evidence.path}`); + } +} + function assertSafePath(path: string, label: string): void { const normalized = normalizePath(path); if (normalized.startsWith("../") || isAbsolute(normalized)) { From 27cf2488a1a9bff9a5987c9c9dffe2a1c67f9486 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Wed, 20 May 2026 10:09:43 -0600 Subject: [PATCH 11/11] fix(prompt): normalize fix evidence paths --- src/prompt.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++-- src/prompt.ts | 30 ++++++++++++++------ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/prompt.test.ts b/src/prompt.test.ts index a58b86c..ad13906 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { REVIEW_PROMPT_FILE_CHAR_LIMIT, buildReviewPromptBundle } from "./prompt.js"; +import { + REVIEW_PROMPT_FILE_CHAR_LIMIT, + buildFixPrompt, + buildReviewPromptBundle, +} from "./prompt.js"; import { defaultConfig } from "./config.js"; import { fixtureRoot, writeFixture } from "./test-helpers.js"; -import type { FeatureRecord, ProjectRecord } from "./types.js"; +import type { FeatureRecord, FindingRecord, ProjectRecord } from "./types.js"; describe("review prompt provenance", () => { it("records included, omitted, and truncated review prompt context", async () => { @@ -162,6 +166,32 @@ describe("review prompt provenance", () => { ]); expect(bundle.manifest.omittedFiles).toEqual([]); }); + + it("includes fix evidence paths that differ only by normalized spelling", async () => { + const root = await fixtureRoot("clawpatch-fix-prompt-normalized-evidence-"); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + await writeFixture(root, "src/other.ts", "export const other = 1;\n"); + const normalizedFeature = { + ...feature(), + entrypoints: [], + ownedFiles: [{ path: "./src/index.ts", reason: "primary" }], + contextFiles: [], + tests: [], + }; + + const prompt = await buildFixPrompt(root, finding("src/index.ts"), normalizedFeature, { + ...defaultConfig(), + review: { + ...defaultConfig().review, + maxOwnedFiles: 0, + maxContextFiles: 0, + }, + }); + + expect(prompt).toContain("--- ./src/index.ts"); + expect(prompt).toContain("export const value = 1;"); + expect(prompt).not.toContain("--- src/other.ts"); + }); }); function project(root: string): ProjectRecord { @@ -219,3 +249,39 @@ function feature(): FeatureRecord { updatedAt: now, }; } + +function finding(path: string): FindingRecord { + const now = new Date().toISOString(); + return { + schemaVersion: 1, + findingId: "fnd_prompt", + featureId: "feat_prompt", + title: "Prompt finding", + category: "bug", + severity: "medium", + confidence: "high", + triage: "confirmed-bug", + evidence: [ + { + path, + startLine: 1, + endLine: 1, + symbol: null, + quote: null, + }, + ], + reasoning: "The file needs a fix.", + reproduction: null, + recommendation: "Fix the file.", + whyTestsDoNotAlreadyCoverThis: "", + suggestedRegressionTest: null, + minimumFixScope: "src/index.ts", + status: "open", + history: [], + signature: "sig_prompt", + linkedPatchAttemptIds: [], + createdByRunId: "run_prompt", + createdAt: now, + updatedAt: now, + }; +} diff --git a/src/prompt.ts b/src/prompt.ts index 6f37d8c..800ede8 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -402,20 +402,34 @@ function fixPromptPaths( const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles); const context = feature.contextFiles.slice(0, config.review.maxContextFiles); const tests = feature.tests.slice(0, config.review.maxContextFiles); - const allowed = new Set([ - ...feature.ownedFiles.map((ref) => ref.path), - ...feature.contextFiles.map((ref) => ref.path), - ...feature.tests.map((test) => test.path), - ...feature.entrypoints.map((entrypoint) => entrypoint.path), - ]); + const allowed = new Map(); + const allowPath = (path: string): void => { + const normalizedPath = normalizePromptPath(path); + if (!allowed.has(normalizedPath)) { + allowed.set(normalizedPath, path); + } + }; + for (const ref of feature.ownedFiles) { + allowPath(ref.path); + } + for (const ref of feature.contextFiles) { + allowPath(ref.path); + } + for (const test of feature.tests) { + allowPath(test.path); + } + for (const entrypoint of feature.entrypoints) { + allowPath(entrypoint.path); + } const push = (path: string): void => { if (!paths.includes(path)) { paths.push(path); } }; for (const evidence of finding.evidence) { - if (allowed.has(evidence.path)) { - push(evidence.path); + const allowedPath = allowed.get(normalizePromptPath(evidence.path)); + if (allowedPath !== undefined) { + push(allowedPath); } } for (const ref of owned) {