diff --git a/docs/configuration.md b/docs/configuration.md index 15e24e01..ac155e54 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,7 @@ see [`samples/webapp/deepsec.config.ts`](../samples/webapp/deepsec.config.ts). | `projects` | `ProjectDeclaration[]` | The codebases deepsec knows about. | | `plugins` | `DeepsecPlugin[]` | Loaded in order; later plugins override single-slot capabilities. | | `matchers` | `{ only?: string[]; exclude?: string[] }` | Filter the matcher set used by `scan`. | -| `defaultAgent` | `string` | Default `--agent` value (`codex`, `claude`, or `pi`). See [models.md](models.md). | +| `defaultAgent` | `string` | Default `--agent` value (`codex`, `claude`, `pi`, or `cursor`). See [models.md](models.md). | | `dataDir` | `string` | Override the `data/` directory. Defaults to `./data`. | ## ProjectDeclaration @@ -118,6 +118,9 @@ backend you're using. | `OPENAI_API_KEY` | `--agent codex`, `--agent pi --model openai/...` | Codex SDK token or Pi OpenAI-provider token. Unset is fine if `AI_GATEWAY_API_KEY` is set. | | `OPENAI_BASE_URL` | `--agent codex` | Default (when `AI_GATEWAY_API_KEY` is set): `https://ai-gateway.vercel.sh/v1`. | | `PI_CODING_AGENT_DIR` | `--agent pi` | Optional Pi config/auth directory. Defaults to `~/.pi/agent`; local non-sandbox runs can reuse `auth.json` there. | +| `CURSOR_API_KEY` | `--agent cursor` | Cursor CLI credential. Optional if you've run `cursor-agent login` on this machine. | +| `CURSOR_AUTH_TOKEN` | `--agent cursor` | Cursor CLI session token — alternative to `CURSOR_API_KEY`, useful for unattended/container runs with no interactive login. | +| `CURSOR_AGENT_BIN` | `--agent cursor` | Path to the `cursor-agent` binary. Defaults to `cursor-agent` on `PATH`. | | `DEEPSEC_AGENT_DEBUG` | both backends | Set to `1` to enable verbose agent logging. | | `DEEPSEC_DATA_ROOT` | core | Override the data directory location. Equivalent to `dataDir` in config. | diff --git a/docs/models.md b/docs/models.md index da746ba5..75536939 100644 --- a/docs/models.md +++ b/docs/models.md @@ -10,13 +10,21 @@ deepsec talks to LLMs through interchangeable agent backends: | `codex` (default) | `gpt-5.5` | `process`, `revalidate` | | `claude` | `claude-opus-4-8` | `process`, `revalidate` | | `pi` | `zai/glm-5.2` | `process`, `revalidate` | +| `cursor` | `auto` | `process`, `revalidate` | | `claude` (triage) | `claude-sonnet-4-6` | `triage` (Claude-only) | -The built-in backends work with [Vercel AI Gateway](https://vercel.com/ai-gateway). -One `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` covers Codex, Claude, -and Pi. Pi also accepts provider/model identifiers directly through its -model registry, which makes it useful for comparing gateway/provider -behavior under the same deepsec workload. +The `codex`, `claude` and `pi` backends work with +[Vercel AI Gateway](https://vercel.com/ai-gateway). One +`AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` covers all three. Pi also +accepts provider/model identifiers directly through its model registry, +which makes it useful for comparing gateway/provider behavior under the +same deepsec workload. + +The `cursor` backend is different: it drives the **Cursor CLI** +(`cursor-agent`) rather than a model endpoint, so it authenticates +through Cursor (a `cursor-agent login` session or `CURSOR_API_KEY`) and +does not use the gateway. See [the Cursor CLI backend](#the-cursor-cli-backend) +below. ## CLI selection @@ -39,6 +47,12 @@ pnpm deepsec process --project-id my-app --agent pi # Pi with an AI SDK / AI Gateway style model id: pnpm deepsec process --project-id my-app --agent pi --model zai/glm-5.2 +# Cursor CLI backend (uses your cursor-agent login), default model (auto): +pnpm deepsec process --project-id my-app --agent cursor + +# Cursor with an explicit model: +pnpm deepsec process --project-id my-app --agent cursor --model sonnet-4.5 + # Triage uses Claude; pass a cheaper model if you want: pnpm deepsec triage --project-id my-app --model claude-haiku-4-5 ``` @@ -100,6 +114,38 @@ Repeat `--ai-header name=value` for provider-specific headers. There is no Martian-specific first-class integration; these flags are the generic provider override path. +### The Cursor CLI backend + +`--agent cursor` drives the [Cursor CLI](https://docs.cursor.com/cli) +(`cursor-agent`) headless. Unlike the other backends, which POST to a +model endpoint over HTTP, Cursor runs its own agent loop — the CLI +does the file reads, globs and shell calls, streams newline-delimited +JSON events, and deepsec parses those into its progress stream and pulls +the findings JSON out of the final message. + +```bash +# Log in once (or export CURSOR_API_KEY), then: +cursor-agent login +pnpm deepsec process --project-id my-app --agent cursor +``` + +- **Auth** comes from the `cursor-agent` session (`~/.cursor`), a + `CURSOR_API_KEY`, or a `CURSOR_AUTH_TOKEN` session token in the env — the + last runs unattended in a fresh container with no interactive login. + deepsec injects nothing; the Vercel AI Gateway env (`AI_GATEWAY_API_KEY` + etc.) is not used and there is no preflight credential check for this + backend. +- **Model** defaults to `auto` (Cursor picks). Pass any Cursor model id + with `--model` (e.g. `sonnet-4.5`, `gpt-5`); run `cursor-agent + --list-models` to see what your plan exposes. +- **Binary** resolves to `cursor-agent` on `PATH`; override with + `CURSOR_AGENT_BIN`. +- The CLI is spawned with `--force` (no interactive approvals) and + `--trust` (skip the workspace-trust prompt) so it runs unattended on a + fresh checkout; the investigation prompt instructs it to read only. + Cost is not reported by the CLI, so the per-batch readout shows tokens + but no `$` figure. + ### `claude-sonnet-4-6` for `triage` Triage buckets findings into P0/P1/P2/skip without re-reading the code diff --git a/packages/deepsec/src/__tests__/agent-defaults.test.ts b/packages/deepsec/src/__tests__/agent-defaults.test.ts index 8aa2ecd0..f1434507 100644 --- a/packages/deepsec/src/__tests__/agent-defaults.test.ts +++ b/packages/deepsec/src/__tests__/agent-defaults.test.ts @@ -5,6 +5,7 @@ describe("defaultModelForAgent", () => { it("returns the backend-specific default models", () => { expect(defaultModelForAgent("codex")).toBe("gpt-5.5"); expect(defaultModelForAgent("pi")).toBe("zai/glm-5.2"); + expect(defaultModelForAgent("cursor")).toBe("auto"); expect(defaultModelForAgent("claude-agent-sdk")).toBe("claude-opus-4-8"); }); }); diff --git a/packages/deepsec/src/__tests__/resolve-agent-type.test.ts b/packages/deepsec/src/__tests__/resolve-agent-type.test.ts index d9daa6e8..b6adf3c3 100644 --- a/packages/deepsec/src/__tests__/resolve-agent-type.test.ts +++ b/packages/deepsec/src/__tests__/resolve-agent-type.test.ts @@ -20,6 +20,10 @@ describe("resolveAgentType", () => { expect(resolveAgentType("pi")).toBe("pi"); }); + it("accepts cursor as a built-in agent type", () => { + expect(resolveAgentType("cursor")).toBe("cursor"); + }); + it("aliases claude from defaultAgent config", () => { setLoadedConfig(defineConfig({ projects: [], defaultAgent: "claude" })); expect(resolveAgentType(undefined)).toBe("claude-agent-sdk"); diff --git a/packages/deepsec/src/agent-defaults.ts b/packages/deepsec/src/agent-defaults.ts index 18ed21a2..b5615fed 100644 --- a/packages/deepsec/src/agent-defaults.ts +++ b/packages/deepsec/src/agent-defaults.ts @@ -8,6 +8,8 @@ export function defaultModelForAgent(agentType: string): string { return "gpt-5.5"; case "pi": return "zai/glm-5.2"; + case "cursor": + return "auto"; default: return "claude-opus-4-8"; } diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index 94d52ed8..62938c88 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -133,7 +133,7 @@ program .option("--run-id ", "Resume a specific processing run") .option( "--agent ", - "Agent plugin type: codex, claude, or pi (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, pi, or cursor (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", @@ -208,7 +208,7 @@ program .option("--run-id ", "Resume a specific revalidation run") .option( "--agent ", - "Agent plugin type: codex, claude, or pi (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, pi, or cursor (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", diff --git a/packages/processor/src/__tests__/cursor-cli.test.ts b/packages/processor/src/__tests__/cursor-cli.test.ts new file mode 100644 index 00000000..8d3e0e8a --- /dev/null +++ b/packages/processor/src/__tests__/cursor-cli.test.ts @@ -0,0 +1,151 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { FileRecord } from "@deepsec/core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CursorCliPlugin } from "../agents/cursor-cli.js"; +import type { AgentProgress } from "../agents/types.js"; + +// A fake `cursor-agent` that ignores its args and prints a canned +// stream-json transcript: init → a Read tool call → an assistant message +// carrying the fenced findings JSON → a result with usage. Lets us exercise +// the plugin's event parsing without a Cursor login/seat. +function writeFakeCursorAgent(dir: string, events: object[]): string { + const bin = path.join(dir, "fake-cursor-agent"); + const jsonl = events.map((e) => JSON.stringify(e)).join("\n"); + writeFileSync( + bin, + `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(`${jsonl}\n`)});\n`, + ); + chmodSync(bin, 0o755); + return bin; +} + +function makeRecord(filePath: string): FileRecord { + return { + filePath, + projectId: "test", + candidates: [ + { vulnSlug: "sql-injection", lineNumbers: [7], snippet: "q", matchedPattern: "concat" }, + ], + lastScannedAt: "2026-04-01T00:00:00Z", + lastScannedRunId: "run1", + fileHash: "abc", + findings: [], + analysisHistory: [], + status: "pending", + }; +} + +async function drain(gen: AsyncGenerator) { + const progress: AgentProgress[] = []; + let res = await gen.next(); + while (!res.done) { + progress.push(res.value); + res = await gen.next(); + } + return { progress, output: res.value }; +} + +describe("CursorCliPlugin.investigate", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "deepsec-cursor-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env.CURSOR_AGENT_BIN; + }); + + it("parses findings out of the CLI's stream-json transcript", async () => { + const findingsBlock = [ + { + filePath: "src/auth.js", + findings: [ + { + severity: "CRITICAL", + vulnSlug: "sql-injection", + title: "SQL injection via req.query.name", + description: "User input concatenated into a SQL string.", + lineNumbers: [7], + recommendation: "Use parameterized queries.", + confidence: "high", + }, + ], + }, + ]; + process.env.CURSOR_AGENT_BIN = writeFakeCursorAgent(dir, [ + { type: "system", subtype: "init", session_id: "sess-1", model: "Auto" }, + { + type: "tool_call", + subtype: "started", + tool_call: { readToolCall: { args: { path: "src/auth.js" } } }, + }, + { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: `\`\`\`json\n${JSON.stringify(findingsBlock)}\n\`\`\`` }], + }, + }, + { + type: "result", + subtype: "success", + is_error: false, + result: "done", + usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 5, cacheWriteTokens: 0 }, + }, + ]); + + const plugin = new CursorCliPlugin(); + const { progress, output } = await drain( + plugin.investigate({ + batch: [makeRecord("src/auth.js")], + projectRoot: dir, + promptTemplate: "SYSTEM", + projectInfo: "INFO", + config: {}, + }), + ); + + const results = output.results as Array<{ filePath: string; findings: unknown[] }>; + const auth = results.find((r) => r.filePath === "src/auth.js"); + expect(auth?.findings).toHaveLength(1); + expect((auth?.findings[0] as { vulnSlug: string }).vulnSlug).toBe("sql-injection"); + + // The Read tool call surfaced as a tool_use progress event, and the + // session id / usage rode through into the batch meta. + expect(progress.some((p) => p.type === "tool_use" && p.message.startsWith("Read"))).toBe(true); + expect((output.meta as { agentSessionId?: string }).agentSessionId).toBe("sess-1"); + expect((output.meta as { usage?: { inputTokens: number } }).usage?.inputTokens).toBe(100); + }); + + it("marks files with no reported finding as clean", async () => { + const findingsBlock = [{ filePath: "src/safe.js", findings: [] }]; + process.env.CURSOR_AGENT_BIN = writeFakeCursorAgent(dir, [ + { type: "system", subtype: "init", session_id: "sess-2", model: "Auto" }, + { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: `\`\`\`json\n${JSON.stringify(findingsBlock)}\n\`\`\`` }], + }, + }, + { type: "result", subtype: "success", is_error: false, result: "done" }, + ]); + + const plugin = new CursorCliPlugin(); + const { output } = await drain( + plugin.investigate({ + batch: [makeRecord("src/safe.js")], + projectRoot: dir, + promptTemplate: "SYSTEM", + projectInfo: "INFO", + config: {}, + }), + ); + const results = output.results as Array<{ filePath: string; findings: unknown[] }>; + expect(results.find((r) => r.filePath === "src/safe.js")?.findings).toHaveLength(0); + }); +}); diff --git a/packages/processor/src/__tests__/registry.test.ts b/packages/processor/src/__tests__/registry.test.ts index 2f9e06c1..d3062bed 100644 --- a/packages/processor/src/__tests__/registry.test.ts +++ b/packages/processor/src/__tests__/registry.test.ts @@ -57,6 +57,7 @@ describe("createDefaultAgentRegistry", () => { expect(registry.get("claude-agent-sdk")).toBeDefined(); expect(registry.get("codex")).toBeDefined(); expect(registry.get("pi")).toBeDefined(); - expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex", "pi"]); + expect(registry.get("cursor")).toBeDefined(); + expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex", "cursor", "pi"]); }); }); diff --git a/packages/processor/src/agents/cursor-cli.ts b/packages/processor/src/agents/cursor-cli.ts new file mode 100644 index 00000000..a63b158c --- /dev/null +++ b/packages/processor/src/agents/cursor-cli.ts @@ -0,0 +1,455 @@ +import { spawn } from "node:child_process"; +import { + buildInvestigateJsonRepairPrompt, + buildInvestigatePrompt, + buildRevalidateJsonRepairPrompt, + buildRevalidatePrompt, + classifyQuotaError, + formatJsonRepairFailureDebugText, + jsonRepairFailureError, + parseInvestigateResults, + parseRevalidateVerdicts, + QuotaExhaustedError, + writeParseFailureDebug, +} from "./shared.js"; +import type { + AgentPlugin, + AgentProgress, + BatchMeta, + InvestigateOutput, + InvestigateParams, + InvestigateResult, + RevalidateOutput, + RevalidateParams, + RevalidateVerdict, +} from "./types.js"; + +/** + * Cursor CLI agent plugin. + * + * Unlike claude-agent-sdk / codex (which talk to a model endpoint over + * HTTP), this backend drives the **Cursor CLI** (`cursor-agent`) headless. + * Cursor's own agent runs the tool-use loop (Read / Glob / Shell) against + * the checkout and prints newline-delimited JSON events; we parse those + * into deepsec's AgentProgress stream and pull the final findings JSON out + * of the assistant text. + * + * Why a CLI backend and not the HTTP path: the Cursor "seat" (Ratatoskr + * bridge) exposes an OpenAI/Anthropic-compatible *message* API but does NOT + * relay agentic `tool_use`, so claude/codex/pi driving it produced 0 tool + * calls → 0 findings. The CLI carries the loop natively. A later iteration + * can point the CLI at the seat via `--api-key` / custom headers once the + * bridge relays tools (see CURSOR_AGENT_* env below). + * + * Auth: the CLI uses whatever `cursor-agent` is logged into on the host + * (login session in ~/.cursor), a `CURSOR_API_KEY`, or a `CURSOR_AUTH_TOKEN` + * session token in the env — the last is what lets it run in a fresh + * container with no interactive login. Nothing is injected by this plugin, + * so `cursor` is intentionally NOT in preflight's KNOWN_BACKENDS + * (assertAgentCredential no-ops for it). + */ + +const DEFAULT_MODEL = "auto"; +const DEBUG = process.env.DEEPSEC_AGENT_DEBUG === "1"; + +// Resolved per-spawn (not at module load) so tests can point at a fake CLI +// via CURSOR_AGENT_BIN after import. +function cursorBin(): string { + return process.env.CURSOR_AGENT_BIN || "cursor-agent"; +} + +// Map a `tool_call` event's inner key (readToolCall / globToolCall / +// shellToolCall / …) to a short human label for progress lines. +function toolLabel(toolCall: Record): string { + const key = Object.keys(toolCall).find((k) => k.endsWith("ToolCall")); + if (!key) return "tool"; + const base = key.replace(/ToolCall$/, ""); + switch (base) { + case "read": + return "Read"; + case "glob": + return "Glob"; + case "grep": + return "Grep"; + case "shell": + return "Bash"; + default: + return base; + } +} + +// Pull a short target (path / pattern / command) out of a tool_call for the +// progress line, best-effort. +function toolTarget(toolCall: Record): string { + const key = Object.keys(toolCall).find((k) => k.endsWith("ToolCall")); + if (!key) return ""; + const inner = (toolCall[key] as { args?: Record })?.args ?? {}; + const raw = + (inner.path as string) || + (inner.globPattern as string) || + (inner.pattern as string) || + (inner.command as string) || + ""; + const oneLine = String(raw).split("\n")[0] ?? ""; + return oneLine.split("/").slice(-3).join("/").slice(0, 120); +} + +interface CursorUsage { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} + +function mapUsage(u: CursorUsage): BatchMeta["usage"] { + return { + inputTokens: u.inputTokens ?? 0, + outputTokens: u.outputTokens ?? 0, + cacheReadInputTokens: u.cacheReadTokens ?? 0, + cacheCreationInputTokens: u.cacheWriteTokens ?? 0, + }; +} + +interface CursorRunState { + agentMessages: string[]; + toolUseCount: number; + turnCount: number; + sessionId?: string; + usage?: BatchMeta["usage"]; + durationApiMs?: number; + isError: boolean; + errorMessage: string; +} + +/** + * Spawn `cursor-agent -p --output-format stream-json` and stream + * its JSONL events. Yields AgentProgress for tool calls; returns the + * accumulated run state (assistant messages, usage, session id, errors). + * + * `--force` runs all tools without approval prompts and `--trust` skips the + * workspace-trust prompt (both required for an unattended run on a fresh + * checkout). The scan prompt tells the agent to only read; a read-only mode + * + env allowlist are hardening follow-ups (see plugin header). + */ +async function* runCursor( + prompt: string, + projectRoot: string, + model: string, + signal: AbortSignal | undefined, +): AsyncGenerator { + const args = [ + "-p", + prompt, + "--output-format", + "stream-json", + "--force", + "--trust", + "--model", + model, + ]; + + const state: CursorRunState = { + agentMessages: [], + toolUseCount: 0, + turnCount: 0, + isError: false, + errorMessage: "", + }; + + const child = spawn(cursorBin(), args, { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + signal, + }); + + let stderrTail = ""; + child.stderr?.on("data", (d) => { + stderrTail = (stderrTail + d.toString()).slice(-4000); + }); + + // Buffer stdout and hand back complete JSON lines as they arrive. A queue + // decouples the (sync) 'data' handler from the (async) generator consumer. + const lineQueue: string[] = []; + let resolveWaiter: (() => void) | null = null; + let closed = false; + let buf = ""; + + const pushLines = (chunk: string) => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (line) lineQueue.push(line); + } + resolveWaiter?.(); + resolveWaiter = null; + }; + + child.stdout?.on("data", (d) => pushLines(d.toString())); + child.on("close", () => { + if (buf.trim()) lineQueue.push(buf.trim()); + buf = ""; + closed = true; + resolveWaiter?.(); + resolveWaiter = null; + }); + child.on("error", (e) => { + state.errorMessage = e.message; + state.isError = true; + closed = true; + resolveWaiter?.(); + resolveWaiter = null; + }); + + while (true) { + if (lineQueue.length === 0) { + if (closed) break; + await new Promise((r) => { + resolveWaiter = r; + }); + continue; + } + const line = lineQueue.shift() as string; + let ev: Record; + try { + ev = JSON.parse(line); + } catch { + continue; // non-JSON noise + } + + const type = ev.type as string; + if (type === "system" && ev.subtype === "init") { + state.sessionId = ev.session_id as string; + } else if (type === "assistant") { + const content = + (ev.message as { content?: Array<{ type: string; text?: string }> })?.content ?? []; + const text = content + .filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join(""); + if (text) state.agentMessages.push(text); + state.turnCount++; + } else if (type === "tool_call" && ev.subtype === "started") { + state.toolUseCount++; + const tc = ev.tool_call as Record; + yield { + type: "tool_use", + message: `${toolLabel(tc)}: ${toolTarget(tc)}`, + }; + } else if (type === "thinking" && ev.subtype === "completed" && DEBUG) { + yield { type: "thinking", message: "reasoning…" }; + } else if (type === "result") { + state.isError = !!ev.is_error; + if (ev.usage) state.usage = mapUsage(ev.usage as CursorUsage); + if (typeof ev.duration_api_ms === "number") state.durationApiMs = ev.duration_api_ms; + // The final `result` text is the concatenation of all assistant text; + // keep it as a fallback if no discrete assistant messages carried the + // JSON block. + if (state.agentMessages.length === 0 && typeof ev.result === "string") { + state.agentMessages.push(ev.result); + } + if (ev.is_error && typeof ev.result === "string") state.errorMessage = ev.result; + } + } + + if (state.isError && !state.errorMessage) { + state.errorMessage = stderrTail || "cursor-agent exited with error"; + } + return state; +} + +// Cursor CLI has Read/Glob/Shell tools natively (like Claude), so the base +// prompt mostly fits. A tiny preamble grounds the project root and pins the +// JSON-only output contract. +function cursorPreamble(projectRoot: string): string { + return `## Environment + +You are running inside the Cursor CLI agent (read-only investigation). + +- **Project root**: \`${projectRoot}\`. File paths in "Target Files" are RELATIVE to it. +- **Tools**: you have Read, Glob and Shell (\`rg\`, \`cat\`, \`sed -n\`, \`grep -r\`). Read each target file fully before judging; trace imports with Shell. Do NOT modify any file. +- **Output**: end your final message with a single fenced \`\`\`json ... \`\`\` block matching the schema in "Output Format". Narration before it is fine; the JSON block must be in your final message.`; +} + +function chooseFinalText(messages: string[]): string { + if (messages.length === 0) return ""; + for (let i = messages.length - 1; i >= 0; i--) { + if (/```json/.test(messages[i])) return messages[i]; + } + const joined = messages.join("\n\n"); + if (/```json/.test(joined)) return joined; + return messages[messages.length - 1] ?? ""; +} + +export class CursorCliPlugin implements AgentPlugin { + type = "cursor"; + + async *investigate(params: InvestigateParams): AsyncGenerator { + const { batch, projectRoot, promptTemplate, projectInfo, config, signal, projectId } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + + yield { + type: "started", + message: `Investigating ${batch.length} file(s) with Cursor CLI (${model})`, + }; + + const basePrompt = buildInvestigatePrompt({ promptTemplate, projectInfo, batch }); + const prompt = `${cursorPreamble(projectRoot)}\n\n${basePrompt}`; + const startTime = Date.now(); + + const gen = runCursor(prompt, projectRoot, model, signal); + let res = await gen.next(); + while (!res.done) { + yield res.value; + res = await gen.next(); + } + const state = res.value; + + const durationMs = Date.now() - startTime; + const sdkMeta: Partial = { + numTurns: state.turnCount, + agentSessionId: state.sessionId, + usage: state.usage, + durationApiMs: state.durationApiMs, + }; + + // Typed quota bail (best-effort — the CLI surfaces rate limits in the + // error text / stderr). + if (state.isError) { + const quota = classifyQuotaError(state.errorMessage, undefined); + if (quota) throw new QuotaExhaustedError(quota, state.errorMessage); + } + + const resultText = chooseFinalText(state.agentMessages); + if (!resultText) { + throw new Error( + `Cursor CLI produced no result. Last error: ${state.errorMessage || "(none captured)"}.`, + ); + } + + let parsed: InvestigateResult[]; + try { + parsed = parseInvestigateResults(resultText, batch); + } catch (err) { + // One JSON-only repair pass — re-run the CLI with a repair prompt (no + // thread resume in the CLI, so it's a fresh, cheap tool-less call). + yield { + type: "thinking", + message: "Cursor returned non-JSON investigation output; requesting JSON-only repair", + }; + const repairGen = runCursor( + buildInvestigateJsonRepairPrompt(batch), + projectRoot, + model, + signal, + ); + let rr = await repairGen.next(); + while (!rr.done) rr = await repairGen.next(); + const repairText = chooseFinalText(rr.value.agentMessages); + try { + parsed = parseInvestigateResults(repairText, batch); + yield { type: "thinking", message: "Cursor JSON repair succeeded" }; + } catch (repairErr) { + const combined = jsonRepairFailureError(err, repairErr); + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText: formatJsonRepairFailureDebugText(resultText, repairText), + error: combined, + batch, + }); + throw combined; + } + } + + const tokensStr = state.usage + ? ` ${state.usage.inputTokens + state.usage.outputTokens} tokens` + : ""; + yield { + type: "complete", + message: `Investigation complete (${(durationMs / 1000).toFixed(1)}s, ${state.turnCount} turns, ${state.toolUseCount} tool calls${tokensStr})`, + }; + + return { results: parsed, meta: { durationMs, ...sdkMeta } }; + } + + async *revalidate(params: RevalidateParams): AsyncGenerator { + const { batch, projectRoot, projectInfo, config, force = false, signal, projectId } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + + const built = buildRevalidatePrompt({ batch, projectRoot, projectInfo, force }); + const prompt = `${cursorPreamble(projectRoot)}\n\n${built.prompt}`; + + yield { + type: "started", + message: `Revalidating ${built.totalFindings} finding(s) across ${batch.length} file(s) with Cursor CLI (${model})`, + }; + + const startTime = Date.now(); + const gen = runCursor(prompt, projectRoot, model, signal); + let res = await gen.next(); + while (!res.done) { + yield res.value; + res = await gen.next(); + } + const state = res.value; + const durationMs = Date.now() - startTime; + const sdkMeta: Partial = { + numTurns: state.turnCount, + agentSessionId: state.sessionId, + usage: state.usage, + durationApiMs: state.durationApiMs, + }; + + if (state.isError) { + const quota = classifyQuotaError(state.errorMessage, undefined); + if (quota) throw new QuotaExhaustedError(quota, state.errorMessage); + } + + const resultText = chooseFinalText(state.agentMessages); + if (!resultText) { + throw new Error( + `Cursor CLI produced no revalidation result. Last error: ${state.errorMessage || "(none captured)"}.`, + ); + } + + let verdicts: RevalidateVerdict[]; + try { + verdicts = parseRevalidateVerdicts(resultText); + } catch (err) { + yield { + type: "thinking", + message: "Cursor returned non-JSON revalidation output; requesting JSON-only repair", + }; + const repairGen = runCursor(buildRevalidateJsonRepairPrompt(), projectRoot, model, signal); + let rr = await repairGen.next(); + while (!rr.done) rr = await repairGen.next(); + const repairText = chooseFinalText(rr.value.agentMessages); + try { + verdicts = parseRevalidateVerdicts(repairText); + yield { type: "thinking", message: "Cursor JSON repair succeeded" }; + } catch (repairErr) { + const combined = jsonRepairFailureError(err, repairErr); + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText: formatJsonRepairFailureDebugText(resultText, repairText), + error: combined, + batch, + }); + throw combined; + } + } + + yield { + type: "complete", + message: `Revalidation complete (${(durationMs / 1000).toFixed(1)}s, ${state.turnCount} turns, ${verdicts.length} verdicts)`, + }; + + return { verdicts, meta: { durationMs, ...sdkMeta } }; + } +} diff --git a/packages/processor/src/index.ts b/packages/processor/src/index.ts index 0928c8e6..5f58437d 100644 --- a/packages/processor/src/index.ts +++ b/packages/processor/src/index.ts @@ -21,6 +21,7 @@ import { import { noiseScore, readTechJson } from "@deepsec/scanner"; import { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; import { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +import { CursorCliPlugin } from "./agents/cursor-cli.js"; import { PiAgentPlugin } from "./agents/pi-sdk.js"; import { AgentRegistry } from "./agents/registry.js"; import { QuotaExhaustedError, type QuotaSource } from "./agents/shared.js"; @@ -37,6 +38,7 @@ import { languagesForBatch } from "./prompt/file-language.js"; export { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; export { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +export { CursorCliPlugin } from "./agents/cursor-cli.js"; export { PiAgentPlugin } from "./agents/pi-sdk.js"; export { AgentRegistry } from "./agents/registry.js"; export { @@ -64,6 +66,7 @@ export function createDefaultAgentRegistry(): AgentRegistry { registry.register(new ClaudeAgentSdkPlugin()); registry.register(new CodexAgentSdkPlugin()); registry.register(new PiAgentPlugin()); + registry.register(new CursorCliPlugin()); // Plugins can contribute additional backends via `agents: []` in their // DeepsecPlugin export. The shape is validated by AgentRegistry at use. for (const a of getRegistry().agents as AgentPlugin[]) {