@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.
@@ -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
- if (typeof val === "string")
122
- out[k] = val;
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
- return kind === "source-analysis" ? "source-analysis" : "opencode";
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
- if ((deps.taskKind ?? dispatchTaskKind(ctx, process.env)) === "source-analysis") {
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;