Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
56 changes: 51 additions & 5 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/deepsec/src/__tests__/agent-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
4 changes: 4 additions & 0 deletions packages/deepsec/src/__tests__/resolve-agent-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions packages/deepsec/src/agent-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
4 changes: 2 additions & 2 deletions packages/deepsec/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ program
.option("--run-id <id>", "Resume a specific processing run")
.option(
"--agent <type>",
"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 <model>",
Expand Down Expand Up @@ -208,7 +208,7 @@ program
.option("--run-id <id>", "Resume a specific revalidation run")
.option(
"--agent <type>",
"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 <model>",
Expand Down
151 changes: 151 additions & 0 deletions packages/processor/src/__tests__/cursor-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<AgentProgress, { results: unknown; meta: unknown }>) {
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);
});
});
3 changes: 2 additions & 1 deletion packages/processor/src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
Loading