@zeroroot-ai/zerocool 0.3.0 → 0.4.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.
@@ -16,9 +16,9 @@
16
16
  * first run trades it for a persistent host key, and every later run re-registers
17
17
  * with that key (ADR-0045). A sandboxed run has neither. It authenticates with
18
18
  * the PER-DISPATCH capability grant gibson mints for this run and injects as
19
- * `GIBSON_CALLBACK_ENDPOINT` + `GIBSON_CALLBACK_TOKEN`. That grant carries exactly
20
- * the authority of this dispatch, so this process never reads a bootstrap token
21
- * and never touches a host key.
19
+ * `GIBSON_CALLBACK_ENDPOINT` + `GIBSON_CG_JWT`. That grant carries exactly the
20
+ * authority of this dispatch, so this process never reads a bootstrap token and
21
+ * never touches a host key.
22
22
  *
23
23
  * THE CALLBACKS ARE GRANT-AUTHENTICATED. This process drives `opencode run`
24
24
  * headless and passes the per-dispatch grant to the child (see
@@ -35,18 +35,31 @@
35
35
  *
36
36
  * Environment (injected by the sandbox launch):
37
37
  *
38
+ * These names are gibson's canonical dispatch contract — the constants in gibson
39
+ * `internal/engine/harness/sandboxed/agent.go` (envAgent*). This process conforms
40
+ * to them; it does not define its own.
41
+ *
38
42
  * GIBSON_CALLBACK_ENDPOINT per-dispatch harness callback dial target (required)
39
- * GIBSON_CALLBACK_TOKEN per-dispatch capability grant (required)
40
- * GIBSON_TASK_GOAL the task to pursue (required)
43
+ * GIBSON_CG_JWT per-dispatch capability grant (CG-JWT) (required)
44
+ * GIBSON_AGENT_TASK_B64 base64 protojson gibson.types.v1.Task (required)
45
+ * GIBSON_MODEL provider/model resolved for the tenant at dispatch
41
46
  * GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
42
- * GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID / GIBSON_TRACE_ID provenance
47
+ * GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
43
48
  * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
44
- * ZEROCOOL_MODEL provider/model for opencode
49
+ * ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
50
+ * own context `zerocool.task` is the normal way
45
51
  * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
46
52
  * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
53
+ *
54
+ * The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
55
+ * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
56
+ * the GIBSON_CG_JWT this process reads.
47
57
  */
48
- import type { AgentOutcome } from "@zeroroot-ai/sdk";
58
+ import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
59
+ import { type FindingsBackend } from "./findings.js";
49
60
  import { runOpencode } from "./opencode-run.js";
61
+ import type { SemgrepRunner } from "./semgrep.js";
62
+ import { type TriageModel } from "./source-analysis.js";
50
63
  /**
51
64
  * The per-dispatch grant, as the sandbox injects it. The child authenticates its
52
65
  * harness callbacks with this; {@link dispatchChildEnv} renders it back to env.
@@ -67,6 +80,14 @@ export interface CallbackGrant {
67
80
  export interface DispatchContext extends CallbackGrant {
68
81
  /** `task.goal` — the natural-language objective the sandbox handed over. */
69
82
  goal: string;
83
+ /**
84
+ * `task.context` — the string map the mission node set. Selects the task kind
85
+ * (`zerocool.task`) and carries what a task needs beyond a goal: the
86
+ * repository url and commit, a sub-path, the target id.
87
+ */
88
+ taskContext: Record<string, string>;
89
+ /** `GIBSON_MISSION_ID`, so a Finding names the mission it belongs to. */
90
+ missionId?: string;
70
91
  /** opencode edits files here; it is the run's workspace. */
71
92
  workspace: string;
72
93
  /** `provider/model`, e.g. `gibson/default`. Omitted lets opencode choose. */
@@ -86,6 +107,33 @@ export interface DispatchContext extends CallbackGrant {
86
107
  * same environment.
87
108
  */
88
109
  export declare function dispatchChildEnv(g: CallbackGrant): NodeJS.ProcessEnv;
110
+ /**
111
+ * Decode the base64 protojson `gibson.types.v1.Task` the sandbox launch injects
112
+ * as `GIBSON_AGENT_TASK_B64` (gibson agent.go `envAgentTaskB64`) and return its
113
+ * goal. gibson sends the full typed Task, not a plain goal string, so decode it
114
+ * here rather than run opencode against an empty or malformed prompt.
115
+ */
116
+ export declare function decodeTaskGoal(taskB64: string | undefined): string;
117
+ /** The parts of a `gibson.types.v1.Task` a dispatch reads. */
118
+ export interface DecodedTask {
119
+ goal: string;
120
+ context: Record<string, string>;
121
+ metadata: Record<string, string>;
122
+ }
123
+ /**
124
+ * Decode the whole Task: the goal, and the `context` and `metadata` string
125
+ * maps a mission node sets. protojson renders both as plain objects, so a
126
+ * value that is not a string is dropped rather than guessed at.
127
+ */
128
+ export declare function decodeTask(taskB64: string | undefined): DecodedTask;
129
+ /** The task kinds a sandboxed dispatch can serve. */
130
+ export type DispatchTaskKind = "opencode" | "source-analysis";
131
+ /**
132
+ * Which task this dispatch runs. The mission node says so in the Task's
133
+ * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
134
+ * Anything unrecognized is the default: drive opencode on the goal.
135
+ */
136
+ export declare function dispatchTaskKind(ctx: Pick<DispatchContext, "taskContext">, env?: NodeJS.ProcessEnv): DispatchTaskKind;
89
137
  /**
90
138
  * Read one sandboxed dispatch's context from the environment.
91
139
  *
@@ -108,6 +156,14 @@ export interface DispatchDeps {
108
156
  run?: typeof runOpencode;
109
157
  /** Live tap for each NDJSON event opencode prints. */
110
158
  onEvent?: (line: string) => void;
159
+ /** Source-analysis seams: no semgrep binary, no model, no daemon in a test. */
160
+ semgrep?: SemgrepRunner;
161
+ model?: TriageModel;
162
+ findings?: FindingsBackend;
163
+ /** Opens the task harness. Defaults to `openTaskHarness` on the dispatch grant. */
164
+ harness?: (ctx: DispatchContext) => TaskHarness;
165
+ /** Force the task kind, over the Task's own context. */
166
+ taskKind?: DispatchTaskKind;
111
167
  }
112
168
  /**
113
169
  * Run one dispatched Task headless and map it to a terminal outcome.
@@ -118,6 +174,17 @@ export interface DispatchDeps {
118
174
  * into a failed terminal result and a non-zero exit.
119
175
  */
120
176
  export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
177
+ /**
178
+ * The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
179
+ * involved: semgrep produces candidates, the model triages them through the
180
+ * harness `LLMComplete` on the task grant, and real ones are submitted through
181
+ * the harness `SubmitFinding` on the same grant. The task reads the checkout
182
+ * (the workspace, or `source.path` under it) and changes nothing.
183
+ *
184
+ * Task context keys it reads: `zerocool.task` (selector), `source.path`,
185
+ * `repository.url`, `repository.commit`, `target.id`.
186
+ */
187
+ export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
121
188
  /**
122
189
  * Render a terminal outcome as the last NDJSON line on stdout.
123
190
  *
package/dist/dispatch.js CHANGED
@@ -1,5 +1,65 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * zerocool-dispatch — one sandboxed dispatched run, then exit.
4
+ *
5
+ * The third dispatched shape, and the one gibson launches (zerocool-plugins#57,
6
+ * gibson#1596, ADR-0016). `zerocool-agent` and `zerocool-serve` are long-lived
7
+ * hosts that enroll once, keep a host key, and POLL the daemon for work. This is
8
+ * the opposite: gibson runs an untrusted agent by launching an ephemeral setec
9
+ * sandbox per mission run and injecting the run's credentials as environment. So
10
+ * there is no enrollment, no host key, and no poll loop — the task and the grant
11
+ * are already in the environment when the process starts, and the process serves
12
+ * exactly one dispatch and exits so the sandbox is torn down.
13
+ *
14
+ * WHAT REPLACES THE BOOTSTRAP TOKEN. The poll-loop shapes authenticate as a
15
+ * long-lived COMPONENT: a human mints a one-time `GIBSON_BOOTSTRAP_TOKEN`, the
16
+ * first run trades it for a persistent host key, and every later run re-registers
17
+ * with that key (ADR-0045). A sandboxed run has neither. It authenticates with
18
+ * the PER-DISPATCH capability grant gibson mints for this run and injects as
19
+ * `GIBSON_CALLBACK_ENDPOINT` + `GIBSON_CG_JWT`. That grant carries exactly the
20
+ * authority of this dispatch, so this process never reads a bootstrap token and
21
+ * never touches a host key.
22
+ *
23
+ * THE CALLBACKS ARE GRANT-AUTHENTICATED. This process drives `opencode run`
24
+ * headless and passes the per-dispatch grant to the child (see
25
+ * {@link dispatchChildEnv}). The child's mission callbacks — findings, tool
26
+ * calls, knowledge reads — then reach the daemon's harness callback listener as
27
+ * the TASK, on the injected grant, not as some enrolled component (see
28
+ * `knowledge-source.ts`).
29
+ *
30
+ * THE OUTPUT IS A LIVE NDJSON STREAM. Every event opencode prints is forwarded
31
+ * to this process's stdout as it arrives, so the daemon can stream the run to the
32
+ * read-only console (a later slice consumes it). The last line is the terminal
33
+ * result. stdout is therefore a clean event stream — the driver's own logs go to
34
+ * stderr — and a clean exit lets the sandbox tear down.
35
+ *
36
+ * Environment (injected by the sandbox launch):
37
+ *
38
+ * These names are gibson's canonical dispatch contract — the constants in gibson
39
+ * `internal/engine/harness/sandboxed/agent.go` (envAgent*). This process conforms
40
+ * to them; it does not define its own.
41
+ *
42
+ * GIBSON_CALLBACK_ENDPOINT per-dispatch harness callback dial target (required)
43
+ * GIBSON_CG_JWT per-dispatch capability grant (CG-JWT) (required)
44
+ * GIBSON_AGENT_TASK_B64 base64 protojson gibson.types.v1.Task (required)
45
+ * GIBSON_MODEL provider/model resolved for the tenant at dispatch
46
+ * GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
47
+ * GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
48
+ * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
49
+ * ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
50
+ * own context `zerocool.task` is the normal way
51
+ * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
52
+ * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
53
+ *
54
+ * The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
55
+ * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
56
+ * the GIBSON_CG_JWT this process reads.
57
+ */
58
+ import { openTaskHarness } from "@zeroroot-ai/sdk";
59
+ import { join } from "node:path";
60
+ import { taskFindingsBackend } from "./findings.js";
2
61
  import { runOpencode } from "./opencode-run.js";
62
+ import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
3
63
  /**
4
64
  * Render the per-dispatch grant back to environment for the opencode child, so
5
65
  * the child's harness callbacks travel on the task grant rather than on any
@@ -19,6 +79,59 @@ export function dispatchChildEnv(g) {
19
79
  ...(g.traceId ? { GIBSON_TRACE_ID: g.traceId } : {}),
20
80
  };
21
81
  }
82
+ /**
83
+ * Decode the base64 protojson `gibson.types.v1.Task` the sandbox launch injects
84
+ * as `GIBSON_AGENT_TASK_B64` (gibson agent.go `envAgentTaskB64`) and return its
85
+ * goal. gibson sends the full typed Task, not a plain goal string, so decode it
86
+ * here rather than run opencode against an empty or malformed prompt.
87
+ */
88
+ export function decodeTaskGoal(taskB64) {
89
+ return decodeTask(taskB64).goal;
90
+ }
91
+ /**
92
+ * Decode the whole Task: the goal, and the `context` and `metadata` string
93
+ * maps a mission node sets. protojson renders both as plain objects, so a
94
+ * value that is not a string is dropped rather than guessed at.
95
+ */
96
+ export function decodeTask(taskB64) {
97
+ if (!taskB64) {
98
+ throw new Error("GIBSON_AGENT_TASK_B64 is not set. The sandbox launch injects the task to pursue as the " +
99
+ "base64 protojson of a gibson.types.v1.Task; without it there is nothing to run.");
100
+ }
101
+ let task;
102
+ try {
103
+ task = JSON.parse(Buffer.from(taskB64, "base64").toString("utf8"));
104
+ }
105
+ catch (e) {
106
+ throw new Error("GIBSON_AGENT_TASK_B64 is not valid base64 protojson of a gibson.types.v1.Task: " +
107
+ (e instanceof Error ? e.message : String(e)));
108
+ }
109
+ const goal = typeof task.goal === "string" ? task.goal.trim() : "";
110
+ if (!goal) {
111
+ throw new Error("GIBSON_AGENT_TASK_B64 decoded to a Task with no goal. Running opencode against an empty " +
112
+ "prompt would report whatever it says back as a mission result.");
113
+ }
114
+ return { goal, context: stringMap(task.context), metadata: stringMap(task.metadata) };
115
+ }
116
+ function stringMap(v) {
117
+ if (!v || typeof v !== "object")
118
+ return {};
119
+ const out = {};
120
+ for (const [k, val] of Object.entries(v)) {
121
+ if (typeof val === "string")
122
+ out[k] = val;
123
+ }
124
+ return out;
125
+ }
126
+ /**
127
+ * Which task this dispatch runs. The mission node says so in the Task's
128
+ * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
129
+ * Anything unrecognized is the default: drive opencode on the goal.
130
+ */
131
+ export function dispatchTaskKind(ctx, env = {}) {
132
+ const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
133
+ return kind === "source-analysis" ? "source-analysis" : "opencode";
134
+ }
22
135
  /**
23
136
  * Read one sandboxed dispatch's context from the environment.
24
137
  *
@@ -34,22 +147,18 @@ export function dispatchChildEnv(g) {
34
147
  */
35
148
  export function readDispatchContext(env, opts = {}) {
36
149
  const endpoint = env.GIBSON_CALLBACK_ENDPOINT;
37
- const token = env.GIBSON_CALLBACK_TOKEN;
150
+ const token = env.GIBSON_CG_JWT;
38
151
  if (!endpoint) {
39
152
  throw new Error("GIBSON_CALLBACK_ENDPOINT is not set. A sandboxed dispatched run is authenticated by " +
40
153
  "the per-dispatch grant the sandbox launch injects, not by a bootstrap token — the " +
41
- "launch must supply the callback endpoint and token.");
154
+ "launch must supply the callback endpoint and the grant.");
42
155
  }
43
156
  if (!token) {
44
- throw new Error("GIBSON_CALLBACK_ENDPOINT is set but GIBSON_CALLBACK_TOKEN is not. The callbacks would " +
45
- "have no grant to authenticate with; a sandboxed run must not fall back to any other " +
46
- "identity, so this fails instead.");
47
- }
48
- const goal = env.GIBSON_TASK_GOAL?.trim();
49
- if (!goal) {
50
- throw new Error("GIBSON_TASK_GOAL is empty. The sandbox launch injects the task to pursue; running " +
51
- "opencode against an empty prompt would report whatever it says back as a mission result.");
157
+ throw new Error("GIBSON_CALLBACK_ENDPOINT is set but GIBSON_CG_JWT is not. The callbacks would have no " +
158
+ "grant to authenticate with; a sandboxed run must not fall back to any other identity, " +
159
+ "so this fails instead.");
52
160
  }
161
+ const task = decodeTask(env.GIBSON_AGENT_TASK_B64);
53
162
  const timeoutRaw = env.GIBSON_TIMEOUT_MS ? Number(env.GIBSON_TIMEOUT_MS) : 0;
54
163
  return {
55
164
  callbackEndpoint: endpoint,
@@ -58,9 +167,11 @@ export function readDispatchContext(env, opts = {}) {
58
167
  missionRunId: env.GIBSON_MISSION_RUN_ID ?? "",
59
168
  agentRunId: env.GIBSON_AGENT_RUN_ID ?? "",
60
169
  traceId: env.GIBSON_TRACE_ID ?? "",
61
- goal,
170
+ goal: task.goal,
171
+ taskContext: task.context,
172
+ ...(env.GIBSON_MISSION_ID ? { missionId: env.GIBSON_MISSION_ID } : {}),
62
173
  workspace: env.ZEROCOOL_WORKSPACE ?? opts.cwd ?? process.cwd(),
63
- ...(env.ZEROCOOL_MODEL ? { model: env.ZEROCOOL_MODEL } : {}),
174
+ ...(env.GIBSON_MODEL ? { model: env.GIBSON_MODEL } : {}),
64
175
  ...(env.GIBSON_OPENCODE_SESSION_ID ? { sessionId: env.GIBSON_OPENCODE_SESSION_ID } : {}),
65
176
  timeoutMs: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 0,
66
177
  };
@@ -74,6 +185,9 @@ export function readDispatchContext(env, opts = {}) {
74
185
  * into a failed terminal result and a non-zero exit.
75
186
  */
76
187
  export async function runDispatch(ctx, deps = {}) {
188
+ if ((deps.taskKind ?? dispatchTaskKind(ctx, process.env)) === "source-analysis") {
189
+ return runSourceAnalysisDispatch(ctx, deps);
190
+ }
77
191
  const run = deps.run ?? runOpencode;
78
192
  const result = await run({
79
193
  goal: ctx.goal,
@@ -96,6 +210,60 @@ export async function runDispatch(ctx, deps = {}) {
96
210
  },
97
211
  };
98
212
  }
213
+ /**
214
+ * The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
215
+ * involved: semgrep produces candidates, the model triages them through the
216
+ * harness `LLMComplete` on the task grant, and real ones are submitted through
217
+ * the harness `SubmitFinding` on the same grant. The task reads the checkout
218
+ * (the workspace, or `source.path` under it) and changes nothing.
219
+ *
220
+ * Task context keys it reads: `zerocool.task` (selector), `source.path`,
221
+ * `repository.url`, `repository.commit`, `target.id`.
222
+ */
223
+ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
224
+ const opened = !deps.harness && !(deps.model && deps.findings);
225
+ const harness = deps.model && deps.findings
226
+ ? undefined
227
+ : (deps.harness ?? ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx);
228
+ const model = deps.model ?? harnessTriageModel(harness);
229
+ const findings = deps.findings ?? taskFindingsBackend(harness);
230
+ const sub = ctx.taskContext["source.path"];
231
+ const dir = sub ? join(ctx.workspace, sub) : ctx.workspace;
232
+ try {
233
+ const summary = await runSourceAnalysis({
234
+ dir,
235
+ model,
236
+ findings,
237
+ ...(deps.semgrep ? { semgrep: deps.semgrep } : {}),
238
+ ...(ctx.timeoutMs > 0 ? { semgrepTimeoutMs: ctx.timeoutMs } : {}),
239
+ provenance: {
240
+ ...(ctx.missionId ? { missionId: ctx.missionId } : {}),
241
+ ...(ctx.taskContext["repository.url"] ? { repository: ctx.taskContext["repository.url"] } : {}),
242
+ ...(ctx.taskContext["repository.commit"] ? { commit: ctx.taskContext["repository.commit"] } : {}),
243
+ ...(ctx.taskContext["target.id"] ? { targetId: ctx.taskContext["target.id"] } : {}),
244
+ },
245
+ ...(deps.onEvent ? { onEvent: (event) => deps.onEvent?.(JSON.stringify(event)) } : {}),
246
+ });
247
+ return {
248
+ output: formatSourceAnalysis(summary),
249
+ success: true,
250
+ findingIds: summary.findingIds,
251
+ metadata: {
252
+ task: "source-analysis",
253
+ matches: String(summary.matches),
254
+ candidates: String(summary.candidates),
255
+ real: String(summary.real),
256
+ noise: String(summary.noise),
257
+ submitted: String(summary.findingIds.length),
258
+ failed: String(summary.failed.length),
259
+ },
260
+ };
261
+ }
262
+ finally {
263
+ if (opened)
264
+ harness?.stop();
265
+ }
266
+ }
99
267
  /**
100
268
  * Render a terminal outcome as the last NDJSON line on stdout.
101
269
  *
@@ -1,5 +1,5 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
- import { type Finding, type GibsonSession } from "@zeroroot-ai/sdk";
2
+ import { type Finding, type GibsonSession, type TaskHarness } from "@zeroroot-ai/sdk";
3
3
  export interface FindingsBackend {
4
4
  /** Submit a finding; returns the identifier it was recorded under. */
5
5
  submit(f: Finding): Promise<string>;
@@ -8,6 +8,14 @@ export interface FindingsBackend {
8
8
  }
9
9
  /** Platform backend — findings land in the tenant knowledge graph. */
10
10
  export declare function gibsonFindingsBackend(session: GibsonSession): FindingsBackend;
11
+ /**
12
+ * Task backend — the callback service's typed `SubmitFinding` under the
13
+ * per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
14
+ * (mission and run), never to a component identity the sandbox does not hold.
15
+ * Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
16
+ * daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
17
+ */
18
+ export declare function taskFindingsBackend(harness: TaskHarness): FindingsBackend;
11
19
  /**
12
20
  * Standalone backend — findings are appended as JSONL. The same Finding shape is
13
21
  * written, so a local log can be replayed into Gibson later.
package/dist/findings.js CHANGED
@@ -26,6 +26,42 @@ export function gibsonFindingsBackend(session) {
26
26
  describe: () => "the tenant Gibson graph",
27
27
  };
28
28
  }
29
+ /**
30
+ * Task backend — the callback service's typed `SubmitFinding` under the
31
+ * per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
32
+ * (mission and run), never to a component identity the sandbox does not hold.
33
+ * Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
34
+ * daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
35
+ */
36
+ export function taskFindingsBackend(harness) {
37
+ const severity = (s) => ({ critical: 1, high: 2, medium: 3, low: 4, info: 5 })[s] ?? 0;
38
+ return {
39
+ submit: async (f) => {
40
+ const res = await harness.client.submitFinding({
41
+ context: harness.context,
42
+ finding: {
43
+ id: f.id,
44
+ missionId: f.mission_id,
45
+ agentName: f.agent_name,
46
+ title: f.title,
47
+ description: f.description,
48
+ category: f.category,
49
+ severity: severity(f.severity),
50
+ confidence: f.confidence,
51
+ remediation: f.remediation ?? "",
52
+ targetId: f.target_id ?? "",
53
+ tags: f.tags ?? [],
54
+ references: f.references ?? [],
55
+ evidence: (f.evidence ?? []).map((e) => ({ type: e.type, title: e.title, content: e.content })),
56
+ },
57
+ });
58
+ if (res.error)
59
+ throw new Error(`SubmitFinding refused: ${res.error.message}`);
60
+ return f.id;
61
+ },
62
+ describe: () => "the tenant Gibson graph (dispatch grant)",
63
+ };
64
+ }
29
65
  /**
30
66
  * Standalone backend — findings are appended as JSONL. The same Finding shape is
31
67
  * written, so a local log can be replayed into Gibson later.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroroot-ai/zerocool",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,8 @@
14
14
  "main": "dist/index.js",
15
15
  "types": "dist/index.d.ts",
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "semgrep"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "tsc -p tsconfig.json",
@@ -22,7 +23,7 @@
22
23
  "test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
23
24
  },
24
25
  "dependencies": {
25
- "@zeroroot-ai/sdk": "^0.4.0"
26
+ "@zeroroot-ai/sdk": "^0.9.0"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "@opencode-ai/plugin": "*"
@@ -0,0 +1,178 @@
1
+ # The pinned semgrep ruleset the zerocool source-analysis task runs
2
+ # (zerocool-plugins#87). Vendored, not fetched: the sandbox must produce the
3
+ # same candidate list for the same checkout on every run, with no registry
4
+ # call and no telemetry. Every rule names the CWE it detects in metadata.cwe;
5
+ # the task turns that into the Finding's Vulnerability id. A rule without a
6
+ # CWE gets a platform id instead (zerocool:semgrep:<rule id>).
7
+ #
8
+ # Rules are candidates, not verdicts. The model triages each match; a rule
9
+ # here may be noisy on purpose as long as the match carries enough context
10
+ # to be judged. Keep the set small and cross-language; a rule that never
11
+ # yields a real finding in the demo fixture or a real repository is removed.
12
+ rules:
13
+ - id: js-eval-of-request-input
14
+ languages: [javascript, typescript]
15
+ severity: ERROR
16
+ message: >-
17
+ eval() runs a string that comes from request input. An attacker who
18
+ controls that input runs arbitrary code in the server process.
19
+ patterns:
20
+ - pattern-either:
21
+ - pattern: eval($ARG)
22
+ - pattern: new Function($ARG)
23
+ - metavariable-pattern:
24
+ metavariable: $ARG
25
+ pattern-either:
26
+ - pattern: $REQ.body.$X
27
+ - pattern: $REQ.query.$X
28
+ - pattern: $REQ.params.$X
29
+ - pattern: $REQ.body
30
+ - pattern: $REQ.query
31
+ metadata:
32
+ cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
33
+ category: injection
34
+
35
+ - id: js-eval-any
36
+ languages: [javascript, typescript]
37
+ severity: WARNING
38
+ message: >-
39
+ eval() on a value. Real when the value can be influenced by a caller;
40
+ noise when it is a constant.
41
+ pattern: eval($ARG)
42
+ metadata:
43
+ cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
44
+ category: injection
45
+
46
+ - id: js-child-process-shell-concat
47
+ languages: [javascript, typescript]
48
+ severity: ERROR
49
+ message: >-
50
+ A shell command is built by string concatenation and run through
51
+ child_process. Input in the string becomes shell syntax.
52
+ patterns:
53
+ - pattern-either:
54
+ - pattern: $CP.exec($CMD, ...)
55
+ - pattern: $CP.execSync($CMD, ...)
56
+ - pattern: exec($CMD, ...)
57
+ - pattern: execSync($CMD, ...)
58
+ - metavariable-pattern:
59
+ metavariable: $CMD
60
+ pattern-either:
61
+ - pattern: $A + $B
62
+ - pattern: "`...${$X}...`"
63
+ metadata:
64
+ cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
65
+ category: injection
66
+
67
+ - id: js-sql-string-concat
68
+ languages: [javascript, typescript]
69
+ severity: ERROR
70
+ message: >-
71
+ A SQL query is built by string concatenation or interpolation and passed
72
+ to a query function. Use a parameterized query.
73
+ patterns:
74
+ - pattern-either:
75
+ - pattern: $DB.query($SQL, ...)
76
+ - pattern: $DB.raw($SQL, ...)
77
+ - pattern: $DB.execute($SQL, ...)
78
+ - metavariable-pattern:
79
+ metavariable: $SQL
80
+ pattern-either:
81
+ - pattern: $A + $B
82
+ - pattern: "`...${$X}...`"
83
+ metadata:
84
+ cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
85
+ category: injection
86
+
87
+ - id: js-hardcoded-jwt-secret
88
+ languages: [javascript, typescript]
89
+ severity: ERROR
90
+ message: >-
91
+ A JWT is signed or verified with a string literal secret. Anyone who
92
+ reads the source can forge tokens.
93
+ patterns:
94
+ - pattern-either:
95
+ - pattern: $JWT.sign($P, "$SECRET", ...)
96
+ - pattern: $JWT.verify($T, "$SECRET", ...)
97
+ metadata:
98
+ cwe: "CWE-798: Use of Hard-coded Credentials"
99
+ category: secrets-exposure
100
+
101
+ - id: js-express-res-send-request-input
102
+ languages: [javascript, typescript]
103
+ severity: WARNING
104
+ message: >-
105
+ Request input is written straight into the response body. If the
106
+ response is HTML this is reflected cross-site scripting.
107
+ patterns:
108
+ - pattern-either:
109
+ - pattern: $RES.send($ARG)
110
+ - pattern: $RES.write($ARG)
111
+ - metavariable-pattern:
112
+ metavariable: $ARG
113
+ pattern-either:
114
+ - pattern: $REQ.query.$X
115
+ - pattern: $REQ.params.$X
116
+ - pattern: $REQ.body.$X
117
+ - pattern: "`...${$REQ.query.$X}...`"
118
+ - pattern: "`...${$REQ.params.$X}...`"
119
+ - pattern: $A + $REQ.query.$X
120
+ - pattern: $A + $REQ.params.$X
121
+ metadata:
122
+ cwe: "CWE-79: Improper Neutralization of Input During Web Page Generation"
123
+ category: injection
124
+
125
+ - id: py-subprocess-shell-true
126
+ languages: [python]
127
+ severity: ERROR
128
+ message: >-
129
+ subprocess is called with shell=True and a command that is not a
130
+ constant. Input in the command becomes shell syntax.
131
+ patterns:
132
+ - pattern-either:
133
+ - pattern: subprocess.$F($CMD, ..., shell=True, ...)
134
+ - pattern-not: subprocess.$F("...", ..., shell=True, ...)
135
+ metadata:
136
+ cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
137
+ category: injection
138
+
139
+ - id: py-yaml-unsafe-load
140
+ languages: [python]
141
+ severity: ERROR
142
+ message: yaml.load without a safe Loader deserializes arbitrary objects.
143
+ patterns:
144
+ - pattern: yaml.load($X)
145
+ metadata:
146
+ cwe: "CWE-502: Deserialization of Untrusted Data"
147
+ category: deserialization
148
+
149
+ - id: go-sql-string-concat
150
+ languages: [go]
151
+ severity: ERROR
152
+ message: >-
153
+ A SQL query is built with fmt.Sprintf or concatenation and passed to the
154
+ database. Use placeholders.
155
+ patterns:
156
+ - pattern-either:
157
+ - pattern: $DB.Query(fmt.Sprintf(...), ...)
158
+ - pattern: $DB.Exec(fmt.Sprintf(...), ...)
159
+ - pattern: $DB.QueryRow(fmt.Sprintf(...), ...)
160
+ - pattern: $DB.Query($A + $B, ...)
161
+ - pattern: $DB.Exec($A + $B, ...)
162
+ metadata:
163
+ cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
164
+ category: injection
165
+
166
+ - id: go-exec-command-shell
167
+ languages: [go]
168
+ severity: WARNING
169
+ message: >-
170
+ A shell is invoked with -c and a command that is not a constant.
171
+ patterns:
172
+ - pattern-either:
173
+ - pattern: exec.Command("sh", "-c", $CMD, ...)
174
+ - pattern: exec.Command("bash", "-c", $CMD, ...)
175
+ - pattern-not: exec.Command("...", "-c", "...", ...)
176
+ metadata:
177
+ cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
178
+ category: injection