@zeroroot-ai/zerocool 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gitlab.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
3
+ *
4
+ * The agent polls one project's pipelines on one branch. It never receives a
5
+ * webhook and GitLab holds no Gibson credential: the poll runs outbound from
6
+ * the sandbox on the tenant's own project access token, resolved through the
7
+ * harness `GetCredential` under the dispatch grant. That direction is the point
8
+ * — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
9
+ * only from inside a mission it was dispatched to), so the agent watches
10
+ * GitLab rather than GitLab calling Gibson.
11
+ *
12
+ * FINISHED MEANS `success`. A pipeline that failed published no image and left
13
+ * `main` unchanged, so there is nothing new to scan; treating it as a trigger
14
+ * would run a Scan mission against the image the previous pipeline built and
15
+ * report its findings against the new commit. `canceled` and `skipped` are the
16
+ * same case. The status this polls for is therefore `success`, named once in
17
+ * {@link TRIGGER_STATUS} rather than spelled at each call site.
18
+ *
19
+ * Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
20
+ * fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
21
+ * network and no token.
22
+ */
23
+ /** The pipeline status that triggers a Scan mission. See the module note. */
24
+ export const TRIGGER_STATUS = "success";
25
+ /** GitLab's public host, when a tenant names no other instance. */
26
+ export const GITLAB_DEFAULT_URL = "https://gitlab.com";
27
+ /**
28
+ * Read one pipeline out of GitLab's JSON. A row without an id or a sha is
29
+ * dropped rather than guessed at: a Scan mission keyed on a missing commit
30
+ * would scan whatever the workspace happened to hold.
31
+ */
32
+ export function parsePipeline(raw) {
33
+ if (!raw || typeof raw !== "object")
34
+ return undefined;
35
+ const p = raw;
36
+ const id = typeof p.id === "number" ? p.id : Number.NaN;
37
+ const sha = typeof p.sha === "string" ? p.sha : "";
38
+ if (!Number.isInteger(id) || id <= 0 || !sha)
39
+ return undefined;
40
+ return {
41
+ id,
42
+ sha,
43
+ status: typeof p.status === "string" ? p.status : "",
44
+ ref: typeof p.ref === "string" ? p.ref : "",
45
+ webUrl: typeof p.web_url === "string" ? p.web_url : "",
46
+ };
47
+ }
48
+ /**
49
+ * A {@link GitLabClient} over GitLab's REST API.
50
+ *
51
+ * The token travels in `PRIVATE-TOKEN`, which is what a project access token
52
+ * authenticates with. It is never logged and never placed in a URL, so it
53
+ * cannot reach the console stream through an error message that quotes the
54
+ * request.
55
+ */
56
+ export function gitlabRest(opts) {
57
+ const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
58
+ const project = encodeURIComponent(opts.projectPath);
59
+ const doFetch = opts.fetch ?? globalThis.fetch;
60
+ return {
61
+ async latestFinishedPipeline(ref) {
62
+ const url = `${base}/api/v4/projects/${project}/pipelines` +
63
+ `?ref=${encodeURIComponent(ref)}&status=${TRIGGER_STATUS}&order_by=id&sort=desc&per_page=1`;
64
+ const res = await doFetch(url, {
65
+ headers: { "PRIVATE-TOKEN": opts.token, Accept: "application/json" },
66
+ });
67
+ if (!res.ok) {
68
+ throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${res.status} ${res.statusText}`);
69
+ }
70
+ const body = await res.json();
71
+ if (!Array.isArray(body)) {
72
+ throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${typeof body}, expected an array`);
73
+ }
74
+ for (const row of body) {
75
+ const p = parsePipeline(row);
76
+ if (p)
77
+ return p;
78
+ }
79
+ return undefined;
80
+ },
81
+ };
82
+ }
@@ -1,4 +1,4 @@
1
- import { componentKnowledge, connectTaskHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
1
+ import { componentKnowledge, openTaskHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
2
2
  /**
3
3
  * Pick the grant this process should read the knowledge graph with.
4
4
  *
@@ -27,7 +27,7 @@ export function selectKnowledgeSource(session) {
27
27
  "widen this run's authority.");
28
28
  }
29
29
  return {
30
- knowledge: taskKnowledge(connectTaskHarness({
30
+ knowledge: taskKnowledge(openTaskHarness({
31
31
  endpoint,
32
32
  token,
33
33
  // A local or kind daemon may serve the callback listener without TLS.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * semgrep — the candidate producer for the source-analysis task
3
+ * (zerocool-plugins#87).
4
+ *
5
+ * semgrep runs the vendored ruleset (`semgrep/rules.yaml`, pinned with the
6
+ * package, never fetched) over a checkout and prints one JSON document. The
7
+ * parser below is pure and is tested against bytes recorded from semgrep
8
+ * 1.175.0 over the fixture repository, so a change in semgrep's output fails a
9
+ * unit test rather than a Scan mission.
10
+ *
11
+ * A match is a CANDIDATE, not a Finding. The model triages each one in
12
+ * `source-analysis.ts`; nothing here decides.
13
+ */
14
+ /** One semgrep match, reduced to what triage and a Finding need. */
15
+ export interface SemgrepCandidate {
16
+ /** The rule id, e.g. `rules.js-eval-of-request-input`. */
17
+ checkId: string;
18
+ /** Repository-relative path, as semgrep printed it. */
19
+ path: string;
20
+ line: number;
21
+ endLine: number;
22
+ message: string;
23
+ /** semgrep's own level: ERROR, WARNING or INFO. */
24
+ severity: string;
25
+ /** CWE strings from the rule's metadata, verbatim (`CWE-79: ...`). */
26
+ cwe: string[];
27
+ /** The rule's `metadata.category`, when it set one. */
28
+ category?: string;
29
+ /** The matched source lines. */
30
+ snippet: string;
31
+ }
32
+ /**
33
+ * Parse the JSON `semgrep scan --json` writes. Throws when the document is not
34
+ * JSON or carries an error semgrep itself marked as an error, because a partial
35
+ * candidate list would look like a clean checkout.
36
+ */
37
+ export declare function parseSemgrepOutput(stdout: string): SemgrepCandidate[];
38
+ /**
39
+ * semgrep prefixes a local rule's id with the config file's path, dot-joined
40
+ * (`app.semgrep.js-eval-any` in the image, `rules.js-eval-any` from a relative
41
+ * config). The rule is the last segment; the prefix is where the file sat. A
42
+ * Finding must name the rule, not the install path, so the prefix is dropped.
43
+ */
44
+ export declare function bareRuleId(checkId: string): string;
45
+ /** The vendored ruleset, resolved from the package it ships with. */
46
+ export declare function defaultRulesPath(): string;
47
+ export interface SemgrepRunOptions {
48
+ /** The checkout to scan. semgrep runs with this as its cwd and `.` as the target. */
49
+ dir: string;
50
+ /** Ruleset path. Defaults to the vendored one. */
51
+ rules?: string;
52
+ /** Binary to run. Overridable for a pinned install and for tests. */
53
+ bin?: string;
54
+ /** Hard deadline. The child is killed when it elapses. */
55
+ timeoutMs?: number;
56
+ }
57
+ /**
58
+ * Build the argv. Exported so a test can assert it.
59
+ *
60
+ * The target is `.` and the cwd is the checkout, on purpose: semgrep reports
61
+ * paths relative to the target, so a Finding's `file` is repository-relative
62
+ * rather than a sandbox path, and semgrep's default ignore list (which skips
63
+ * `test/` directories anywhere in the path) is judged against the checkout,
64
+ * not against wherever the sandbox mounted it.
65
+ */
66
+ export declare function semgrepArgs(opts: SemgrepRunOptions): string[];
67
+ /** A function that yields candidates for a checkout. The seam tests stub. */
68
+ export type SemgrepRunner = (opts: SemgrepRunOptions) => Promise<SemgrepCandidate[]>;
69
+ /**
70
+ * Run semgrep once over a checkout and return the candidates.
71
+ *
72
+ * semgrep exits 0 when the scan ran, with or without matches; anything else is
73
+ * a failed scan and throws with semgrep's stderr tail, so a task never reports
74
+ * "no findings" for a scan that did not happen.
75
+ */
76
+ export declare const runSemgrep: SemgrepRunner;
@@ -0,0 +1,111 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * Parse the JSON `semgrep scan --json` writes. Throws when the document is not
5
+ * JSON or carries an error semgrep itself marked as an error, because a partial
6
+ * candidate list would look like a clean checkout.
7
+ */
8
+ export function parseSemgrepOutput(stdout) {
9
+ let doc;
10
+ try {
11
+ doc = JSON.parse(stdout);
12
+ }
13
+ catch (e) {
14
+ throw new Error(`semgrep output is not JSON: ${e.message}`);
15
+ }
16
+ const fatal = (doc.errors ?? []).filter((err) => (err.level ?? "error") === "error");
17
+ if (fatal.length > 0) {
18
+ throw new Error(`semgrep reported ${fatal.length} error(s): ${fatal.map((err) => err.message ?? "?").join("; ")}`);
19
+ }
20
+ return (doc.results ?? []).map((r) => {
21
+ const cweRaw = r.extra?.metadata?.cwe;
22
+ const cwe = Array.isArray(cweRaw) ? cweRaw : cweRaw ? [cweRaw] : [];
23
+ return {
24
+ checkId: bareRuleId(r.check_id ?? ""),
25
+ path: r.path ?? "",
26
+ line: r.start?.line ?? 0,
27
+ endLine: r.end?.line ?? r.start?.line ?? 0,
28
+ message: (r.extra?.message ?? "").trim(),
29
+ severity: r.extra?.severity ?? "",
30
+ cwe,
31
+ ...(r.extra?.metadata?.category ? { category: r.extra.metadata.category } : {}),
32
+ snippet: r.extra?.lines ?? "",
33
+ };
34
+ });
35
+ }
36
+ /**
37
+ * semgrep prefixes a local rule's id with the config file's path, dot-joined
38
+ * (`app.semgrep.js-eval-any` in the image, `rules.js-eval-any` from a relative
39
+ * config). The rule is the last segment; the prefix is where the file sat. A
40
+ * Finding must name the rule, not the install path, so the prefix is dropped.
41
+ */
42
+ export function bareRuleId(checkId) {
43
+ const i = checkId.lastIndexOf(".");
44
+ return i >= 0 ? checkId.slice(i + 1) : checkId;
45
+ }
46
+ /** The vendored ruleset, resolved from the package it ships with. */
47
+ export function defaultRulesPath() {
48
+ return fileURLToPath(new URL("../semgrep/rules.yaml", import.meta.url));
49
+ }
50
+ /**
51
+ * Build the argv. Exported so a test can assert it.
52
+ *
53
+ * The target is `.` and the cwd is the checkout, on purpose: semgrep reports
54
+ * paths relative to the target, so a Finding's `file` is repository-relative
55
+ * rather than a sandbox path, and semgrep's default ignore list (which skips
56
+ * `test/` directories anywhere in the path) is judged against the checkout,
57
+ * not against wherever the sandbox mounted it.
58
+ */
59
+ export function semgrepArgs(opts) {
60
+ return [
61
+ "scan",
62
+ "--json",
63
+ "--quiet",
64
+ "--metrics=off",
65
+ "--disable-version-check",
66
+ "--config",
67
+ opts.rules ?? defaultRulesPath(),
68
+ ".",
69
+ ];
70
+ }
71
+ /**
72
+ * Run semgrep once over a checkout and return the candidates.
73
+ *
74
+ * semgrep exits 0 when the scan ran, with or without matches; anything else is
75
+ * a failed scan and throws with semgrep's stderr tail, so a task never reports
76
+ * "no findings" for a scan that did not happen.
77
+ */
78
+ export const runSemgrep = async (opts) => {
79
+ const bin = opts.bin ?? process.env.ZEROCOOL_SEMGREP_BIN ?? "semgrep";
80
+ const args = semgrepArgs(opts);
81
+ return await new Promise((resolve, reject) => {
82
+ const child = spawn(bin, args, {
83
+ cwd: opts.dir,
84
+ env: { ...process.env, SEMGREP_SEND_METRICS: "off", SEMGREP_ENABLE_VERSION_CHECK: "0" },
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ ...(opts.timeoutMs && opts.timeoutMs > 0 ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {}),
87
+ });
88
+ let stdout = "";
89
+ let stderr = "";
90
+ child.stdout.on("data", (d) => (stdout += d.toString()));
91
+ child.stderr.on("data", (d) => (stderr += d.toString()));
92
+ child.on("error", (e) => reject(new Error(`could not run ${bin}: ${e.message}`)));
93
+ child.on("close", (code, signal) => {
94
+ if (signal) {
95
+ reject(new Error(`semgrep timed out after ${opts.timeoutMs}ms (killed with ${signal})`));
96
+ return;
97
+ }
98
+ if (code !== 0) {
99
+ const tail = stderr.trim().split("\n").slice(-5).join("; ");
100
+ reject(new Error(`semgrep exited ${code}${tail ? `: ${tail}` : ""}`));
101
+ return;
102
+ }
103
+ try {
104
+ resolve(parseSemgrepOutput(stdout));
105
+ }
106
+ catch (e) {
107
+ reject(e);
108
+ }
109
+ });
110
+ });
111
+ };
@@ -0,0 +1,134 @@
1
+ import { type Finding, type Severity, type TaskHarness } from "@zeroroot-ai/sdk";
2
+ import type { FindingsBackend } from "./findings.js";
3
+ import { type SemgrepCandidate, type SemgrepRunner } from "./semgrep.js";
4
+ /**
5
+ * The source-analysis task (zerocool-plugins#87).
6
+ *
7
+ * semgrep produces candidates from a checkout; the model triages each one as
8
+ * real or noise; every real one is submitted as a Finding that names its
9
+ * Vulnerability (a CWE, or a platform id when the rule carries none), the file
10
+ * and line, and the model's reason. The task reads the checkout and writes
11
+ * Findings. It changes no file and pushes nothing — that is the Fix's job
12
+ * (zerocool-plugins#89), a different task with a different grant posture.
13
+ *
14
+ * Deterministic first, model second. semgrep decides what is a candidate; the
15
+ * model only decides whether a candidate is real, given the rule's message and
16
+ * the code around the match. A rule that never yields a real finding is
17
+ * removed from the ruleset rather than argued with in a prompt.
18
+ *
19
+ * Every seam is injectable — the semgrep runner, the model, the findings
20
+ * backend, the file reader — so the test in `source-analysis.test.ts` drives a
21
+ * whole pass over the fixture repository with no semgrep binary, no network,
22
+ * and no daemon.
23
+ */
24
+ /** What the model said about one candidate. */
25
+ export interface Triage {
26
+ verdict: "real" | "noise";
27
+ reason: string;
28
+ }
29
+ /** The model seam: one candidate in, one verdict out. */
30
+ export type TriageModel = (c: AnalysisCandidate, snippet: string) => Promise<Triage>;
31
+ /** A deduplicated candidate: one place, one Vulnerability, the rules that hit it. */
32
+ export interface AnalysisCandidate extends SemgrepCandidate {
33
+ /** `CWE-95`, or `zerocool:semgrep:<rule>` when the rule names no CWE. */
34
+ vulnerabilityId: string;
35
+ /** Every rule that matched this place for this Vulnerability. */
36
+ ruleIds: string[];
37
+ }
38
+ /**
39
+ * The Vulnerability id for a candidate. A CWE from the rule's metadata wins,
40
+ * normalized to `CWE-<n>` so the same weakness from two rules is one node in
41
+ * the graph. A rule with no CWE gets a platform id keyed by the rule, which is
42
+ * still stable across runs and repositories.
43
+ */
44
+ export declare function vulnerabilityId(c: SemgrepCandidate): string;
45
+ /**
46
+ * Collapse matches to one candidate per (path, line, Vulnerability). Two rules
47
+ * that flag the same eval on the same line are one weakness, and two Findings
48
+ * for it would be one duplicate for a person to close. The strongest rule's
49
+ * level and message are kept; every rule id is recorded.
50
+ */
51
+ export declare function dedupeCandidates(matches: SemgrepCandidate[]): AnalysisCandidate[];
52
+ /** Lines around a match, numbered, for the model and for the Finding's evidence. */
53
+ export declare function readSnippet(dir: string, c: SemgrepCandidate, read?: (path: string) => Promise<string>, context?: number): Promise<string>;
54
+ /**
55
+ * The triage prompt. The model gets the rule's message, the weakness, and the
56
+ * code; it answers with one JSON object. Exported so the wording is testable
57
+ * and so a reader can see exactly what the model is asked.
58
+ */
59
+ export declare function triageMessages(c: AnalysisCandidate, snippet: string): {
60
+ role: string;
61
+ content: string;
62
+ }[];
63
+ /**
64
+ * Parse the model's answer. Tolerant of prose around the object, strict about
65
+ * the verdict: anything but a clear "real" is noise, because a Finding the
66
+ * model did not clearly confirm is exactly the noise this task exists to drop.
67
+ */
68
+ export declare function parseTriage(text: string): Triage;
69
+ /**
70
+ * The model behind the task grant: one `LLMComplete` on the harness per
71
+ * candidate, on the tenant's own provider, through the harness so the call is
72
+ * traced and budgeted like every other model call the run makes.
73
+ */
74
+ export declare function harnessTriageModel(harness: TaskHarness, slot?: string): TriageModel;
75
+ /** semgrep's level mapped onto the Finding severity scale. */
76
+ export declare function findingSeverity(level: string): Severity;
77
+ /** What the Finding needs to say where it came from. */
78
+ export interface AnalysisProvenance {
79
+ missionId?: string;
80
+ /** `https://gitlab.com/examplebank/customer-portal`, when the task knows it. */
81
+ repository?: string;
82
+ commit?: string;
83
+ targetId?: string;
84
+ }
85
+ /**
86
+ * Build the Finding for a real candidate. The Vulnerability id, the file and
87
+ * the line travel in the title, the tags and the evidence, so they survive the
88
+ * typed `SubmitFinding` mapping, which carries no free-form metadata.
89
+ */
90
+ export declare function candidateFinding(c: AnalysisCandidate, triage: Triage, snippet: string, prov: AnalysisProvenance): Finding;
91
+ export interface SourceAnalysisOptions {
92
+ /** The checkout to analyze. */
93
+ dir: string;
94
+ model: TriageModel;
95
+ findings: FindingsBackend;
96
+ provenance?: AnalysisProvenance;
97
+ /** Seams. */
98
+ semgrep?: SemgrepRunner;
99
+ readFile?: (path: string) => Promise<string>;
100
+ rules?: string;
101
+ semgrepTimeoutMs?: number;
102
+ /** Progress events, one object per step, for the console stream. */
103
+ onEvent?: (event: Record<string, unknown>) => void;
104
+ }
105
+ /** What one pass produced. */
106
+ export interface SourceAnalysisSummary {
107
+ matches: number;
108
+ candidates: number;
109
+ real: number;
110
+ noise: number;
111
+ /** Ids of the Findings submitted, in order. */
112
+ findingIds: string[];
113
+ /** Candidates the model or the backend failed on, with the error. Never silent. */
114
+ failed: {
115
+ candidate: string;
116
+ error: string;
117
+ }[];
118
+ /** Every verdict, for the terminal result and the MR note. */
119
+ verdicts: {
120
+ candidate: string;
121
+ vulnerabilityId: string;
122
+ verdict: Triage["verdict"];
123
+ reason: string;
124
+ }[];
125
+ }
126
+ /**
127
+ * One pass: scan, dedupe, triage, submit. A candidate the model or the backend
128
+ * fails on is recorded in `failed` and the pass continues, so one bad answer
129
+ * costs one candidate rather than the whole scan. The pass never throws for a
130
+ * clean checkout; it throws only when semgrep itself could not run.
131
+ */
132
+ export declare function runSourceAnalysis(opts: SourceAnalysisOptions): Promise<SourceAnalysisSummary>;
133
+ /** The human-readable result the mission node records. */
134
+ export declare function formatSourceAnalysis(s: SourceAnalysisSummary): string;
@@ -0,0 +1,251 @@
1
+ import { newFinding } from "@zeroroot-ai/sdk";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { runSemgrep } from "./semgrep.js";
5
+ /** semgrep's level, ranked so the strongest rule wins a deduplicated place. */
6
+ const LEVEL_RANK = { ERROR: 3, WARNING: 2, INFO: 1 };
7
+ /**
8
+ * The Vulnerability id for a candidate. A CWE from the rule's metadata wins,
9
+ * normalized to `CWE-<n>` so the same weakness from two rules is one node in
10
+ * the graph. A rule with no CWE gets a platform id keyed by the rule, which is
11
+ * still stable across runs and repositories.
12
+ */
13
+ export function vulnerabilityId(c) {
14
+ for (const raw of c.cwe) {
15
+ const m = /CWE-(\d+)/i.exec(raw);
16
+ if (m)
17
+ return `CWE-${m[1]}`;
18
+ }
19
+ return `zerocool:semgrep:${c.checkId}`;
20
+ }
21
+ /**
22
+ * Collapse matches to one candidate per (path, line, Vulnerability). Two rules
23
+ * that flag the same eval on the same line are one weakness, and two Findings
24
+ * for it would be one duplicate for a person to close. The strongest rule's
25
+ * level and message are kept; every rule id is recorded.
26
+ */
27
+ export function dedupeCandidates(matches) {
28
+ const byKey = new Map();
29
+ for (const m of matches) {
30
+ const id = vulnerabilityId(m);
31
+ const key = `${m.path}:${m.line}:${id}`;
32
+ const seen = byKey.get(key);
33
+ if (!seen) {
34
+ byKey.set(key, { ...m, vulnerabilityId: id, ruleIds: [m.checkId] });
35
+ continue;
36
+ }
37
+ if (!seen.ruleIds.includes(m.checkId))
38
+ seen.ruleIds.push(m.checkId);
39
+ if ((LEVEL_RANK[m.severity] ?? 0) > (LEVEL_RANK[seen.severity] ?? 0)) {
40
+ seen.severity = m.severity;
41
+ seen.message = m.message;
42
+ seen.checkId = m.checkId;
43
+ seen.endLine = Math.max(seen.endLine, m.endLine);
44
+ }
45
+ }
46
+ return [...byKey.values()].sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
47
+ }
48
+ /** Lines around a match, numbered, for the model and for the Finding's evidence. */
49
+ export async function readSnippet(dir, c, read = (p) => readFile(p, "utf8"), context = 4) {
50
+ let text;
51
+ try {
52
+ text = await read(join(dir, c.path));
53
+ }
54
+ catch {
55
+ return "";
56
+ }
57
+ const lines = text.split("\n");
58
+ const from = Math.max(1, c.line - context);
59
+ const to = Math.min(lines.length, c.endLine + context);
60
+ const out = [];
61
+ for (let n = from; n <= to; n++) {
62
+ const mark = n >= c.line && n <= c.endLine ? ">" : " ";
63
+ out.push(`${mark} ${String(n).padStart(4)} | ${lines[n - 1] ?? ""}`);
64
+ }
65
+ return out.join("\n");
66
+ }
67
+ /**
68
+ * The triage prompt. The model gets the rule's message, the weakness, and the
69
+ * code; it answers with one JSON object. Exported so the wording is testable
70
+ * and so a reader can see exactly what the model is asked.
71
+ */
72
+ export function triageMessages(c, snippet) {
73
+ return [
74
+ {
75
+ role: "system",
76
+ content: "You triage static-analysis matches in application source code. For the match below, " +
77
+ "decide whether it is a real weakness an attacker or a caller could exploit, or noise " +
78
+ "(a constant, dead code, a test, a value no caller can influence). Answer with exactly " +
79
+ 'one JSON object and nothing else: {"verdict":"real"|"noise","reason":"<one sentence>"}.',
80
+ },
81
+ {
82
+ role: "user",
83
+ content: `Rule: ${c.ruleIds.join(", ")}\n` +
84
+ `Weakness: ${c.vulnerabilityId}\n` +
85
+ `Rule message: ${c.message}\n` +
86
+ `File: ${c.path} lines ${c.line}-${c.endLine}\n\n` +
87
+ `${snippet || "(source not available)"}`,
88
+ },
89
+ ];
90
+ }
91
+ /**
92
+ * Parse the model's answer. Tolerant of prose around the object, strict about
93
+ * the verdict: anything but a clear "real" is noise, because a Finding the
94
+ * model did not clearly confirm is exactly the noise this task exists to drop.
95
+ */
96
+ export function parseTriage(text) {
97
+ const m = /\{[\s\S]*\}/.exec(text);
98
+ if (m) {
99
+ try {
100
+ const obj = JSON.parse(m[0]);
101
+ const verdict = String(obj.verdict ?? "").toLowerCase() === "real" ? "real" : "noise";
102
+ const reason = typeof obj.reason === "string" && obj.reason.trim() ? obj.reason.trim() : "no reason given";
103
+ return { verdict, reason };
104
+ }
105
+ catch {
106
+ // fall through
107
+ }
108
+ }
109
+ return { verdict: "noise", reason: `model answer was not the expected JSON: ${text.slice(0, 120)}` };
110
+ }
111
+ /**
112
+ * The model behind the task grant: one `LLMComplete` on the harness per
113
+ * candidate, on the tenant's own provider, through the harness so the call is
114
+ * traced and budgeted like every other model call the run makes.
115
+ */
116
+ export function harnessTriageModel(harness, slot = "primary") {
117
+ return async (c, snippet) => {
118
+ const res = await harness.client.lLMComplete({
119
+ context: harness.context,
120
+ slot,
121
+ messages: triageMessages(c, snippet).map((m) => ({ role: m.role, content: m.content })),
122
+ temperature: 0,
123
+ stop: [],
124
+ });
125
+ if (res.error)
126
+ throw new Error(`LLMComplete refused: ${res.error.message}`);
127
+ return parseTriage(res.content);
128
+ };
129
+ }
130
+ /** semgrep's level mapped onto the Finding severity scale. */
131
+ export function findingSeverity(level) {
132
+ if (level === "ERROR")
133
+ return "high";
134
+ if (level === "WARNING")
135
+ return "medium";
136
+ return "low";
137
+ }
138
+ /**
139
+ * Build the Finding for a real candidate. The Vulnerability id, the file and
140
+ * the line travel in the title, the tags and the evidence, so they survive the
141
+ * typed `SubmitFinding` mapping, which carries no free-form metadata.
142
+ */
143
+ export function candidateFinding(c, triage, snippet, prov) {
144
+ const where = `${c.path}:${c.line}`;
145
+ return newFinding({
146
+ title: `${c.vulnerabilityId} at ${where}: ${c.message.split(".")[0] ?? c.message}`.slice(0, 200),
147
+ description: `${c.message}\n\n` +
148
+ `Vulnerability: ${c.vulnerabilityId}\n` +
149
+ `File: ${c.path}\nLines: ${c.line}-${c.endLine}\n` +
150
+ `Rules: ${c.ruleIds.join(", ")}\n` +
151
+ (prov.repository ? `Repository: ${prov.repository}${prov.commit ? ` @ ${prov.commit}` : ""}\n` : "") +
152
+ `\nTriage: ${triage.reason}`,
153
+ category: c.category ?? "source",
154
+ severity: findingSeverity(c.severity),
155
+ confidence: 0.8,
156
+ missionID: prov.missionId ?? "",
157
+ agentName: "zerocool",
158
+ ...(prov.targetId ? { targetID: prov.targetId } : {}),
159
+ tags: [
160
+ "source-analysis",
161
+ `vulnerability:${c.vulnerabilityId}`,
162
+ `file:${c.path}`,
163
+ `line:${c.line}`,
164
+ ...c.ruleIds.map((r) => `semgrep:${r}`),
165
+ ...(prov.commit ? [`commit:${prov.commit}`] : []),
166
+ ],
167
+ evidence: [
168
+ {
169
+ type: "code",
170
+ title: where,
171
+ content: snippet || "(source not available)",
172
+ timestamp: new Date().toISOString(),
173
+ metadata: { path: c.path, line: c.line, end_line: c.endLine, rules: c.ruleIds },
174
+ },
175
+ ],
176
+ });
177
+ }
178
+ /**
179
+ * One pass: scan, dedupe, triage, submit. A candidate the model or the backend
180
+ * fails on is recorded in `failed` and the pass continues, so one bad answer
181
+ * costs one candidate rather than the whole scan. The pass never throws for a
182
+ * clean checkout; it throws only when semgrep itself could not run.
183
+ */
184
+ export async function runSourceAnalysis(opts) {
185
+ const emit = opts.onEvent ?? (() => { });
186
+ const semgrep = opts.semgrep ?? runSemgrep;
187
+ emit({ type: "source_analysis", phase: "semgrep", dir: opts.dir });
188
+ const matches = await semgrep({
189
+ dir: opts.dir,
190
+ ...(opts.rules ? { rules: opts.rules } : {}),
191
+ ...(opts.semgrepTimeoutMs ? { timeoutMs: opts.semgrepTimeoutMs } : {}),
192
+ });
193
+ const candidates = dedupeCandidates(matches);
194
+ emit({ type: "source_analysis", phase: "candidates", matches: matches.length, candidates: candidates.length });
195
+ const summary = {
196
+ matches: matches.length,
197
+ candidates: candidates.length,
198
+ real: 0,
199
+ noise: 0,
200
+ findingIds: [],
201
+ failed: [],
202
+ verdicts: [],
203
+ };
204
+ for (const c of candidates) {
205
+ const label = `${c.path}:${c.line} ${c.vulnerabilityId}`;
206
+ const snippet = await readSnippet(opts.dir, c, opts.readFile);
207
+ let triage;
208
+ try {
209
+ triage = await opts.model(c, snippet);
210
+ }
211
+ catch (e) {
212
+ summary.failed.push({ candidate: label, error: `triage: ${e.message}` });
213
+ emit({ type: "source_analysis", phase: "triage", candidate: label, error: e.message });
214
+ continue;
215
+ }
216
+ summary.verdicts.push({ candidate: label, vulnerabilityId: c.vulnerabilityId, verdict: triage.verdict, reason: triage.reason });
217
+ emit({ type: "source_analysis", phase: "triage", candidate: label, verdict: triage.verdict, reason: triage.reason });
218
+ if (triage.verdict !== "real") {
219
+ summary.noise += 1;
220
+ continue;
221
+ }
222
+ summary.real += 1;
223
+ const finding = candidateFinding(c, triage, snippet, opts.provenance ?? {});
224
+ try {
225
+ const id = await opts.findings.submit(finding);
226
+ summary.findingIds.push(id);
227
+ emit({ type: "source_analysis", phase: "submit", candidate: label, finding_id: id });
228
+ }
229
+ catch (e) {
230
+ summary.failed.push({ candidate: label, error: `submit: ${e.message}` });
231
+ emit({ type: "source_analysis", phase: "submit", candidate: label, error: e.message });
232
+ }
233
+ }
234
+ emit({ type: "source_analysis", phase: "done", ...summaryCounts(summary) });
235
+ return summary;
236
+ }
237
+ function summaryCounts(s) {
238
+ return { matches: s.matches, candidates: s.candidates, real: s.real, noise: s.noise, submitted: s.findingIds.length, failed: s.failed.length };
239
+ }
240
+ /** The human-readable result the mission node records. */
241
+ export function formatSourceAnalysis(s) {
242
+ const lines = [
243
+ `source analysis: ${s.matches} semgrep matches, ${s.candidates} candidates, ` +
244
+ `${s.real} real, ${s.noise} noise, ${s.findingIds.length} findings submitted, ${s.failed.length} failed`,
245
+ ];
246
+ for (const v of s.verdicts)
247
+ lines.push(`- ${v.verdict.padEnd(5)} ${v.candidate}: ${v.reason}`);
248
+ for (const f of s.failed)
249
+ lines.push(`- FAILED ${f.candidate}: ${f.error}`);
250
+ return lines.join("\n");
251
+ }