@xaccefy/pi-casefile 0.8.2 → 0.8.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/package.json +3 -5
- package/src/index.ts +50 -40
- package/src/ledger.ts +331 -324
- package/src/pipeline-submit.ts +212 -14
- package/src/poc-runner.ts +21 -4
- package/src/scratchpad.ts +78 -12
- package/src/workflow.ts +8 -8
package/src/pipeline-submit.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
23
|
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
24
24
|
|
|
@@ -204,19 +204,43 @@ function statePath(runId: string): string {
|
|
|
204
204
|
function readState(runId: string): SubmitState {
|
|
205
205
|
const p = statePath(runId);
|
|
206
206
|
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
|
|
208
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
209
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
210
|
+
}
|
|
211
|
+
const repairs = raw.repairs ?? {};
|
|
212
|
+
if (typeof repairs !== "object" || repairs === null || Array.isArray(repairs)) {
|
|
213
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: repairs must be an object`);
|
|
214
|
+
}
|
|
215
|
+
for (const [k, v] of Object.entries(repairs)) {
|
|
216
|
+
if (typeof k !== "string" || typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
217
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: invalid repair counter`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const accepted = raw.accepted_findings ?? [];
|
|
221
|
+
if (!Array.isArray(accepted)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings must be an array`,
|
|
224
|
+
);
|
|
215
225
|
}
|
|
226
|
+
for (const [i, item] of accepted.entries()) {
|
|
227
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings[${i}] invalid`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
repairs: repairs as Record<string, number>,
|
|
235
|
+
accepted_findings: accepted as FindingRef[],
|
|
236
|
+
};
|
|
216
237
|
}
|
|
217
238
|
|
|
218
239
|
function writeState(runId: string, state: SubmitState): void {
|
|
219
|
-
|
|
240
|
+
const p = statePath(runId);
|
|
241
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
242
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
243
|
+
renameSync(tmp, p);
|
|
220
244
|
}
|
|
221
245
|
|
|
222
246
|
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
@@ -272,6 +296,109 @@ function isNonEmptyString(v: unknown): v is string {
|
|
|
272
296
|
return typeof v === "string" && v.trim().length > 0;
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
function requireStringArray(errors: string[], path: string, value: unknown, minItems = 0): void {
|
|
300
|
+
if (!Array.isArray(value)) {
|
|
301
|
+
errors.push(`${path}: missing or not an array`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (value.length < minItems) errors.push(`${path}: needs at least ${minItems} item(s)`);
|
|
305
|
+
value.forEach((item, i) => {
|
|
306
|
+
if (!isNonEmptyString(item)) errors.push(`${path}[${i}]: missing or empty string`);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
311
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
312
|
+
? (value as Record<string, unknown>)
|
|
313
|
+
: undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
317
|
+
const findingSeverities = ["info", "low", "medium", "high", "critical"];
|
|
318
|
+
if (Array.isArray(obj.findings)) {
|
|
319
|
+
obj.findings.forEach((item, i) => {
|
|
320
|
+
const finding = asObject(item);
|
|
321
|
+
if (!finding) {
|
|
322
|
+
errors.push(`findings[${i}]: not an object`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const field of ["id", "vuln_class", "severity", "status"] as const) {
|
|
326
|
+
if (!isNonEmptyString(finding[field]))
|
|
327
|
+
errors.push(`findings[${i}].${field}: missing or empty`);
|
|
328
|
+
}
|
|
329
|
+
if (!isNonEmptyString(finding.file) && !isNonEmptyString(finding.endpoint)) {
|
|
330
|
+
errors.push(`findings[${i}]: provide file or endpoint`);
|
|
331
|
+
}
|
|
332
|
+
if (isNonEmptyString(finding.severity) && !findingSeverities.includes(finding.severity)) {
|
|
333
|
+
errors.push(`findings[${i}].severity: invalid value`);
|
|
334
|
+
}
|
|
335
|
+
if (isNonEmptyString(finding.status) && !["confirmed", "reported"].includes(finding.status)) {
|
|
336
|
+
errors.push(`findings[${i}].status: invalid value`);
|
|
337
|
+
}
|
|
338
|
+
if (finding.chain_with !== undefined)
|
|
339
|
+
requireStringArray(errors, `findings[${i}].chain_with`, finding.chain_with);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const coverage = asObject(obj.coverage);
|
|
344
|
+
if (coverage) {
|
|
345
|
+
const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
|
|
346
|
+
for (const [key, value] of Object.entries(coverage)) {
|
|
347
|
+
if (!/^[a-z-]+$/.test(key)) errors.push(`coverage.${key}: invalid class key`);
|
|
348
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
349
|
+
errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (obj.chains !== undefined) {
|
|
355
|
+
if (!Array.isArray(obj.chains)) {
|
|
356
|
+
errors.push("chains: not an array");
|
|
357
|
+
} else {
|
|
358
|
+
obj.chains.forEach((item, i) => {
|
|
359
|
+
const chain = asObject(item);
|
|
360
|
+
if (!chain) {
|
|
361
|
+
errors.push(`chains[${i}]: not an object`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (chain.title !== undefined && !isNonEmptyString(chain.title))
|
|
365
|
+
errors.push(`chains[${i}].title: missing or empty`);
|
|
366
|
+
if (chain.steps !== undefined)
|
|
367
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps);
|
|
368
|
+
if (
|
|
369
|
+
chain.severity !== undefined &&
|
|
370
|
+
(!isNonEmptyString(chain.severity) ||
|
|
371
|
+
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity))
|
|
372
|
+
) {
|
|
373
|
+
errors.push(`chains[${i}].severity: invalid value`);
|
|
374
|
+
}
|
|
375
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
376
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (obj.patches_applied !== undefined) {
|
|
383
|
+
if (!Array.isArray(obj.patches_applied)) {
|
|
384
|
+
errors.push("patches_applied: not an array");
|
|
385
|
+
} else {
|
|
386
|
+
obj.patches_applied.forEach((item, i) => {
|
|
387
|
+
const patch = asObject(item);
|
|
388
|
+
if (!patch) {
|
|
389
|
+
errors.push(`patches_applied[${i}]: not an object`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const field of ["finding_id", "diff_summary", "re_attack_result"] as const) {
|
|
393
|
+
if (patch[field] !== undefined && !isNonEmptyString(patch[field])) {
|
|
394
|
+
errors.push(`patches_applied[${i}].${field}: missing or empty`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
275
402
|
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
276
403
|
const spec = SPECS[stage];
|
|
277
404
|
const errors: string[] = [];
|
|
@@ -334,10 +461,78 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
334
461
|
}
|
|
335
462
|
}
|
|
336
463
|
|
|
337
|
-
|
|
464
|
+
if (stage === "trace") {
|
|
465
|
+
requireStringArray(errors, "call_chain", obj.call_chain, 1);
|
|
466
|
+
if (Array.isArray(obj.defenses_checked)) {
|
|
467
|
+
obj.defenses_checked.forEach((item, i) => {
|
|
468
|
+
const defense = asObject(item);
|
|
469
|
+
if (!defense) {
|
|
470
|
+
errors.push(`defenses_checked[${i}]: not an object`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!isNonEmptyString(defense.defense))
|
|
474
|
+
errors.push(`defenses_checked[${i}].defense: missing or empty`);
|
|
475
|
+
if (!isNonEmptyString(defense.location))
|
|
476
|
+
errors.push(`defenses_checked[${i}].location: missing or empty`);
|
|
477
|
+
if (
|
|
478
|
+
!isNonEmptyString(defense.verdict) ||
|
|
479
|
+
!["bypassed", "blocked", "not-present"].includes(defense.verdict)
|
|
480
|
+
) {
|
|
481
|
+
errors.push(`defenses_checked[${i}].verdict: must be bypassed, blocked, or not-present`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (stage === "skeptic") {
|
|
488
|
+
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
489
|
+
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
490
|
+
const allowed = [
|
|
491
|
+
"unreachable",
|
|
492
|
+
"framework_protection",
|
|
493
|
+
"input_validation_blocks",
|
|
494
|
+
"requires_privilege_attacker_lacks",
|
|
495
|
+
"intended_behavior",
|
|
496
|
+
"overstated_impact",
|
|
497
|
+
"duplicate",
|
|
498
|
+
"test_artifact",
|
|
499
|
+
"out_of_scope",
|
|
500
|
+
];
|
|
501
|
+
if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (stage === "validate") {
|
|
506
|
+
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
507
|
+
const allowed = [
|
|
508
|
+
"unreachable",
|
|
509
|
+
"framework_protection",
|
|
510
|
+
"input_validation_blocks",
|
|
511
|
+
"requires_privilege_attacker_lacks",
|
|
512
|
+
"poc_failed_3x",
|
|
513
|
+
"no_real_impact",
|
|
514
|
+
"intended_behavior",
|
|
515
|
+
"duplicate",
|
|
516
|
+
];
|
|
517
|
+
if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
|
|
518
|
+
}
|
|
519
|
+
if (
|
|
520
|
+
obj.refinement_attempts !== undefined &&
|
|
521
|
+
(!Number.isInteger(obj.refinement_attempts) ||
|
|
522
|
+
(obj.refinement_attempts as number) < 1 ||
|
|
523
|
+
(obj.refinement_attempts as number) > 3)
|
|
524
|
+
) {
|
|
525
|
+
errors.push("refinement_attempts: must be an integer from 1 to 3");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
338
529
|
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
339
530
|
obj.chains.forEach((c, i) => {
|
|
340
|
-
const chain = c
|
|
531
|
+
const chain = asObject(c);
|
|
532
|
+
if (!chain) {
|
|
533
|
+
errors.push(`chains[${i}]: not an object`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
341
536
|
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
342
537
|
if (
|
|
343
538
|
!isNonEmptyString(chain.severity) ||
|
|
@@ -345,14 +540,17 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
345
540
|
) {
|
|
346
541
|
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
347
542
|
}
|
|
348
|
-
|
|
349
|
-
|
|
543
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps, 2);
|
|
544
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
545
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
350
546
|
}
|
|
351
547
|
if (!isNonEmptyString(chain.narrative))
|
|
352
548
|
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
353
549
|
});
|
|
354
550
|
}
|
|
355
551
|
|
|
552
|
+
if (stage === "report") validateReport(errors, obj);
|
|
553
|
+
|
|
356
554
|
return errors;
|
|
357
555
|
}
|
|
358
556
|
|
package/src/poc-runner.ts
CHANGED
|
@@ -34,6 +34,12 @@ export type PocRun = {
|
|
|
34
34
|
rawOutput?: string;
|
|
35
35
|
/** True when `output` was truncated for display (rawOutput has more). */
|
|
36
36
|
truncated?: boolean;
|
|
37
|
+
/** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
|
|
38
|
+
outputComplete?: boolean;
|
|
39
|
+
/** Harness mode used for this run. */
|
|
40
|
+
mode?: string;
|
|
41
|
+
/** Harness target used for this run. */
|
|
42
|
+
target?: string;
|
|
37
43
|
/**
|
|
38
44
|
* True when the run never started because of harness infrastructure
|
|
39
45
|
* failure (e.g. sandbox image pull failed) — set only by the runner,
|
|
@@ -106,8 +112,6 @@ const EXTENSION_MAP: Record<string, string> = {
|
|
|
106
112
|
};
|
|
107
113
|
|
|
108
114
|
const OUTPUT_MAX_CHARS = 4000;
|
|
109
|
-
/** Sanitized output is kept whole (for marker checks) up to this size. */
|
|
110
|
-
const RAW_OUTPUT_MAX_CHARS = 4 * 1024 * 1024;
|
|
111
115
|
const TIMEOUT_MS = 30_000;
|
|
112
116
|
/** Completion sentinel echoed after the PoC command inside the sandbox shell. */
|
|
113
117
|
function makeSentinel(): string {
|
|
@@ -278,9 +282,16 @@ function sanitizeOutput(output: string): string {
|
|
|
278
282
|
|
|
279
283
|
/** Split sanitized output into the raw (whole) and display (sliced) halves. */
|
|
280
284
|
function splitOutput(raw: string): { rawOutput: string; output: string; truncated: boolean } {
|
|
281
|
-
const rawOutput = raw.slice(0, RAW_OUTPUT_MAX_CHARS);
|
|
282
285
|
const truncated = raw.length > OUTPUT_MAX_CHARS;
|
|
283
|
-
return { rawOutput, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
286
|
+
return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
|
|
290
|
+
return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
|
|
294
|
+
return !result.error && result.signal === null;
|
|
284
295
|
}
|
|
285
296
|
|
|
286
297
|
/** Reject control characters in harness-supplied PoC env values. */
|
|
@@ -427,6 +438,8 @@ function runSandboxed(
|
|
|
427
438
|
sandbox: true,
|
|
428
439
|
completed: false,
|
|
429
440
|
infraError: true,
|
|
441
|
+
outputComplete: false,
|
|
442
|
+
...runProvenance(env),
|
|
430
443
|
};
|
|
431
444
|
}
|
|
432
445
|
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
@@ -463,6 +476,8 @@ function runSandboxed(
|
|
|
463
476
|
ranAt,
|
|
464
477
|
sandbox: true,
|
|
465
478
|
completed,
|
|
479
|
+
outputComplete: outputWasComplete(result),
|
|
480
|
+
...runProvenance(env),
|
|
466
481
|
};
|
|
467
482
|
} finally {
|
|
468
483
|
// Best-effort: remove any container still running after a timeout/kill.
|
|
@@ -521,6 +536,8 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
521
536
|
ranAt,
|
|
522
537
|
sandbox: false,
|
|
523
538
|
completed,
|
|
539
|
+
outputComplete: outputWasComplete(result),
|
|
540
|
+
...runProvenance(env),
|
|
524
541
|
};
|
|
525
542
|
}
|
|
526
543
|
|
package/src/scratchpad.ts
CHANGED
|
@@ -24,7 +24,16 @@
|
|
|
24
24
|
* `--fresh` clears it via scratchpad_clear().
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
28
37
|
import { dirname, join, resolve } from "node:path";
|
|
29
38
|
|
|
30
39
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -152,9 +161,21 @@ function sanitizeName(name: string, label: string): string {
|
|
|
152
161
|
return safe;
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
function runDirName(runId: string): string {
|
|
165
|
+
const safe = sanitizeName(runId, "run_id");
|
|
166
|
+
if (safe === runId) return safe;
|
|
167
|
+
const suffix = createHash("sha256").update(runId).digest("hex").slice(0, 12);
|
|
168
|
+
return `${safe.slice(0, 80)}-${suffix}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
|
|
172
|
+
function legacyRunDirName(runId: string): string {
|
|
173
|
+
return sanitizeName(runId, "run_id");
|
|
174
|
+
}
|
|
175
|
+
|
|
155
176
|
/** The directory for a specific run. */
|
|
156
177
|
export function getRunDir(runId: string, projectRoot?: string): string {
|
|
157
|
-
return join(getScratchpadRoot(projectRoot),
|
|
178
|
+
return join(getScratchpadRoot(projectRoot), runDirName(runId));
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
/** The state.json path for a run. */
|
|
@@ -187,23 +208,42 @@ function ensureRunDirs(runDir: string): void {
|
|
|
187
208
|
function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
|
|
188
209
|
const statePath = getStatePath(runId, projectRoot);
|
|
189
210
|
if (!existsSync(statePath)) return null;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
211
|
+
const raw = readFileSync(statePath, "utf8");
|
|
212
|
+
const cp = JSON.parse(raw) as ScratchpadCheckpoint;
|
|
213
|
+
if (typeof cp !== "object" || cp === null || Array.isArray(cp)) {
|
|
214
|
+
throw new Error(`Corrupt scratchpad state for ${runId}: root must be an object`);
|
|
215
|
+
}
|
|
216
|
+
if (cp.run_id !== runId) {
|
|
217
|
+
throw new Error(`Corrupt scratchpad state for ${runId}: state belongs to ${cp.run_id}`);
|
|
218
|
+
}
|
|
219
|
+
if (!Array.isArray(cp.completed_phases)) {
|
|
220
|
+
throw new Error(`Corrupt scratchpad state for ${runId}: completed_phases must be an array`);
|
|
221
|
+
}
|
|
222
|
+
for (const phase of cp.completed_phases) {
|
|
223
|
+
if (!PHASE_ORDER.includes(phase)) {
|
|
224
|
+
throw new Error(`Corrupt scratchpad state for ${runId}: invalid phase ${phase}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!cp.phase_ids || typeof cp.phase_ids !== "object" || Array.isArray(cp.phase_ids)) {
|
|
228
|
+
cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
|
|
199
229
|
}
|
|
230
|
+
if (
|
|
231
|
+
!cp.phase_summaries ||
|
|
232
|
+
typeof cp.phase_summaries !== "object" ||
|
|
233
|
+
Array.isArray(cp.phase_summaries)
|
|
234
|
+
) {
|
|
235
|
+
cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
|
|
236
|
+
}
|
|
237
|
+
return cp;
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
|
|
203
241
|
cp.last_updated = new Date().toISOString();
|
|
204
242
|
const statePath = getStatePath(cp.run_id, projectRoot);
|
|
205
243
|
ensureRunDirs(getRunDir(cp.run_id, projectRoot));
|
|
206
|
-
|
|
244
|
+
const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
245
|
+
writeFileSync(tmp, JSON.stringify(cp, null, 2), "utf8");
|
|
246
|
+
renameSync(tmp, statePath);
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
// ── Public API ───────────────────────────────────────────────────────
|
|
@@ -268,6 +308,32 @@ export function scratchpad_read(
|
|
|
268
308
|
/**
|
|
269
309
|
* List all artifacts written for a phase.
|
|
270
310
|
*/
|
|
311
|
+
export function scratchpad_runs(projectRoot?: string): string[] {
|
|
312
|
+
const root = getScratchpadRoot(projectRoot);
|
|
313
|
+
if (!existsSync(root)) return [];
|
|
314
|
+
const out: string[] = [];
|
|
315
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
316
|
+
if (!entry.isDirectory()) continue;
|
|
317
|
+
const state = join(root, entry.name, "state.json");
|
|
318
|
+
if (!existsSync(state)) continue;
|
|
319
|
+
try {
|
|
320
|
+
const cp = JSON.parse(readFileSync(state, "utf8")) as { run_id?: unknown };
|
|
321
|
+
if (typeof cp.run_id !== "string") continue;
|
|
322
|
+
if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
|
|
323
|
+
out.push(cp.run_id);
|
|
324
|
+
} else if (join(root, legacyRunDirName(cp.run_id)) === join(root, entry.name)) {
|
|
325
|
+
// Runs created before the hash-suffix naming used sanitizeName(runId)
|
|
326
|
+
// as the directory; keep surfacing them in bundle discovery. Safe ids
|
|
327
|
+
// were never suffixed, so only legacy unsafe ids can land here.
|
|
328
|
+
out.push(cp.run_id);
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Corrupt runs are ignored during report bundle discovery; direct resume still fails closed.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
271
337
|
export function scratchpad_list(
|
|
272
338
|
runId: string,
|
|
273
339
|
phase: ScratchpadPhase,
|
package/src/workflow.ts
CHANGED
|
@@ -38,15 +38,15 @@ Think like a real external attacker, not a code reviewer. Technical bugs are che
|
|
|
38
38
|
|
|
39
39
|
**Web lookup (research):** web_search, web_fetch, exploit_search, context7, deepwiki, http_request
|
|
40
40
|
|
|
41
|
-
**Subagent dispatch:** \`subagent({agent:
|
|
41
|
+
**Subagent dispatch:** every launch uses \`subagent({ workflowScript: "return runs.run('stable-key', { agent: 'tracer', task: '...' })", context: 'fresh', async: true })\`. Parallel HUNT uses one workflowScript with \`return runs.all([{ key: 'run-class-attempt', agent: 'auditor', task: '...' }, ...])\`. Stable keys include run, stage, class/case, and attempt. Dispatch specialists; do NOT do their work yourself.
|
|
42
42
|
|
|
43
43
|
## Stage Machine (run in order — you are the coordinator)
|
|
44
44
|
|
|
45
45
|
RECON (you, inline) → **HUNT** (auditor subagents, one per attack class, parallel) → TRACE (tracer) → SKEPTIC (high-confidence only) → VALIDATE (exploit) → CHAIN (chain) → REPORT (reporter)
|
|
46
46
|
|
|
47
|
-
**HARD GATE — after RECON:** record the entry-point inventory, then STOP all inline reading/probing. Your
|
|
47
|
+
**HARD GATE — after RECON:** record the entry-point inventory, then STOP all inline reading/probing. Your next tool call MUST launch one async workflowScript whose \`runs.all([...])\` dispatches HUNT auditors. When its completion is delivered, submit each output through PipelineSubmit. If you catch yourself mapping a sink, reading a handler, or probing an endpoint beyond the recon inventory, stop and add it to a HUNT task.
|
|
48
48
|
|
|
49
|
-
**Subagent crash handling:** a
|
|
49
|
+
**Subagent crash handling:** a crash (SIGABRT, OOM, timeout) is a RETRY, not a verdict. Launch one new workflowScript with the same specialist task, a new stable attempt key, and a stronger model. Crash again → record \`blocked: <agent> crashed\` in the pipeline-run case and continue; never silently drop the stage.
|
|
50
50
|
|
|
51
51
|
## Case Lifecycle (State Machine)
|
|
52
52
|
${LIFECYCLE_DIAGRAM}
|
|
@@ -66,7 +66,7 @@ ${LIFECYCLE_DIAGRAM}
|
|
|
66
66
|
| Advance To | Required Case Fields | On Disk |
|
|
67
67
|
|-----------|---------------------|---------|
|
|
68
68
|
| HYPOTHESIS → INVESTIGATING | evidence (observations), confidence | Notes on what was observed |
|
|
69
|
-
| INVESTIGATING → **CONFIRMED** | evidence, poc, **impact** (content below), severity, **target**, **disconfirmation** (your documented disprove attempt) | PoC script
|
|
69
|
+
| INVESTIGATING → **CONFIRMED** | evidence, poc, **impact** (content below), severity, **target**, **disconfirmation** (your documented disprove attempt) | PoC script exit 0 with verification_marker; same-script control at a distinct control_target completes with liveness but no vuln marker; disconfirmation script completed and exited non-0. |
|
|
70
70
|
| Any → KILLED | assumptions (why it died) | — |
|
|
71
71
|
| CONFIRMED → REPORTED | CaseContext(id) succeeded (records report path) AND the reporter agent wrote the report file | Context bundle + report file |
|
|
72
72
|
|
|
@@ -120,7 +120,7 @@ If you cannot name a concrete attacker who gains something they should not have
|
|
|
120
120
|
|
|
121
121
|
The finding must survive an attempt to disprove it. Two tiers, gated on \`confidence\` (severity comes later, from the PoC):
|
|
122
122
|
|
|
123
|
-
**\`confidence: high\` → skeptic subagent (MANDATORY):** dispatch \`subagent({
|
|
123
|
+
**\`confidence: high\` → skeptic subagent (MANDATORY):** dispatch it BEFORE the exploit agent with \`subagent({ workflowScript: "return runs.run('skeptic-<case>-1', { agent: 'skeptic', task: '...' })", context: 'fresh', async: true })\`. It independently re-reads the source (or re-probes live), verifies scope, and tries to disprove. Its \`disconfirmation_attempt\` becomes the case's \`disconfirmation\` — stronger than self-disconfirmation. DISPROVEN → killed directly, no tie-breaker. Do NOT skip; do NOT self-disconfirm high-confidence findings.
|
|
124
124
|
|
|
125
125
|
**Below high → self-disconfirmation:** actively try to disprove your own finding; document it. Not a formality.
|
|
126
126
|
|
|
@@ -133,7 +133,7 @@ If the disconfirmation script (\`disconfirmation_path\`) exits 0, promotion is b
|
|
|
133
133
|
|
|
134
134
|
**Evidence chain closure (before PromoteFinding):** promotion is rejected unless the case carries an **artifact-backed** \`observation\` evidence item (EvidenceAdd role=observation with \`artifact_path\` — the initial signal, stored with its SHA-256) in addition to the auto-recorded reproduction item. Record observations as you go, not at promote time.
|
|
135
135
|
|
|
136
|
-
**
|
|
136
|
+
**Differential control check (REQUIRED for EVERY promotion):** pass \`control_path\` (the SAME bytes as the PoC), a distinct \`control_target\`, and \`control_liveness_marker\`. The harness runs the same script with \`PI_POC_MODE=poc\` and the case target, then \`PI_POC_MODE=control\` and \`control_target\`. Control must complete, print liveness after reaching the baseline, and omit the vulnerability marker. Checks use complete captured output; crashed or truncated output blocks promotion. **Local/live findings:** \`local:true\` uses the host-network Docker sandbox. Bare host execution still needs operator \`PI_POC_ALLOW_LOCAL=1\`.
|
|
137
137
|
|
|
138
138
|
**PoC audit (anti-cheat, before PromoteFinding):** have an independent eye on the PoC script itself. For \`confidence: high\` findings the skeptic agent re-reads the PoC file (not just the source) hunting for: unconditional marker prints, trivially-true checks (accepting any 200, grepping for always-present strings), hardcoded expected values, and local mocks of the target. Record the audit result as an EvidenceAdd \`observation\` item (or \`refutation\` if it found a cheat → kill). The model that writes the check must not be the only one that reads it. The deterministic backstops are code, not prompts: the control run (mandatory, marker-absence + liveness-presence checks) and, for high/critical, the executed disconfirmation run.
|
|
139
139
|
|
|
@@ -210,7 +210,7 @@ Reproduce at least twice or via two methods.
|
|
|
210
210
|
## At REPORT
|
|
211
211
|
|
|
212
212
|
1. **Run CaseContext(case_id)** — writes the context bundle (complete record, PoC + disconfirmation logs, links, pipeline artifacts) and records the report path.
|
|
213
|
-
2. **Dispatch the reporter subagent
|
|
213
|
+
2. **Dispatch the reporter subagent** with \`subagent({ workflowScript: "return runs.run('report-<case>-1', { agent: 'reporter', task: 'Write the final report. case_id=<id>, context_path=<context path>, report_path=<report path>, program_name=<if known>.' })", context: 'fresh', async: true })\`. It writes the polished report and flips the case to REPORTED.
|
|
214
214
|
3. **Report-readiness gate** (YOU check this on the reporter's output before accepting; on failure, re-dispatch with the gap list):
|
|
215
215
|
- Deterministic reproduction by another researcher
|
|
216
216
|
- Steps realistic in production
|
|
@@ -277,7 +277,7 @@ Write the final report as a self-contained markdown file at the report path Case
|
|
|
277
277
|
- **No finding is confirmed until its target is verified in scope** per the program's scope instruction. Out-of-scope findings are killed, not confirmed.
|
|
278
278
|
- **No finding is validated without a reachability trace** showing REACHABLE.
|
|
279
279
|
- **High-confidence findings: do your own adversarial disconfirmation.** No skeptic subagent in lite mode — actively try to disprove your own finding and document the attempt in \`disconfirmation\`. Failing to disprove is the expected outcome.
|
|
280
|
-
- **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation
|
|
280
|
+
- **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation. Every promotion also requires \`disconfirmation_path\`, same-script \`control_path\`, a distinct \`control_target\`, and \`control_liveness_marker\`. The PoC must print the verification marker; the completed control must print liveness and omit it. \`local:true\` uses a host-network sandbox; bare host execution needs operator \`PI_POC_ALLOW_LOCAL=1\`. No mocks.
|
|
281
281
|
- **Severity is derived from proven PoC impact, not theory.** Under-claiming is safe; over-claiming gets the finding rejected at triage.
|
|
282
282
|
- **Evidence-first:** every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior.
|
|
283
283
|
- **Design & runtime check (mandatory before CONFIRMED):** actively search the target's docs, git history, changelog, and runtime/framework docs for evidence the behavior is BY DESIGN or already FIXED IN THE RUNTIME. Found it → KILL (\`intended_behavior\` / \`framework_protection\`), unless the documented intent is itself the flaw with real attacker impact. Not found → document the search in \`disconfirmation\` as non-intentionality proof.
|