@xaccefy/pi-casefile 0.7.1 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,6 +35,7 @@
35
35
  "src/ledger.ts",
36
36
  "src/workflow.ts",
37
37
  "src/poc-runner.ts",
38
+ "src/pipeline-submit.ts",
38
39
  "src/scratchpad.ts",
39
40
  "src/sqlite-compat/index.ts",
40
41
  "skills",
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
- * Event: before_agent_start — injects cyber workflow (+ active case list) once per user prompt
6
+ * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -44,6 +44,7 @@ import {
44
44
  updateCaseResult,
45
45
  writeCaseReport,
46
46
  } from "./ledger.ts";
47
+ import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
47
48
  import { type PocRun, runPoc } from "./poc-runner.ts";
48
49
  import {
49
50
  type ScratchpadPhase,
@@ -511,9 +512,15 @@ function buildCaseListContext(records: CaseRecord[]): string {
511
512
  return lines.join("\n");
512
513
  }
513
514
 
514
- /** Always includes cyber workflow; attaches case list when active cases exist. */
515
- function buildAgentInjection(active: CaseRecord[]): string {
515
+ /**
516
+ * Builds the per-prompt injection. The cyber workflow is session-scope data —
517
+ * it never changes — so the caller passes includeWorkflow=true exactly once
518
+ * per session; re-injecting it on every prompt is pure token cost. The active
519
+ * case list DOES change as cases are added, so it is refreshed every prompt.
520
+ */
521
+ function buildAgentInjection(active: CaseRecord[], includeWorkflow: boolean): string {
516
522
  const caseList = buildCaseListContext(active);
523
+ if (!includeWorkflow) return caseList;
517
524
  // Workflow FIRST for prominence, then case list as reference data.
518
525
  return caseList ? `${STATIC_CYBER_WORKFLOW}\n\n${caseList}` : STATIC_CYBER_WORKFLOW;
519
526
  }
@@ -1207,6 +1214,80 @@ export default function casefileExtension(pi: ExtensionAPI) {
1207
1214
  },
1208
1215
  });
1209
1216
 
1217
+ // ── Tool: PipelineSubmit ──
1218
+
1219
+ pi.registerTool({
1220
+ name: "PipelineSubmit",
1221
+ label: "Submit Stage Output",
1222
+ description:
1223
+ "Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
1224
+ promptSnippet: "Validate and submit a pipeline stage's output",
1225
+ promptGuidelines: [
1226
+ "Every stage output a subagent returns must go through PipelineSubmit before the next stage is dispatched. Do not eyeball schemas.",
1227
+ "If the verdict is repair, fix the fields listed in errors and re-submit the same output. The repair budget is 2 attempts per finding — after that the submission is rejected and the stage is failed.",
1228
+ "Unhandled skeptic output: an unparseable or schema-invalid skeptic response is UNDETERMINED, never DISPROVEN. A tracer error is UNREACHABLE. PipelineSubmit returns repair for these instead of accepting them.",
1229
+ "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1230
+ ],
1231
+ parameters: Type.Object(
1232
+ {
1233
+ run_id: Type.String({
1234
+ description: "Pipeline run identifier (same as the scratchpad run_id)",
1235
+ }),
1236
+ stage: Type.String({
1237
+ enum: [...SUBMIT_STAGES],
1238
+ description: "Pipeline stage: hunt | trace | skeptic | validate | chain | report",
1239
+ }),
1240
+ output: Type.Union([Type.String(), Type.Object({}, { additionalProperties: true })], {
1241
+ description: "The stage output as a JSON object or JSON string (code fences tolerated)",
1242
+ }),
1243
+ },
1244
+ { additionalProperties: false },
1245
+ ),
1246
+
1247
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1248
+ const result = pipeline_submit(
1249
+ params.run_id as string,
1250
+ params.stage as SubmitStage,
1251
+ params.output,
1252
+ );
1253
+ const statusLine =
1254
+ result.verdict === "accepted"
1255
+ ? `ACCEPTED (${params.stage}) — artifact: ${result.artifact}`
1256
+ : result.verdict === "repair"
1257
+ ? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
1258
+ : `REJECTED — ${result.errors.join("\n")}`;
1259
+ return {
1260
+ content: [{ type: "text", text: statusLine }],
1261
+ isError: result.verdict !== "accepted",
1262
+ details: result as unknown as Record<string, unknown>,
1263
+ };
1264
+ },
1265
+
1266
+ renderCall(args, theme) {
1267
+ return new Text(
1268
+ theme.fg("toolTitle", theme.bold("PipelineSubmit ")) +
1269
+ theme.fg("dim", `${args.stage ?? ""}`),
1270
+ 0,
1271
+ 0,
1272
+ );
1273
+ },
1274
+
1275
+ renderResult(result, _opts, theme) {
1276
+ const details = result.details as { verdict?: string; repair_attempt?: number } | undefined;
1277
+ if (details?.verdict === "accepted") {
1278
+ return new Text(theme.fg("success", "✓ PipelineSubmit accepted"), 0, 0);
1279
+ }
1280
+ if (details?.verdict === "repair") {
1281
+ return new Text(
1282
+ theme.fg("warning", `↷ PipelineSubmit repair ${details.repair_attempt}/2`),
1283
+ 0,
1284
+ 0,
1285
+ );
1286
+ }
1287
+ return new Text(theme.fg("error", "✗ PipelineSubmit rejected"), 0, 0);
1288
+ },
1289
+ });
1290
+
1210
1291
  // ── Tool: ScratchpadInit ──
1211
1292
 
1212
1293
  pi.registerTool({
@@ -1599,13 +1680,22 @@ export default function casefileExtension(pi: ExtensionAPI) {
1599
1680
 
1600
1681
  // ── Event: Inject cyber workflow into system prompt ──
1601
1682
  // XP (offensive) mode is OFF by default so normal dev work stays quiet.
1602
- // Only when enabled do we inject the cyber workflow (and case list) into
1603
- // the system prompt each turn. Injecting into event.systemPrompt (not as a
1604
- // conversation message) makes the attacker mindset immediate and avoids
1605
- // session bloat from repeated message entries.
1683
+ // When enabled, the cyber workflow is injected ONCE per session (first
1684
+ // prompt); the active case list refreshes every prompt because it changes
1685
+ // as cases are added. Injecting into event.systemPrompt (not as a
1686
+ // conversation message) avoids session bloat from repeated message entries.
1687
+ let workflowInjected = false;
1606
1688
 
1607
1689
  pi.on("before_agent_start", async (event) => {
1608
1690
  if (readXpMode() === "off") return;
1691
+ // Skip subagent child processes: pi-subagents runs each child in its own
1692
+ // pi process (PI_SUBAGENT_CHILD=1) with this extension loaded. Injecting
1693
+ // the workflow + entire active-case ledger into every child dispatch is a
1694
+ // token multiplier (N subagents × workflow + growing case list per turn) —
1695
+ // workers get what they need via their task and tool guidelines.
1696
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
1697
+
1698
+ const includeWorkflow = !workflowInjected;
1609
1699
 
1610
1700
  let active: CaseRecord[] = [];
1611
1701
  try {
@@ -1614,7 +1704,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
1614
1704
  // No database yet — still inject workflow.
1615
1705
  }
1616
1706
 
1617
- const injection = buildAgentInjection(active);
1707
+ const injection = buildAgentInjection(active, includeWorkflow);
1708
+ if (!injection) return; // workflow already injected, no active cases
1709
+ workflowInjected = true;
1618
1710
 
1619
1711
  // Inject workflow FIRST (before skills) so the attacker mindset is
1620
1712
  // prominent, not buried at the end of a long system prompt.
@@ -0,0 +1,509 @@
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 KILL_REASONS = [
90
+ "unreachable",
91
+ "framework_protection",
92
+ "input_validation_blocks",
93
+ "requires_privilege_attacker_lacks",
94
+ "poc_failed_3x",
95
+ "no_real_impact",
96
+ "intended_behavior",
97
+ "duplicate",
98
+ ] as const;
99
+
100
+ const SPECS: Record<SubmitStage, StageSpec> = {
101
+ // schemas/stage-finding.json
102
+ hunt: {
103
+ required: [
104
+ { name: "vuln_class", type: "string", enum: VULN_CLASSES },
105
+ { name: "sink", type: "string" },
106
+ { name: "entry_point", type: "string" },
107
+ { name: "confidence", type: "string", enum: ["low", "medium", "high"] },
108
+ { name: "evidence", type: "string" },
109
+ ],
110
+ // Source targets: file + line. Live targets: endpoint.
111
+ locatorXor: [["file", "line"], ["endpoint"]],
112
+ },
113
+ // schemas/stage-trace.json
114
+ trace: {
115
+ required: [
116
+ { name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
117
+ { name: "entry_point", type: "string" },
118
+ { name: "call_chain", type: "array", minItems: 1 },
119
+ { name: "defenses_checked", type: "array" },
120
+ { name: "attacker_model", type: "string" },
121
+ ],
122
+ conditional: [
123
+ { when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
124
+ { when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
125
+ ],
126
+ },
127
+ // schemas/stage-skeptic.json
128
+ skeptic: {
129
+ required: [
130
+ { name: "finding_id", type: "string" },
131
+ { name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
132
+ { name: "reasoning", type: "string" },
133
+ { name: "evidence_reviewed", type: "array", minItems: 1 },
134
+ ],
135
+ conditional: [
136
+ { when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
137
+ ],
138
+ },
139
+ // schemas/stage-validation.json
140
+ validate: {
141
+ required: [
142
+ { name: "finding_id", type: "string" },
143
+ { name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
144
+ { name: "technique_used", type: "string" },
145
+ { name: "detection_method", type: "string" },
146
+ ],
147
+ conditional: [
148
+ {
149
+ when: { field: "status", equals: "confirmed" },
150
+ require: ["poc_path", "run_log", "evidence_extracted"],
151
+ },
152
+ { when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
153
+ ],
154
+ },
155
+ // schemas/stage-chain.json
156
+ chain: {
157
+ required: [
158
+ { name: "chains", type: "array" },
159
+ { name: "summary", type: "string" },
160
+ ],
161
+ },
162
+ // schemas/stage-report.json
163
+ report: {
164
+ required: [
165
+ { name: "target", type: "string" },
166
+ { name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
167
+ { name: "findings", type: "array" },
168
+ { name: "coverage", type: "object" }, // patternProperties object, not array
169
+ { name: "summary", type: "string" },
170
+ ],
171
+ },
172
+ };
173
+
174
+ const MAX_REPAIR_ATTEMPTS = 2;
175
+
176
+ // Segment-based test-path detection: matches "test", "__tests__", "specs",
177
+ // "e2e", "test-utils", "fixtures", ... anchored per path segment so
178
+ // "latest"/"contest"/"attest" do NOT match. A regex-only version missed
179
+ // leading underscores ("__tests__").
180
+ const TEST_SEGMENT_RE =
181
+ /^[._-]*(tests?|specs?|e2e|fixtures?|mocks?|stubs?|examples?|samples?|test[-_]?data|test[-_]?utils)[._-]*$/i;
182
+ const TEST_FILE_RE =
183
+ /([._-](test|spec|mock|fixture|stub|example|sample)\.[a-z0-9]+$|^test[-_]utils\.[a-z0-9]+$)/i;
184
+
185
+ /** Chain items: each must have title, severity, steps (≥2), narrative. */
186
+ const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
187
+
188
+ // ── Pre-filter constants (hunt stage only) ───────────────────────────
189
+
190
+ /**
191
+ * Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
192
+ * from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
193
+ * auditor can submit those under vuln_class "other"+bugClass documentation;
194
+ * the gate errs on filtering noise.
195
+ */
196
+
197
+ /** Trivial dedup: same file + vuln_class + line within this tolerance. */
198
+ const DEDUP_LINE_TOLERANCE = 10;
199
+
200
+ // ── Persistence ─────────────────────────────────────────────────────
201
+
202
+ type FindingRef = {
203
+ key: string;
204
+ file: string;
205
+ line?: number;
206
+ vuln_class: string;
207
+ };
208
+
209
+ type SubmitState = {
210
+ repairs: Record<string, number>;
211
+ accepted_findings: FindingRef[];
212
+ };
213
+
214
+ function statePath(runId: string): string {
215
+ return join(getRunDir(runId), "pipeline-submit.json");
216
+ }
217
+
218
+ function readState(runId: string): SubmitState {
219
+ const p = statePath(runId);
220
+ if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
221
+ try {
222
+ const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
223
+ return {
224
+ repairs: raw.repairs ?? {},
225
+ accepted_findings: raw.accepted_findings ?? [],
226
+ };
227
+ } catch {
228
+ return { repairs: {}, accepted_findings: [] };
229
+ }
230
+ }
231
+
232
+ function writeState(runId: string, state: SubmitState): void {
233
+ writeFileSync(statePath(runId), JSON.stringify(state, null, 2), "utf8");
234
+ }
235
+
236
+ /** Project root containing the scratchpad (file-existence checks resolve here). */
237
+ function projectRoot(): string {
238
+ return dirname(getScratchpadRoot());
239
+ }
240
+
241
+ // ── Parsing ──────────────────────────────────────────────────────────
242
+
243
+ function parseOutput(output: unknown): { obj?: Record<string, unknown>; error?: string } {
244
+ if (typeof output === "object" && output !== null && !Array.isArray(output)) {
245
+ return { obj: output as Record<string, unknown> };
246
+ }
247
+ if (typeof output !== "string") {
248
+ return { error: "output must be a JSON object or a JSON string" };
249
+ }
250
+ let text = output.trim();
251
+ // Tolerate markdown code fences around the payload.
252
+ text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
253
+ try {
254
+ const parsed = JSON.parse(text);
255
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
256
+ return { error: "output must parse to a JSON object" };
257
+ }
258
+ return { obj: parsed as Record<string, unknown> };
259
+ } catch (e) {
260
+ return { error: `output is not valid JSON: ${(e as Error).message.slice(0, 120)}` };
261
+ }
262
+ }
263
+
264
+ /** Stable repair-bucket key for a submission. */
265
+ function submissionKey(stage: SubmitStage, obj: Record<string, unknown>): string {
266
+ const id =
267
+ (typeof obj.finding_id === "string" && obj.finding_id) ||
268
+ (typeof obj.title === "string" && obj.title) ||
269
+ (typeof obj.id === "string" && obj.id);
270
+ const tail = id ?? createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 8);
271
+ return `${stage}:${tail}`;
272
+ }
273
+
274
+ // ── Validation ──────────────────────────────────────────────────────
275
+
276
+ function isNonEmptyString(v: unknown): v is string {
277
+ return typeof v === "string" && v.trim().length > 0;
278
+ }
279
+
280
+ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
281
+ const spec = SPECS[stage];
282
+ const errors: string[] = [];
283
+
284
+ for (const field of spec.required) {
285
+ const v = obj[field.name];
286
+ if (field.type === "string") {
287
+ if (!isNonEmptyString(v)) {
288
+ errors.push(`${field.name}: missing or empty string`);
289
+ continue;
290
+ }
291
+ } else if (field.type === "object") {
292
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
293
+ errors.push(`${field.name}: missing or not an object`);
294
+ continue;
295
+ }
296
+ } else if (field.type === "integer") {
297
+ if (typeof v !== "number" || !Number.isInteger(v)) {
298
+ errors.push(`${field.name}: missing or not an integer`);
299
+ continue;
300
+ }
301
+ } else {
302
+ if (!Array.isArray(v)) {
303
+ errors.push(`${field.name}: missing or not an array`);
304
+ continue;
305
+ }
306
+ if (field.minItems !== undefined && v.length < field.minItems) {
307
+ errors.push(`${field.name}: needs at least ${field.minItems} item(s), got ${v.length}`);
308
+ continue;
309
+ }
310
+ }
311
+ if (field.enum && !field.enum.includes(v as never)) {
312
+ errors.push(`${field.name}: "${String(v)}" not in { ${field.enum.join(" | ")} }`);
313
+ }
314
+ }
315
+
316
+ if (spec.locatorXor) {
317
+ const [a, b] = spec.locatorXor;
318
+ const hasSet = (set: string[]) =>
319
+ set.every((f) => (f === "line" ? Number.isInteger(obj[f]) : isNonEmptyString(obj[f])));
320
+ const hasA = hasSet(a);
321
+ const hasB = hasSet(b);
322
+ if (hasA === hasB) {
323
+ errors.push(
324
+ `locator: provide exactly one of { ${a.join("+")} } (source) or { ${b.join("+")} } (live)`,
325
+ );
326
+ }
327
+ if (hasA && typeof obj.line === "number" && obj.line < 1) {
328
+ errors.push("line: must be >= 1");
329
+ }
330
+ }
331
+
332
+ for (const cond of spec.conditional ?? []) {
333
+ if (obj[cond.when.field] === cond.when.equals) {
334
+ for (const name of cond.require) {
335
+ if (!isNonEmptyString(obj[name])) {
336
+ errors.push(`${name}: required when ${cond.when.field} = ${cond.when.equals}`);
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ // Chain items have their own inner contract (≥2 steps, severity enum).
343
+ if (stage === "chain" && Array.isArray(obj.chains)) {
344
+ obj.chains.forEach((c, i) => {
345
+ const chain = c as Record<string, unknown>;
346
+ if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
347
+ if (
348
+ !isNonEmptyString(chain.severity) ||
349
+ !(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity)
350
+ ) {
351
+ errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
352
+ }
353
+ if (!Array.isArray(chain.steps) || chain.steps.length < 2) {
354
+ errors.push(`chains[${i}].steps: needs at least 2 case IDs`);
355
+ }
356
+ if (!isNonEmptyString(chain.narrative))
357
+ errors.push(`chains[${i}].narrative: missing or empty`);
358
+ });
359
+ }
360
+
361
+ return errors;
362
+ }
363
+
364
+ // ── Pre-filter + dedup (hunt only) ──────────────────────────────────
365
+
366
+ function prefilterHunt(obj: Record<string, unknown>): string | null {
367
+ const file = typeof obj.file === "string" ? obj.file : undefined;
368
+ if (!file) return null; // live target: endpoint locator, nothing to filter
369
+ const normalized = file.replace(/^\.?\//, "");
370
+ const segments = normalized.split("/");
371
+ if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(normalized)) {
372
+ return (
373
+ `test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
374
+ `test code are noise. If this is a deliberately-shipped test credential, ` +
375
+ `re-submit documenting why it ships to production.`
376
+ );
377
+ }
378
+ const root = projectRoot();
379
+ const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(root, normalized);
380
+ // Containment: resolved path must stay inside the project, otherwise a
381
+ // "finding" can point at ../ or absolute files outside the target repo.
382
+ const rel = relative(root, abs);
383
+ if (rel.startsWith("..") || isAbsolute(rel)) {
384
+ return (
385
+ `containment filter: "${file}" resolves outside the project root (${root}). ` +
386
+ `Findings must reference files inside the target repository.`
387
+ );
388
+ }
389
+ if (!existsSync(abs)) {
390
+ return (
391
+ `file-existence filter: "${file}" does not exist under the project root ` +
392
+ `(${root}). Hallucinated paths are rejected outright.`
393
+ );
394
+ }
395
+ return null;
396
+ }
397
+
398
+ function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
399
+ const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
400
+ const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
401
+ const line = typeof obj.line === "number" ? obj.line : undefined;
402
+ if (!file || !vulnClass) return {};
403
+ for (const accepted of state.accepted_findings) {
404
+ if (accepted.vuln_class !== vulnClass) continue;
405
+ if (accepted.file !== file) continue;
406
+ if (
407
+ line !== undefined &&
408
+ accepted.line !== undefined &&
409
+ Math.abs(line - accepted.line) > DEDUP_LINE_TOLERANCE
410
+ ) {
411
+ continue;
412
+ }
413
+ return { duplicateOf: accepted.key };
414
+ }
415
+ return {};
416
+ }
417
+
418
+ // ── Public API ───────────────────────────────────────────────────────
419
+
420
+ const STAGE_TO_PHASE: Record<SubmitStage, ScratchpadPhase> = {
421
+ hunt: "hunt",
422
+ trace: "trace",
423
+ skeptic: "skeptic",
424
+ validate: "validate",
425
+ chain: "chain",
426
+ report: "report",
427
+ };
428
+
429
+ export function pipeline_submit(runId: string, stage: SubmitStage, output: unknown): SubmitResult {
430
+ const parsed = parseOutput(output);
431
+ if (parsed.error || !parsed.obj) {
432
+ const state = readState(runId);
433
+ const key = `${stage}:unparseable`;
434
+ state.repairs[key] = (state.repairs[key] ?? 0) + 1;
435
+ const attempt = state.repairs[key];
436
+ // Persist before BOTH returns — otherwise unparseable output bypasses the
437
+ // repair budget forever (counter never hits disk on the rejected path).
438
+ writeState(runId, state);
439
+ if (attempt > MAX_REPAIR_ATTEMPTS) {
440
+ return { verdict: "rejected", stage, errors: [parsed.error ?? "unparseable"], key };
441
+ }
442
+ return {
443
+ verdict: "repair",
444
+ stage,
445
+ errors: [parsed.error ?? "unparseable"],
446
+ repair_attempt: attempt,
447
+ key,
448
+ };
449
+ }
450
+
451
+ const obj = parsed.obj;
452
+ const key = submissionKey(stage, obj);
453
+
454
+ const errors = validateStage(stage, obj);
455
+ if (errors.length > 0) {
456
+ const state = readState(runId);
457
+ state.repairs[key] = (state.repairs[key] ?? 0) + 1;
458
+ const attempt = state.repairs[key];
459
+ if (attempt > MAX_REPAIR_ATTEMPTS) {
460
+ writeState(runId, state);
461
+ return {
462
+ verdict: "rejected",
463
+ stage,
464
+ errors: [...errors, `repair budget exhausted (${MAX_REPAIR_ATTEMPTS} attempts)`],
465
+ key,
466
+ };
467
+ }
468
+ writeState(runId, state);
469
+ return { verdict: "repair", stage, errors, repair_attempt: attempt, key };
470
+ }
471
+
472
+ // Hunt stage: deterministic noise gates before acceptance.
473
+ if (stage === "hunt") {
474
+ const filtered = prefilterHunt(obj);
475
+ if (filtered) {
476
+ return { verdict: "rejected", stage, errors: [filtered], key };
477
+ }
478
+ const state = readState(runId);
479
+ const { duplicateOf } = dedupHunt(state, obj);
480
+ if (duplicateOf) {
481
+ return {
482
+ verdict: "rejected",
483
+ stage,
484
+ errors: [
485
+ `trivial dedup: same file + vuln_class within ${DEDUP_LINE_TOLERANCE} lines of accepted finding ${duplicateOf}`,
486
+ ],
487
+ key,
488
+ duplicate_of: duplicateOf,
489
+ };
490
+ }
491
+ if (typeof obj.file === "string" && typeof obj.vuln_class === "string") {
492
+ state.accepted_findings.push({
493
+ key,
494
+ file: obj.file.replace(/^\.?\//, ""),
495
+ line: typeof obj.line === "number" ? obj.line : undefined,
496
+ vuln_class: obj.vuln_class,
497
+ });
498
+ writeState(runId, state);
499
+ }
500
+ }
501
+
502
+ const artifact = scratchpad_write(
503
+ runId,
504
+ STAGE_TO_PHASE[stage],
505
+ `${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
506
+ JSON.stringify(obj, null, 2),
507
+ );
508
+ return { verdict: "accepted", stage, errors: [], key, artifact };
509
+ }
package/src/workflow.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cyber workflow injected into agent context when XP mode is ON.
3
3
  *
4
- * Skills (pipeline, web-pentest) already cover tool usage and methodology.
4
+ * Skills (cyberwf, web-pentest) already cover tool usage and methodology.
5
5
  * This file adds the unique attacker discipline: state machine with
6
6
  * preconditions, attacker model, impact validation, adversarial review,
7
7
  * kill checklist, and report-readiness criteria.
@@ -19,7 +19,7 @@ Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without a proven attacke
19
19
 
20
20
  ## Tool Reference
21
21
 
22
- **Casefile (state tracking):** CaseAdd, CaseUpdate, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PromoteFinding
22
+ **Casefile (state tracking):** CaseAdd, CaseUpdate, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, PromoteFinding, PipelineSubmit
23
23
 
24
24
  **Scratchpad (pipeline artifacts):** ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
25
25