@zeroroot-ai/zerocool 0.4.0 → 0.6.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/dispatch.d.ts +64 -1
- package/dist/dispatch.js +174 -4
- package/dist/fix.d.ts +207 -0
- package/dist/fix.js +255 -0
- package/dist/gitlab.d.ts +118 -0
- package/dist/gitlab.js +176 -0
- package/dist/watch.d.ts +219 -0
- package/dist/watch.js +346 -0
- package/package.json +1 -1
package/dist/dispatch.d.ts
CHANGED
|
@@ -57,8 +57,10 @@
|
|
|
57
57
|
*/
|
|
58
58
|
import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
59
59
|
import { type FindingsBackend } from "./findings.js";
|
|
60
|
+
import { type GitLabClient } from "./gitlab.js";
|
|
60
61
|
import { runOpencode } from "./opencode-run.js";
|
|
61
62
|
import type { SemgrepRunner } from "./semgrep.js";
|
|
63
|
+
import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
|
|
62
64
|
import { type TriageModel } from "./source-analysis.js";
|
|
63
65
|
/**
|
|
64
66
|
* The per-dispatch grant, as the sandbox injects it. The child authenticates its
|
|
@@ -126,8 +128,30 @@ export interface DecodedTask {
|
|
|
126
128
|
* value that is not a string is dropped rather than guessed at.
|
|
127
129
|
*/
|
|
128
130
|
export declare function decodeTask(taskB64: string | undefined): DecodedTask;
|
|
131
|
+
/**
|
|
132
|
+
* Read one `Task.context` / `Task.metadata` entry as a string.
|
|
133
|
+
*
|
|
134
|
+
* Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
|
|
135
|
+
* protojson renders every value as a one-key object naming the oneof arm —
|
|
136
|
+
* `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
|
|
137
|
+
* string. gibson marshals the Task with `protojson.Marshal` over
|
|
138
|
+
* `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
|
|
139
|
+
* this is the only shape a dispatched agent ever receives. Reading these maps
|
|
140
|
+
* as plain strings silently dropped every entry, which is why a mission node's
|
|
141
|
+
* `zerocool.task`, `repository.commit` and `target.id` never reached the run.
|
|
142
|
+
*
|
|
143
|
+
* A bare string is still accepted, because a hand-built Task written as plain
|
|
144
|
+
* JSON is the normal way to drive a dispatch by hand. Both snake_case and
|
|
145
|
+
* lowerCamel arm names are read: protojson emits lowerCamel by default but can
|
|
146
|
+
* be told to keep the proto field names, and a decoder that accepts only one of
|
|
147
|
+
* them fails on a Task that is otherwise valid.
|
|
148
|
+
*
|
|
149
|
+
* `nullValue` yields no entry: a null is the absence of a value, and rendering
|
|
150
|
+
* it as the string "null" would hand a task a target id of `"null"`.
|
|
151
|
+
*/
|
|
152
|
+
export declare function typedValueString(val: unknown): string | undefined;
|
|
129
153
|
/** The task kinds a sandboxed dispatch can serve. */
|
|
130
|
-
export type DispatchTaskKind = "opencode" | "source-analysis";
|
|
154
|
+
export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
|
|
131
155
|
/**
|
|
132
156
|
* Which task this dispatch runs. The mission node says so in the Task's
|
|
133
157
|
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
@@ -164,6 +188,20 @@ export interface DispatchDeps {
|
|
|
164
188
|
harness?: (ctx: DispatchContext) => TaskHarness;
|
|
165
189
|
/** Force the task kind, over the Task's own context. */
|
|
166
190
|
taskKind?: DispatchTaskKind;
|
|
191
|
+
/** Watch seams: no GitLab, no World and no child missions in a test. */
|
|
192
|
+
watch?: WatchDeps;
|
|
193
|
+
}
|
|
194
|
+
/** The watch task's injectable seams. Everything the loop reaches out through. */
|
|
195
|
+
export interface WatchDeps {
|
|
196
|
+
gitlab?: GitLabClient;
|
|
197
|
+
checkpoints?: WatchCheckpoints;
|
|
198
|
+
scans?: ScanLauncher;
|
|
199
|
+
/** Resolve the GitLab token. Defaults to the harness `GetCredential`. */
|
|
200
|
+
credential?: (name: string) => Promise<string>;
|
|
201
|
+
sleep?: (ms: number) => Promise<void>;
|
|
202
|
+
/** Bound the loop. Unset runs until the sandbox is torn down. */
|
|
203
|
+
maxPolls?: number;
|
|
204
|
+
signal?: AbortSignal;
|
|
167
205
|
}
|
|
168
206
|
/**
|
|
169
207
|
* Run one dispatched Task headless and map it to a terminal outcome.
|
|
@@ -185,6 +223,31 @@ export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps):
|
|
|
185
223
|
* `repository.url`, `repository.commit`, `target.id`.
|
|
186
224
|
*/
|
|
187
225
|
export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
226
|
+
/**
|
|
227
|
+
* The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
|
|
228
|
+
* dispatch does not finish until the mission is cancelled or the sandbox is
|
|
229
|
+
* torn down. It polls the application's GitLab project and originates a Scan
|
|
230
|
+
* mission per finished pipeline on the branch, from inside this very mission
|
|
231
|
+
* (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
|
|
232
|
+
*
|
|
233
|
+
* Task context keys it reads:
|
|
234
|
+
* `zerocool.task` selector, "watch"
|
|
235
|
+
* `application` the Application this watch follows (required)
|
|
236
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
237
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
238
|
+
* `gitlab.ref` branch to watch; defaults to `main`
|
|
239
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
240
|
+
* `repository.url` clone url handed to each Scan mission
|
|
241
|
+
* `target.id` the registered target each Scan mission binds to
|
|
242
|
+
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
243
|
+
*/
|
|
244
|
+
export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
245
|
+
/**
|
|
246
|
+
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
247
|
+
* is returned to the caller and never logged: it is the GitLab token, and the
|
|
248
|
+
* console stream this dispatch writes is shown in the browser.
|
|
249
|
+
*/
|
|
250
|
+
export declare function harnessCredential(harness: TaskHarness): (name: string) => Promise<string>;
|
|
188
251
|
/**
|
|
189
252
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
190
253
|
*
|
package/dist/dispatch.js
CHANGED
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
import { openTaskHarness } from "@zeroroot-ai/sdk";
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
import { taskFindingsBackend } from "./findings.js";
|
|
61
|
+
import { gitlabRest } from "./gitlab.js";
|
|
61
62
|
import { runOpencode } from "./opencode-run.js";
|
|
63
|
+
import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
|
|
62
64
|
import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
|
|
63
65
|
/**
|
|
64
66
|
* Render the per-dispatch grant back to environment for the opencode child, so
|
|
@@ -118,11 +120,58 @@ function stringMap(v) {
|
|
|
118
120
|
return {};
|
|
119
121
|
const out = {};
|
|
120
122
|
for (const [k, val] of Object.entries(v)) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const s = typedValueString(val);
|
|
124
|
+
if (s !== undefined)
|
|
125
|
+
out[k] = s;
|
|
123
126
|
}
|
|
124
127
|
return out;
|
|
125
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Read one `Task.context` / `Task.metadata` entry as a string.
|
|
131
|
+
*
|
|
132
|
+
* Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
|
|
133
|
+
* protojson renders every value as a one-key object naming the oneof arm —
|
|
134
|
+
* `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
|
|
135
|
+
* string. gibson marshals the Task with `protojson.Marshal` over
|
|
136
|
+
* `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
|
|
137
|
+
* this is the only shape a dispatched agent ever receives. Reading these maps
|
|
138
|
+
* as plain strings silently dropped every entry, which is why a mission node's
|
|
139
|
+
* `zerocool.task`, `repository.commit` and `target.id` never reached the run.
|
|
140
|
+
*
|
|
141
|
+
* A bare string is still accepted, because a hand-built Task written as plain
|
|
142
|
+
* JSON is the normal way to drive a dispatch by hand. Both snake_case and
|
|
143
|
+
* lowerCamel arm names are read: protojson emits lowerCamel by default but can
|
|
144
|
+
* be told to keep the proto field names, and a decoder that accepts only one of
|
|
145
|
+
* them fails on a Task that is otherwise valid.
|
|
146
|
+
*
|
|
147
|
+
* `nullValue` yields no entry: a null is the absence of a value, and rendering
|
|
148
|
+
* it as the string "null" would hand a task a target id of `"null"`.
|
|
149
|
+
*/
|
|
150
|
+
export function typedValueString(val) {
|
|
151
|
+
if (typeof val === "string")
|
|
152
|
+
return val;
|
|
153
|
+
if (!val || typeof val !== "object")
|
|
154
|
+
return undefined;
|
|
155
|
+
const v = val;
|
|
156
|
+
const arm = (camel, snake) => (v[camel] !== undefined ? v[camel] : v[snake]);
|
|
157
|
+
const s = arm("stringValue", "string_value");
|
|
158
|
+
if (typeof s === "string")
|
|
159
|
+
return s;
|
|
160
|
+
// protojson renders int64 and uint64 as strings, and double as a number.
|
|
161
|
+
const i = arm("intValue", "int_value");
|
|
162
|
+
if (typeof i === "string" || typeof i === "number")
|
|
163
|
+
return String(i);
|
|
164
|
+
const u = arm("uintValue", "uint_value");
|
|
165
|
+
if (typeof u === "string" || typeof u === "number")
|
|
166
|
+
return String(u);
|
|
167
|
+
const d = arm("doubleValue", "double_value");
|
|
168
|
+
if (typeof d === "number")
|
|
169
|
+
return String(d);
|
|
170
|
+
const b = arm("boolValue", "bool_value");
|
|
171
|
+
if (typeof b === "boolean")
|
|
172
|
+
return String(b);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
126
175
|
/**
|
|
127
176
|
* Which task this dispatch runs. The mission node says so in the Task's
|
|
128
177
|
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
@@ -130,7 +179,11 @@ function stringMap(v) {
|
|
|
130
179
|
*/
|
|
131
180
|
export function dispatchTaskKind(ctx, env = {}) {
|
|
132
181
|
const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
|
|
133
|
-
|
|
182
|
+
if (kind === "source-analysis")
|
|
183
|
+
return "source-analysis";
|
|
184
|
+
if (kind === "watch")
|
|
185
|
+
return "watch";
|
|
186
|
+
return "opencode";
|
|
134
187
|
}
|
|
135
188
|
/**
|
|
136
189
|
* Read one sandboxed dispatch's context from the environment.
|
|
@@ -185,9 +238,13 @@ export function readDispatchContext(env, opts = {}) {
|
|
|
185
238
|
* into a failed terminal result and a non-zero exit.
|
|
186
239
|
*/
|
|
187
240
|
export async function runDispatch(ctx, deps = {}) {
|
|
188
|
-
|
|
241
|
+
const kind = deps.taskKind ?? dispatchTaskKind(ctx, process.env);
|
|
242
|
+
if (kind === "source-analysis") {
|
|
189
243
|
return runSourceAnalysisDispatch(ctx, deps);
|
|
190
244
|
}
|
|
245
|
+
if (kind === "watch") {
|
|
246
|
+
return runWatchDispatch(ctx, deps);
|
|
247
|
+
}
|
|
191
248
|
const run = deps.run ?? runOpencode;
|
|
192
249
|
const result = await run({
|
|
193
250
|
goal: ctx.goal,
|
|
@@ -264,6 +321,119 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
|
|
|
264
321
|
harness?.stop();
|
|
265
322
|
}
|
|
266
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
|
|
326
|
+
* dispatch does not finish until the mission is cancelled or the sandbox is
|
|
327
|
+
* torn down. It polls the application's GitLab project and originates a Scan
|
|
328
|
+
* mission per finished pipeline on the branch, from inside this very mission
|
|
329
|
+
* (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
|
|
330
|
+
*
|
|
331
|
+
* Task context keys it reads:
|
|
332
|
+
* `zerocool.task` selector, "watch"
|
|
333
|
+
* `application` the Application this watch follows (required)
|
|
334
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
335
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
336
|
+
* `gitlab.ref` branch to watch; defaults to `main`
|
|
337
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
338
|
+
* `repository.url` clone url handed to each Scan mission
|
|
339
|
+
* `target.id` the registered target each Scan mission binds to
|
|
340
|
+
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
341
|
+
*/
|
|
342
|
+
export async function runWatchDispatch(ctx, deps = {}) {
|
|
343
|
+
const w = deps.watch ?? {};
|
|
344
|
+
const application = ctx.taskContext.application || "";
|
|
345
|
+
const projectPath = ctx.taskContext["gitlab.project"] || "";
|
|
346
|
+
const ref = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
|
|
347
|
+
const targetId = ctx.taskContext["target.id"] || "";
|
|
348
|
+
const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
|
|
349
|
+
if (!application) {
|
|
350
|
+
throw new Error("watch: the mission node set no `application` in the task context. The watch keys its " +
|
|
351
|
+
"checkpoints and its Scan missions by Application; without one it cannot tell its own " +
|
|
352
|
+
"state from another watch's.");
|
|
353
|
+
}
|
|
354
|
+
if (!w.gitlab && !projectPath) {
|
|
355
|
+
throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
356
|
+
"project to poll.");
|
|
357
|
+
}
|
|
358
|
+
// A harness is opened unless every seam that would use one was injected.
|
|
359
|
+
const needsHarness = !w.gitlab || !w.checkpoints || !w.scans || !w.credential;
|
|
360
|
+
const opened = needsHarness && !deps.harness;
|
|
361
|
+
const harness = needsHarness
|
|
362
|
+
? (deps.harness ??
|
|
363
|
+
((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
|
|
364
|
+
: undefined;
|
|
365
|
+
try {
|
|
366
|
+
const gitlab = w.gitlab ??
|
|
367
|
+
gitlabRest({
|
|
368
|
+
projectPath,
|
|
369
|
+
token: await (w.credential ?? harnessCredential(harness))(credentialName),
|
|
370
|
+
...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
|
|
371
|
+
});
|
|
372
|
+
const checkpoints = w.checkpoints ?? worldCheckpoints(harness, application);
|
|
373
|
+
const scans = w.scans ??
|
|
374
|
+
harnessScanLauncher(harness, {
|
|
375
|
+
targetId,
|
|
376
|
+
inputs: {
|
|
377
|
+
application,
|
|
378
|
+
repositoryUrl: ctx.taskContext["repository.url"] || "",
|
|
379
|
+
...(ctx.taskContext["image.ref"] ? { imageRef: ctx.taskContext["image.ref"] } : {}),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
const intervalMs = Number(ctx.taskContext["watch.interval_ms"] || Number.NaN);
|
|
383
|
+
const summary = await runWatchLoop({
|
|
384
|
+
application,
|
|
385
|
+
ref,
|
|
386
|
+
gitlab,
|
|
387
|
+
checkpoints,
|
|
388
|
+
scans,
|
|
389
|
+
...(Number.isFinite(intervalMs) && intervalMs > 0 ? { intervalMs } : {}),
|
|
390
|
+
...(w.maxPolls !== undefined ? { maxPolls: w.maxPolls } : {}),
|
|
391
|
+
...(w.signal ? { signal: w.signal } : {}),
|
|
392
|
+
...(w.sleep ? { sleep: w.sleep } : {}),
|
|
393
|
+
...(deps.onEvent ? { onEvent: (e) => deps.onEvent?.(JSON.stringify(e)) } : {}),
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
output: `watch ${application}@${ref}: ${summary.polls} polls, ${summary.scans} scan missions, ` +
|
|
397
|
+
`${summary.errors} errors; last pipeline ${summary.lastPipelineId || "none"}`,
|
|
398
|
+
success: true,
|
|
399
|
+
metadata: {
|
|
400
|
+
task: "watch",
|
|
401
|
+
application,
|
|
402
|
+
ref,
|
|
403
|
+
polls: String(summary.polls),
|
|
404
|
+
scans: String(summary.scans),
|
|
405
|
+
errors: String(summary.errors),
|
|
406
|
+
last_pipeline_id: String(summary.lastPipelineId),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
if (opened)
|
|
412
|
+
harness?.stop();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
417
|
+
* is returned to the caller and never logged: it is the GitLab token, and the
|
|
418
|
+
* console stream this dispatch writes is shown in the browser.
|
|
419
|
+
*/
|
|
420
|
+
export function harnessCredential(harness) {
|
|
421
|
+
return async (name) => {
|
|
422
|
+
const res = await harness.client.getCredential({ context: harness.context, name });
|
|
423
|
+
if (res.error) {
|
|
424
|
+
throw new Error(`GetCredential(${name}) refused: ${res.error.message}`);
|
|
425
|
+
}
|
|
426
|
+
const secret = res.credential?.secretData;
|
|
427
|
+
const value = secret?.case === "apiKey" || secret?.case === "bearerToken" || secret?.case === "customSecret"
|
|
428
|
+
? secret.value
|
|
429
|
+
: undefined;
|
|
430
|
+
if (!value) {
|
|
431
|
+
throw new Error(`GetCredential(${name}) returned no usable secret. A GitLab project access token is ` +
|
|
432
|
+
"stored as an api key, a bearer token, or a single-valued custom secret.");
|
|
433
|
+
}
|
|
434
|
+
return value;
|
|
435
|
+
};
|
|
436
|
+
}
|
|
267
437
|
/**
|
|
268
438
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
269
439
|
*
|
package/dist/fix.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
|
|
2
|
+
/**
|
|
3
|
+
* The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
|
|
4
|
+
*
|
|
5
|
+
* After a Scan mission lands, the agent works the Application's open Findings
|
|
6
|
+
* in priority order. For each one it can act on it rewrites the repository,
|
|
7
|
+
* runs the repository's own tests, and only then opens a merge request set to
|
|
8
|
+
* merge itself when the pipeline succeeds. It records the scan verdict on the
|
|
9
|
+
* commit as `gibson/scan` and posts one note saying what it fixed, what it did
|
|
10
|
+
* not, and why.
|
|
11
|
+
*
|
|
12
|
+
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
13
|
+
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
14
|
+
* workspace, the tests run there, and a failure resets the workspace and leaves
|
|
15
|
+
* the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
|
|
16
|
+
*
|
|
17
|
+
* THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
|
|
18
|
+
* rescan did not see the weakness again — only the rescan is, and absence is
|
|
19
|
+
* not an observation, so it is reconciled once at a scan's completion
|
|
20
|
+
* (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
|
|
21
|
+
* request opens and `fixing` → `fixed` when GitLab reports that request merged.
|
|
22
|
+
*
|
|
23
|
+
* THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
|
|
24
|
+
* `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
|
|
25
|
+
* request title, description, note or status description is built from it, and
|
|
26
|
+
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
27
|
+
* recorded output if it ever did.
|
|
28
|
+
*
|
|
29
|
+
* TWO SEAMS ARE DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
|
|
30
|
+
* Findings and writing a Finding's status both exist on the Go SDK as of
|
|
31
|
+
* v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
|
|
32
|
+
* carries no `applicationFindings` and its `observe` admits only host, domain,
|
|
33
|
+
* subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
|
|
34
|
+
* {@link FindingSource} and {@link FindingStatusWriter} have no platform
|
|
35
|
+
* implementation, and the Fix is driven by whatever supplies them. This is a
|
|
36
|
+
* seam, not a second code path: when the wire arrives there is one
|
|
37
|
+
* implementation to add and nothing here changes.
|
|
38
|
+
*/
|
|
39
|
+
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
40
|
+
export declare const SEVERITIES: readonly ["critical", "high", "medium", "low", "info"];
|
|
41
|
+
/** A Finding as the Fix needs it — the read half of sdk-ts#51. */
|
|
42
|
+
export interface FixableFinding {
|
|
43
|
+
/** The graph's `brain_id`, so a write lands on the node that was read. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** `open`, `fixing`, `fixed` or `verified`. */
|
|
46
|
+
status: string;
|
|
47
|
+
severity: string;
|
|
48
|
+
/** The CVE, GHSA or platform id this Finding is an instance of. */
|
|
49
|
+
vulnerabilityId: string;
|
|
50
|
+
/** Where it is: a `Package`, a `Repository` file, or a `Service`. */
|
|
51
|
+
placeLabel: string;
|
|
52
|
+
placeKey: string;
|
|
53
|
+
/** `P1`..`P4` once the read carries what a triage pass decided (gibson#1684). */
|
|
54
|
+
priority?: string;
|
|
55
|
+
/** The merge request already opened for this Finding, when it is `fixing`. */
|
|
56
|
+
mergeRequestIid?: number;
|
|
57
|
+
}
|
|
58
|
+
/** Reads an Application's Findings. Platform implementation pending sdk-ts#51. */
|
|
59
|
+
export interface FindingSource {
|
|
60
|
+
/**
|
|
61
|
+
* The Application's Findings in the given statuses, **in the order they
|
|
62
|
+
* should be worked**. Rejects when the graph is unreachable; it must never
|
|
63
|
+
* resolve empty to signal failure, because an empty list reads as a healthy
|
|
64
|
+
* Application and the Fix would report success over a live backlog.
|
|
65
|
+
*/
|
|
66
|
+
findings(application: string, statuses: string[]): Promise<FixableFinding[]>;
|
|
67
|
+
}
|
|
68
|
+
/** Moves a Finding through its statuses. Platform implementation pending sdk-ts#51. */
|
|
69
|
+
export interface FindingStatusWriter {
|
|
70
|
+
/** Record that a merge request now proposes this Finding's fix. */
|
|
71
|
+
markFixing(findingId: string, mr: MergeRequest): Promise<void>;
|
|
72
|
+
/** Record that the merge request merged. Never `verified` — the rescan does that. */
|
|
73
|
+
markFixed(findingId: string): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
/** A checkout the Fix can change and test before anything is pushed. */
|
|
76
|
+
export interface Workspace {
|
|
77
|
+
/** File contents, or `undefined` when the path does not exist. */
|
|
78
|
+
read(path: string): Promise<string | undefined>;
|
|
79
|
+
/** Stage a rewrite in the workspace. Not a push. */
|
|
80
|
+
write(path: string, content: string): Promise<void>;
|
|
81
|
+
/** Run the repository's own tests. `output` is quoted in the note on failure. */
|
|
82
|
+
test(): Promise<{
|
|
83
|
+
ok: boolean;
|
|
84
|
+
output: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** Discard staged rewrites, so a failed fix does not leak into the next one. */
|
|
87
|
+
reset(): Promise<void>;
|
|
88
|
+
/** What was staged, in commit order. */
|
|
89
|
+
staged(): FileChange[];
|
|
90
|
+
}
|
|
91
|
+
/** What the agent proposes to do about one Finding. */
|
|
92
|
+
export interface FixPlan {
|
|
93
|
+
/** Which of the three the owner's scope named. Reported in the note. */
|
|
94
|
+
kind: "dependency" | "source" | "config";
|
|
95
|
+
/** One line, for the merge request title. */
|
|
96
|
+
summary: string;
|
|
97
|
+
/** The commit message body. */
|
|
98
|
+
detail: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decides what to change for a Finding, and stages it in the workspace.
|
|
102
|
+
*
|
|
103
|
+
* Returning `undefined` means "no strategy for this Finding" — reported in the
|
|
104
|
+
* note as unfixed with that reason, never silently skipped. A planner that
|
|
105
|
+
* throws is treated the same way, with its message as the reason: one Finding
|
|
106
|
+
* with no strategy must not stop the ones behind it.
|
|
107
|
+
*/
|
|
108
|
+
export interface FixPlanner {
|
|
109
|
+
plan(finding: FixableFinding, ws: Workspace): Promise<FixPlan | undefined>;
|
|
110
|
+
}
|
|
111
|
+
/** What happened to one Finding in a Fix pass. */
|
|
112
|
+
export interface FixOutcome {
|
|
113
|
+
finding: FixableFinding;
|
|
114
|
+
/** `fixing` when a merge request opened, `merged` when one already had, else `unfixed`. */
|
|
115
|
+
result: "fixing" | "merged" | "unfixed";
|
|
116
|
+
/** Why it is unfixed. Empty otherwise. Quoted verbatim in the note. */
|
|
117
|
+
reason: string;
|
|
118
|
+
mergeRequest?: MergeRequest;
|
|
119
|
+
plan?: FixPlan;
|
|
120
|
+
}
|
|
121
|
+
/** One line of the console stream, so the ops wall shows the Fix working. */
|
|
122
|
+
export type FixEvent = {
|
|
123
|
+
type: "fix.start";
|
|
124
|
+
application: string;
|
|
125
|
+
commit: string;
|
|
126
|
+
open: number;
|
|
127
|
+
fixing: number;
|
|
128
|
+
} | {
|
|
129
|
+
type: "fix.plan";
|
|
130
|
+
findingId: string;
|
|
131
|
+
kind: FixPlan["kind"];
|
|
132
|
+
summary: string;
|
|
133
|
+
} | {
|
|
134
|
+
type: "fix.tests";
|
|
135
|
+
findingId: string;
|
|
136
|
+
ok: boolean;
|
|
137
|
+
} | {
|
|
138
|
+
type: "fix.mr";
|
|
139
|
+
findingId: string;
|
|
140
|
+
iid: number;
|
|
141
|
+
webUrl: string;
|
|
142
|
+
} | {
|
|
143
|
+
type: "fix.merged";
|
|
144
|
+
findingId: string;
|
|
145
|
+
iid: number;
|
|
146
|
+
} | {
|
|
147
|
+
type: "fix.skip";
|
|
148
|
+
findingId: string;
|
|
149
|
+
reason: string;
|
|
150
|
+
} | {
|
|
151
|
+
type: "fix.error";
|
|
152
|
+
stage: "read" | "status" | "note" | "plan";
|
|
153
|
+
message: string;
|
|
154
|
+
};
|
|
155
|
+
export interface FixSummary {
|
|
156
|
+
application: string;
|
|
157
|
+
commit: string;
|
|
158
|
+
outcomes: FixOutcome[];
|
|
159
|
+
/** True when every Finding that had a strategy ended `fixing` or `merged`. */
|
|
160
|
+
clean: boolean;
|
|
161
|
+
}
|
|
162
|
+
export interface RunFixOptions {
|
|
163
|
+
application: string;
|
|
164
|
+
/** The commit the Scan mission ran against — what the status is set on. */
|
|
165
|
+
commit: string;
|
|
166
|
+
/** The branch merge requests target. */
|
|
167
|
+
targetRef: string;
|
|
168
|
+
findings: FindingSource;
|
|
169
|
+
status: FindingStatusWriter;
|
|
170
|
+
planner: FixPlanner;
|
|
171
|
+
workspace: Workspace;
|
|
172
|
+
gitlab: GitLabWriter;
|
|
173
|
+
/** The pipeline page, linked from the commit status. */
|
|
174
|
+
pipelineUrl?: string;
|
|
175
|
+
/** Cap on merge requests opened in one pass, so a backlog cannot flood review. */
|
|
176
|
+
maxMergeRequests?: number;
|
|
177
|
+
onEvent?: (e: FixEvent) => void;
|
|
178
|
+
}
|
|
179
|
+
/** Default ceiling on merge requests opened by one pass. */
|
|
180
|
+
export declare const DEFAULT_MAX_MERGE_REQUESTS = 10;
|
|
181
|
+
/**
|
|
182
|
+
* Run one Fix pass over an Application.
|
|
183
|
+
*
|
|
184
|
+
* Reconcile first, then fix. A Finding already `fixing` whose merge request has
|
|
185
|
+
* merged becomes `fixed` before anything new is opened, so a pass never opens a
|
|
186
|
+
* second merge request for a Finding the previous pass already resolved.
|
|
187
|
+
*/
|
|
188
|
+
export declare function runFix(opts: RunFixOptions): Promise<FixSummary>;
|
|
189
|
+
/**
|
|
190
|
+
* The branch one Finding's fix lands on. Stable, so a retry reuses it rather
|
|
191
|
+
* than opening a second merge request for the same Finding.
|
|
192
|
+
*
|
|
193
|
+
* The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
|
|
194
|
+
* git refuses a ref containing `..`, and a vulnerability id is attacker-
|
|
195
|
+
* adjacent text that ends up interpolated into a URL path, so a dot run is
|
|
196
|
+
* exactly the shape not to carry through. The Finding id is appended so two
|
|
197
|
+
* occurrences of one CVE get two branches.
|
|
198
|
+
*/
|
|
199
|
+
export declare function fixBranch(finding: FixableFinding): string;
|
|
200
|
+
/** Counts by severity, worst first, for the note and the status line. */
|
|
201
|
+
export declare function countsBySeverity(findings: FixableFinding[]): Record<string, number>;
|
|
202
|
+
/** The one-line verdict GitLab shows beside the commit. */
|
|
203
|
+
export declare function statusDescription(summary: FixSummary): string;
|
|
204
|
+
/** The merge request description. Names the Finding it closes and how. */
|
|
205
|
+
export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
|
|
206
|
+
/** The note posted on each merge request: what this pass did, in full. */
|
|
207
|
+
export declare function noteBody(summary: FixSummary): string;
|