@xaccefy/pi-casefile 0.7.1 → 0.7.3
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/README.md +5 -4
- package/package.json +2 -1
- package/skills/casefile/SKILL.md +2 -2
- package/src/index.ts +193 -66
- package/src/ledger.ts +372 -33
- package/src/pipeline-submit.ts +498 -0
- package/src/sqlite-compat/index.ts +0 -1
- package/src/workflow.ts +158 -123
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PipelineSubmit — the stage-output gate.
|
|
3
|
+
*
|
|
4
|
+
* The coordinator (model) dispatches stage subagents and submits their output
|
|
5
|
+
* here. This module is where the pipeline stops trusting prose: it validates
|
|
6
|
+
* stage output against the field specs mirrored from schemas/*.json, applies
|
|
7
|
+
* the deterministic pre-filter (test paths, hallucinated files, trivial dedup),
|
|
8
|
+
* and counts repair attempts. A stage cannot advance on an invalid output —
|
|
9
|
+
* the answer is REPAIR (with field-level errors) or REJECTED, in code.
|
|
10
|
+
*
|
|
11
|
+
* KEEP IN SYNC with schemas/*.json at the repo root. The JSON schemas are the
|
|
12
|
+
* canonical data contract for documentation; the SPECS table here is the
|
|
13
|
+
* executable gate (a focused validator for exactly these six shapes — no
|
|
14
|
+
* general JSON Schema engine).
|
|
15
|
+
*
|
|
16
|
+
* Persistence: .scratchpad/{run_id}/pipeline-submit.json
|
|
17
|
+
* { repairs: { "<stage>:<key>": n }, accepted_findings: FindingRef[] }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
getRunDir,
|
|
25
|
+
getScratchpadRoot,
|
|
26
|
+
type ScratchpadPhase,
|
|
27
|
+
scratchpad_write,
|
|
28
|
+
} from "./scratchpad.ts";
|
|
29
|
+
|
|
30
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export const SUBMIT_STAGES = ["hunt", "trace", "skeptic", "validate", "chain", "report"] as const;
|
|
33
|
+
export type SubmitStage = (typeof SUBMIT_STAGES)[number];
|
|
34
|
+
|
|
35
|
+
export type SubmitVerdict = "accepted" | "repair" | "rejected";
|
|
36
|
+
|
|
37
|
+
export type SubmitResult = {
|
|
38
|
+
verdict: SubmitVerdict;
|
|
39
|
+
stage: SubmitStage;
|
|
40
|
+
/** Field-level validation errors (repair) or rejection reason (rejected). */
|
|
41
|
+
errors: string[];
|
|
42
|
+
/** Repair attempt number (1-based) when verdict is repair. */
|
|
43
|
+
repair_attempt?: number;
|
|
44
|
+
/** Stable key identifying this finding's repair bucket. */
|
|
45
|
+
key?: string;
|
|
46
|
+
/** Set when hunt-stage dedup matched an accepted finding. */
|
|
47
|
+
duplicate_of?: string;
|
|
48
|
+
/** Scratchpad path the accepted output was written to. */
|
|
49
|
+
artifact?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type StageSpec = {
|
|
53
|
+
/** Fields that must be present and non-empty. */
|
|
54
|
+
required: {
|
|
55
|
+
name: string;
|
|
56
|
+
type: "string" | "integer" | "array" | "object";
|
|
57
|
+
enum?: readonly string[];
|
|
58
|
+
minItems?: number;
|
|
59
|
+
}[];
|
|
60
|
+
/** Exactly one of these locator field-sets must be fully present. */
|
|
61
|
+
locatorXor?: [string[], string[]];
|
|
62
|
+
/** Conditional requirements: when field equals value, these must be non-empty. */
|
|
63
|
+
conditional?: { when: { field: string; equals: string }; require: string[] }[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ── Stage specs (mirror of schemas/*.json semantics) ─────────────────
|
|
67
|
+
|
|
68
|
+
const VULN_CLASSES = [
|
|
69
|
+
"injection",
|
|
70
|
+
"xss",
|
|
71
|
+
"idor",
|
|
72
|
+
"bola",
|
|
73
|
+
"path-traversal",
|
|
74
|
+
"ssrf",
|
|
75
|
+
"command-injection",
|
|
76
|
+
"deserialization",
|
|
77
|
+
"auth-bypass",
|
|
78
|
+
"privilege-escalation",
|
|
79
|
+
"business-logic",
|
|
80
|
+
"race-condition",
|
|
81
|
+
"xxe",
|
|
82
|
+
"ssti",
|
|
83
|
+
"open-redirect",
|
|
84
|
+
"information-disclosure",
|
|
85
|
+
"crypto-weakness",
|
|
86
|
+
"other",
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
const SPECS: Record<SubmitStage, StageSpec> = {
|
|
90
|
+
// schemas/stage-finding.json
|
|
91
|
+
hunt: {
|
|
92
|
+
required: [
|
|
93
|
+
{ name: "vuln_class", type: "string", enum: VULN_CLASSES },
|
|
94
|
+
{ name: "sink", type: "string" },
|
|
95
|
+
{ name: "entry_point", type: "string" },
|
|
96
|
+
{ name: "confidence", type: "string", enum: ["low", "medium", "high"] },
|
|
97
|
+
{ name: "evidence", type: "string" },
|
|
98
|
+
],
|
|
99
|
+
// Source targets: file + line. Live targets: endpoint.
|
|
100
|
+
locatorXor: [["file", "line"], ["endpoint"]],
|
|
101
|
+
},
|
|
102
|
+
// schemas/stage-trace.json
|
|
103
|
+
trace: {
|
|
104
|
+
required: [
|
|
105
|
+
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
|
|
106
|
+
{ name: "entry_point", type: "string" },
|
|
107
|
+
{ name: "call_chain", type: "array", minItems: 1 },
|
|
108
|
+
{ name: "defenses_checked", type: "array" },
|
|
109
|
+
{ name: "attacker_model", type: "string" },
|
|
110
|
+
],
|
|
111
|
+
conditional: [
|
|
112
|
+
{ when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
|
|
113
|
+
{ when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
// schemas/stage-skeptic.json
|
|
117
|
+
skeptic: {
|
|
118
|
+
required: [
|
|
119
|
+
{ name: "finding_id", type: "string" },
|
|
120
|
+
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
|
|
121
|
+
{ name: "reasoning", type: "string" },
|
|
122
|
+
{ name: "evidence_reviewed", type: "array", minItems: 1 },
|
|
123
|
+
],
|
|
124
|
+
conditional: [
|
|
125
|
+
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
// schemas/stage-validation.json
|
|
129
|
+
validate: {
|
|
130
|
+
required: [
|
|
131
|
+
{ name: "finding_id", type: "string" },
|
|
132
|
+
{ name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
|
|
133
|
+
{ name: "technique_used", type: "string" },
|
|
134
|
+
{ name: "detection_method", type: "string" },
|
|
135
|
+
],
|
|
136
|
+
conditional: [
|
|
137
|
+
{
|
|
138
|
+
when: { field: "status", equals: "confirmed" },
|
|
139
|
+
require: ["poc_path", "run_log", "evidence_extracted"],
|
|
140
|
+
},
|
|
141
|
+
{ when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
// schemas/stage-chain.json
|
|
145
|
+
chain: {
|
|
146
|
+
required: [
|
|
147
|
+
{ name: "chains", type: "array" },
|
|
148
|
+
{ name: "summary", type: "string" },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
// schemas/stage-report.json
|
|
152
|
+
report: {
|
|
153
|
+
required: [
|
|
154
|
+
{ name: "target", type: "string" },
|
|
155
|
+
{ name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
|
|
156
|
+
{ name: "findings", type: "array" },
|
|
157
|
+
{ name: "coverage", type: "object" }, // patternProperties object, not array
|
|
158
|
+
{ name: "summary", type: "string" },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const MAX_REPAIR_ATTEMPTS = 2;
|
|
164
|
+
|
|
165
|
+
// Segment-based test-path detection: matches "test", "__tests__", "specs",
|
|
166
|
+
// "e2e", "test-utils", "fixtures", ... anchored per path segment so
|
|
167
|
+
// "latest"/"contest"/"attest" do NOT match. A regex-only version missed
|
|
168
|
+
// leading underscores ("__tests__").
|
|
169
|
+
const TEST_SEGMENT_RE =
|
|
170
|
+
/^[._-]*(tests?|specs?|e2e|fixtures?|mocks?|stubs?|examples?|samples?|test[-_]?data|test[-_]?utils)[._-]*$/i;
|
|
171
|
+
const TEST_FILE_RE =
|
|
172
|
+
/([._-](test|spec|mock|fixture|stub|example|sample)\.[a-z0-9]+$|^test[-_]utils\.[a-z0-9]+$)/i;
|
|
173
|
+
|
|
174
|
+
/** Chain items: each must have title, severity, steps (≥2), narrative. */
|
|
175
|
+
const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
|
|
176
|
+
|
|
177
|
+
// ── Pre-filter constants (hunt stage only) ───────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
|
|
181
|
+
* from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
|
|
182
|
+
* auditor can submit those under vuln_class "other"+bugClass documentation;
|
|
183
|
+
* the gate errs on filtering noise.
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
/** Trivial dedup: same file + vuln_class + line within this tolerance. */
|
|
187
|
+
const DEDUP_LINE_TOLERANCE = 10;
|
|
188
|
+
|
|
189
|
+
// ── Persistence ─────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
type FindingRef = {
|
|
192
|
+
key: string;
|
|
193
|
+
file: string;
|
|
194
|
+
line?: number;
|
|
195
|
+
vuln_class: string;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type SubmitState = {
|
|
199
|
+
repairs: Record<string, number>;
|
|
200
|
+
accepted_findings: FindingRef[];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function statePath(runId: string): string {
|
|
204
|
+
return join(getRunDir(runId), "pipeline-submit.json");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readState(runId: string): SubmitState {
|
|
208
|
+
const p = statePath(runId);
|
|
209
|
+
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
210
|
+
try {
|
|
211
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
|
|
212
|
+
return {
|
|
213
|
+
repairs: raw.repairs ?? {},
|
|
214
|
+
accepted_findings: raw.accepted_findings ?? [],
|
|
215
|
+
};
|
|
216
|
+
} catch {
|
|
217
|
+
return { repairs: {}, accepted_findings: [] };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function writeState(runId: string, state: SubmitState): void {
|
|
222
|
+
writeFileSync(statePath(runId), JSON.stringify(state, null, 2), "utf8");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
226
|
+
function projectRoot(): string {
|
|
227
|
+
return dirname(getScratchpadRoot());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Parsing ──────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function parseOutput(output: unknown): { obj?: Record<string, unknown>; error?: string } {
|
|
233
|
+
if (typeof output === "object" && output !== null && !Array.isArray(output)) {
|
|
234
|
+
return { obj: output as Record<string, unknown> };
|
|
235
|
+
}
|
|
236
|
+
if (typeof output !== "string") {
|
|
237
|
+
return { error: "output must be a JSON object or a JSON string" };
|
|
238
|
+
}
|
|
239
|
+
let text = output.trim();
|
|
240
|
+
// Tolerate markdown code fences around the payload.
|
|
241
|
+
text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(text);
|
|
244
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
245
|
+
return { error: "output must parse to a JSON object" };
|
|
246
|
+
}
|
|
247
|
+
return { obj: parsed as Record<string, unknown> };
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { error: `output is not valid JSON: ${(e as Error).message.slice(0, 120)}` };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Stable repair-bucket key for a submission. */
|
|
254
|
+
function submissionKey(stage: SubmitStage, obj: Record<string, unknown>): string {
|
|
255
|
+
const id =
|
|
256
|
+
(typeof obj.finding_id === "string" && obj.finding_id) ||
|
|
257
|
+
(typeof obj.title === "string" && obj.title) ||
|
|
258
|
+
(typeof obj.id === "string" && obj.id);
|
|
259
|
+
const tail = id ?? createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 8);
|
|
260
|
+
return `${stage}:${tail}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Validation ──────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
266
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
270
|
+
const spec = SPECS[stage];
|
|
271
|
+
const errors: string[] = [];
|
|
272
|
+
|
|
273
|
+
for (const field of spec.required) {
|
|
274
|
+
const v = obj[field.name];
|
|
275
|
+
if (field.type === "string") {
|
|
276
|
+
if (!isNonEmptyString(v)) {
|
|
277
|
+
errors.push(`${field.name}: missing or empty string`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
} else if (field.type === "object") {
|
|
281
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
282
|
+
errors.push(`${field.name}: missing or not an object`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
} else if (field.type === "integer") {
|
|
286
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
287
|
+
errors.push(`${field.name}: missing or not an integer`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
if (!Array.isArray(v)) {
|
|
292
|
+
errors.push(`${field.name}: missing or not an array`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (field.minItems !== undefined && v.length < field.minItems) {
|
|
296
|
+
errors.push(`${field.name}: needs at least ${field.minItems} item(s), got ${v.length}`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (field.enum && !field.enum.includes(v as never)) {
|
|
301
|
+
errors.push(`${field.name}: "${String(v)}" not in { ${field.enum.join(" | ")} }`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (spec.locatorXor) {
|
|
306
|
+
const [a, b] = spec.locatorXor;
|
|
307
|
+
const hasSet = (set: string[]) =>
|
|
308
|
+
set.every((f) => (f === "line" ? Number.isInteger(obj[f]) : isNonEmptyString(obj[f])));
|
|
309
|
+
const hasA = hasSet(a);
|
|
310
|
+
const hasB = hasSet(b);
|
|
311
|
+
if (hasA === hasB) {
|
|
312
|
+
errors.push(
|
|
313
|
+
`locator: provide exactly one of { ${a.join("+")} } (source) or { ${b.join("+")} } (live)`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (hasA && typeof obj.line === "number" && obj.line < 1) {
|
|
317
|
+
errors.push("line: must be >= 1");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const cond of spec.conditional ?? []) {
|
|
322
|
+
if (obj[cond.when.field] === cond.when.equals) {
|
|
323
|
+
for (const name of cond.require) {
|
|
324
|
+
if (!isNonEmptyString(obj[name])) {
|
|
325
|
+
errors.push(`${name}: required when ${cond.when.field} = ${cond.when.equals}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Chain items have their own inner contract (≥2 steps, severity enum).
|
|
332
|
+
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
333
|
+
obj.chains.forEach((c, i) => {
|
|
334
|
+
const chain = c as Record<string, unknown>;
|
|
335
|
+
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
336
|
+
if (
|
|
337
|
+
!isNonEmptyString(chain.severity) ||
|
|
338
|
+
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity)
|
|
339
|
+
) {
|
|
340
|
+
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
341
|
+
}
|
|
342
|
+
if (!Array.isArray(chain.steps) || chain.steps.length < 2) {
|
|
343
|
+
errors.push(`chains[${i}].steps: needs at least 2 case IDs`);
|
|
344
|
+
}
|
|
345
|
+
if (!isNonEmptyString(chain.narrative))
|
|
346
|
+
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return errors;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Pre-filter + dedup (hunt only) ──────────────────────────────────
|
|
354
|
+
|
|
355
|
+
function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
356
|
+
const file = typeof obj.file === "string" ? obj.file : undefined;
|
|
357
|
+
if (!file) return null; // live target: endpoint locator, nothing to filter
|
|
358
|
+
const normalized = file.replace(/^\.?\//, "");
|
|
359
|
+
const segments = normalized.split("/");
|
|
360
|
+
if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(normalized)) {
|
|
361
|
+
return (
|
|
362
|
+
`test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
|
|
363
|
+
`test code are noise. If this is a deliberately-shipped test credential, ` +
|
|
364
|
+
`re-submit documenting why it ships to production.`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const root = projectRoot();
|
|
368
|
+
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(root, normalized);
|
|
369
|
+
// Containment: resolved path must stay inside the project, otherwise a
|
|
370
|
+
// "finding" can point at ../ or absolute files outside the target repo.
|
|
371
|
+
const rel = relative(root, abs);
|
|
372
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
373
|
+
return (
|
|
374
|
+
`containment filter: "${file}" resolves outside the project root (${root}). ` +
|
|
375
|
+
`Findings must reference files inside the target repository.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (!existsSync(abs)) {
|
|
379
|
+
return (
|
|
380
|
+
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
381
|
+
`(${root}). Hallucinated paths are rejected outright.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
388
|
+
const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
|
|
389
|
+
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
390
|
+
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
391
|
+
if (!file || !vulnClass) return {};
|
|
392
|
+
for (const accepted of state.accepted_findings) {
|
|
393
|
+
if (accepted.vuln_class !== vulnClass) continue;
|
|
394
|
+
if (accepted.file !== file) continue;
|
|
395
|
+
if (
|
|
396
|
+
line !== undefined &&
|
|
397
|
+
accepted.line !== undefined &&
|
|
398
|
+
Math.abs(line - accepted.line) > DEDUP_LINE_TOLERANCE
|
|
399
|
+
) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
return { duplicateOf: accepted.key };
|
|
403
|
+
}
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
const STAGE_TO_PHASE: Record<SubmitStage, ScratchpadPhase> = {
|
|
410
|
+
hunt: "hunt",
|
|
411
|
+
trace: "trace",
|
|
412
|
+
skeptic: "skeptic",
|
|
413
|
+
validate: "validate",
|
|
414
|
+
chain: "chain",
|
|
415
|
+
report: "report",
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
export function pipeline_submit(runId: string, stage: SubmitStage, output: unknown): SubmitResult {
|
|
419
|
+
const parsed = parseOutput(output);
|
|
420
|
+
if (parsed.error || !parsed.obj) {
|
|
421
|
+
const state = readState(runId);
|
|
422
|
+
const key = `${stage}:unparseable`;
|
|
423
|
+
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
424
|
+
const attempt = state.repairs[key];
|
|
425
|
+
// Persist before BOTH returns — otherwise unparseable output bypasses the
|
|
426
|
+
// repair budget forever (counter never hits disk on the rejected path).
|
|
427
|
+
writeState(runId, state);
|
|
428
|
+
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
429
|
+
return { verdict: "rejected", stage, errors: [parsed.error ?? "unparseable"], key };
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
verdict: "repair",
|
|
433
|
+
stage,
|
|
434
|
+
errors: [parsed.error ?? "unparseable"],
|
|
435
|
+
repair_attempt: attempt,
|
|
436
|
+
key,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const obj = parsed.obj;
|
|
441
|
+
const key = submissionKey(stage, obj);
|
|
442
|
+
|
|
443
|
+
const errors = validateStage(stage, obj);
|
|
444
|
+
if (errors.length > 0) {
|
|
445
|
+
const state = readState(runId);
|
|
446
|
+
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
447
|
+
const attempt = state.repairs[key];
|
|
448
|
+
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
449
|
+
writeState(runId, state);
|
|
450
|
+
return {
|
|
451
|
+
verdict: "rejected",
|
|
452
|
+
stage,
|
|
453
|
+
errors: [...errors, `repair budget exhausted (${MAX_REPAIR_ATTEMPTS} attempts)`],
|
|
454
|
+
key,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
writeState(runId, state);
|
|
458
|
+
return { verdict: "repair", stage, errors, repair_attempt: attempt, key };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Hunt stage: deterministic noise gates before acceptance.
|
|
462
|
+
if (stage === "hunt") {
|
|
463
|
+
const filtered = prefilterHunt(obj);
|
|
464
|
+
if (filtered) {
|
|
465
|
+
return { verdict: "rejected", stage, errors: [filtered], key };
|
|
466
|
+
}
|
|
467
|
+
const state = readState(runId);
|
|
468
|
+
const { duplicateOf } = dedupHunt(state, obj);
|
|
469
|
+
if (duplicateOf) {
|
|
470
|
+
return {
|
|
471
|
+
verdict: "rejected",
|
|
472
|
+
stage,
|
|
473
|
+
errors: [
|
|
474
|
+
`trivial dedup: same file + vuln_class within ${DEDUP_LINE_TOLERANCE} lines of accepted finding ${duplicateOf}`,
|
|
475
|
+
],
|
|
476
|
+
key,
|
|
477
|
+
duplicate_of: duplicateOf,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (typeof obj.file === "string" && typeof obj.vuln_class === "string") {
|
|
481
|
+
state.accepted_findings.push({
|
|
482
|
+
key,
|
|
483
|
+
file: obj.file.replace(/^\.?\//, ""),
|
|
484
|
+
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
485
|
+
vuln_class: obj.vuln_class,
|
|
486
|
+
});
|
|
487
|
+
writeState(runId, state);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const artifact = scratchpad_write(
|
|
492
|
+
runId,
|
|
493
|
+
STAGE_TO_PHASE[stage],
|
|
494
|
+
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
495
|
+
JSON.stringify(obj, null, 2),
|
|
496
|
+
);
|
|
497
|
+
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
498
|
+
}
|
|
@@ -19,7 +19,6 @@ try {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|
|
23
22
|
export interface StatementSync {
|
|
24
23
|
run(...args: unknown[]): { lastInsertRowid: number; changes: number };
|
|
25
24
|
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|