@xaccefy/pi-casefile 0.8.3 → 0.9.1
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 +9 -5
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +4 -3
- package/src/evidence.ts +501 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +391 -224
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +853 -233
- package/src/pipeline-submit.ts +153 -43
- package/src/poc-runner.ts +216 -65
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +67 -28
- package/src/workflow.ts +91 -23
package/src/ledger.ts
CHANGED
|
@@ -11,8 +11,33 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
lstatSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
realpathSync,
|
|
21
|
+
statSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
evidenceNonceMatches,
|
|
28
|
+
type MainAgentVerdict,
|
|
29
|
+
normalizeEvidence,
|
|
30
|
+
type PoCEvidence,
|
|
31
|
+
parsePoCEvidence,
|
|
32
|
+
validateMainAgentVerdict,
|
|
33
|
+
} from "./evidence.ts";
|
|
34
|
+
import { type HarnessVerifyResult, verifyUrlBindingError } from "./harness-verify.ts";
|
|
35
|
+
import {
|
|
36
|
+
assertSafeRegularFile,
|
|
37
|
+
ensureSafeStateDirectory,
|
|
38
|
+
readSafeFile,
|
|
39
|
+
writeSafeFileExclusive,
|
|
40
|
+
} from "./safe-state.ts";
|
|
16
41
|
import {
|
|
17
42
|
findWorkspaceRoot,
|
|
18
43
|
getScratchpadRoot,
|
|
@@ -46,6 +71,51 @@ export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
|
46
71
|
|
|
47
72
|
/** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
|
|
48
73
|
const EVIDENCE_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
|
|
74
|
+
/** PoC evidence has a tighter runner-side cap and must remain equally bounded on re-read. */
|
|
75
|
+
const POC_EVIDENCE_MAX_BYTES = 256 * 1024;
|
|
76
|
+
/** Avoid racing an active or just-finished PoC whose bundle is not committed yet. */
|
|
77
|
+
export const POC_EVIDENCE_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
78
|
+
/** Immutable module-start role; child shells cannot upgrade this process by unsetting an env var. */
|
|
79
|
+
const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
|
|
80
|
+
|
|
81
|
+
function pathIsWithin(root: string, candidate: string): boolean {
|
|
82
|
+
const rel = relative(root, candidate);
|
|
83
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readWorkspaceArtifact(inputPath: string): { path: string; bytes: Buffer } {
|
|
87
|
+
const workspace = realpathSync(detectWorkspaceRoot());
|
|
88
|
+
const requested = resolve(workspace, inputPath);
|
|
89
|
+
if (!existsSync(requested)) {
|
|
90
|
+
throw new Error(`Evidence artifact not found on disk: ${inputPath}`);
|
|
91
|
+
}
|
|
92
|
+
const direct = lstatSync(requested);
|
|
93
|
+
if (direct.isSymbolicLink()) {
|
|
94
|
+
throw new Error(`Evidence artifact must not be a symbolic link: ${inputPath}`);
|
|
95
|
+
}
|
|
96
|
+
const canonical = realpathSync(requested);
|
|
97
|
+
if (!pathIsWithin(workspace, canonical)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Evidence artifact must stay inside the workspace (${workspace}): ${inputPath}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const stat = statSync(canonical);
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
throw new Error(`Evidence artifact is not a regular file: ${inputPath}`);
|
|
105
|
+
}
|
|
106
|
+
if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${inputPath}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const bytes = readFileSync(canonical);
|
|
112
|
+
if (bytes.byteLength > EVIDENCE_ARTIFACT_MAX_BYTES) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Evidence artifact too large (${bytes.byteLength} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${inputPath}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return { path: canonical, bytes };
|
|
118
|
+
}
|
|
49
119
|
|
|
50
120
|
/** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
|
|
51
121
|
export const EVIDENCE_ROLE_VALUES = [
|
|
@@ -157,6 +227,13 @@ export type CaseRecord = {
|
|
|
157
227
|
id: string;
|
|
158
228
|
title: string;
|
|
159
229
|
status: CaseStatus;
|
|
230
|
+
/**
|
|
231
|
+
* True once the case has EVER reached investigating or confirmed. The kill
|
|
232
|
+
* gate keys off this, not the current status: a demotion
|
|
233
|
+
* (investigating/confirmed -> hypothesis) must not let an advanced case die
|
|
234
|
+
* with a keyword in free text instead of artifact-backed refutation evidence.
|
|
235
|
+
*/
|
|
236
|
+
everAdvanced: boolean;
|
|
160
237
|
confidence: CaseConfidence;
|
|
161
238
|
severity?: CaseSeverity;
|
|
162
239
|
priority?: CasePriority;
|
|
@@ -182,8 +259,12 @@ export type CaseRecord = {
|
|
|
182
259
|
pocVerified?: PocVerificationRecord;
|
|
183
260
|
/** Verification of a disconfirmation run (set only by promoteFindingResult). */
|
|
184
261
|
disconfirmationVerified?: PocVerificationRecord;
|
|
185
|
-
/** Verification of a control-target run (set only by
|
|
262
|
+
/** Verification of a control-target run (set only by the confirmation gate). */
|
|
186
263
|
controlVerified?: PocVerificationRecord;
|
|
264
|
+
/** Phase-1 evidence bundle awaiting main-agent review (ConfirmFinding). */
|
|
265
|
+
pendingConfirmation?: PendingConfirmation;
|
|
266
|
+
/** Last phase-2 verdict (legacy field name retained for database compatibility). */
|
|
267
|
+
confirmerVerdict?: ConfirmerVerdictRecord;
|
|
187
268
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
188
269
|
reportedAt?: string;
|
|
189
270
|
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
@@ -210,6 +291,75 @@ export type PocVerificationRecord = {
|
|
|
210
291
|
target?: string;
|
|
211
292
|
};
|
|
212
293
|
|
|
294
|
+
/** One harness-observed PoC run with its validated, nonce-bound evidence. */
|
|
295
|
+
export type PocEvidenceRun = {
|
|
296
|
+
mode: "poc" | "control";
|
|
297
|
+
target: string;
|
|
298
|
+
/** The run's PI_POC_NONCE — evidence.nonce must equal it (binds evidence to the run). */
|
|
299
|
+
nonce: string;
|
|
300
|
+
ranAt: string;
|
|
301
|
+
exitCode: number;
|
|
302
|
+
sandbox: boolean;
|
|
303
|
+
completed: boolean;
|
|
304
|
+
outputComplete: boolean;
|
|
305
|
+
/** Display-sliced output (diagnostic; zero exit is necessary, not proof). */
|
|
306
|
+
output: string;
|
|
307
|
+
evidence: PoCEvidence;
|
|
308
|
+
evidenceSha256: string;
|
|
309
|
+
/** Absolute path to the PRESERVED copy of this run's evidence.json (the
|
|
310
|
+
* runner copies the temp file into a durable .pi/poc-evidence/ dir; the
|
|
311
|
+
* reproduction item references it so the stored hash stays verifiable). */
|
|
312
|
+
evidencePath?: string;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Harness-observed out-of-band interactions (Tier 1, docs/poc-trust-model.md). */
|
|
316
|
+
export type OobVerification = {
|
|
317
|
+
attempted: boolean;
|
|
318
|
+
targetHits: number;
|
|
319
|
+
controlHits: number;
|
|
320
|
+
/** True only when the PoC runner cannot directly reach the listener. */
|
|
321
|
+
sourceSeparated?: boolean;
|
|
322
|
+
note: string;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export type PendingConfirmation = {
|
|
326
|
+
caseId: string;
|
|
327
|
+
ranAt: string;
|
|
328
|
+
pocPath: string;
|
|
329
|
+
/** SHA-256 of the PoC script AT RUN TIME — re-hashed at confirm to catch edits. */
|
|
330
|
+
pocSha256: string;
|
|
331
|
+
controlPath: string;
|
|
332
|
+
controlTarget: string;
|
|
333
|
+
targetRuns: [PocEvidenceRun, PocEvidenceRun];
|
|
334
|
+
controlRun: PocEvidenceRun;
|
|
335
|
+
/** Harness's own replay of evidence.verify (public targets). Absent = legacy bundle. */
|
|
336
|
+
harnessVerified?: HarnessVerifyResult;
|
|
337
|
+
/** Harness-owned OOB listener log for the run (opt-in blind classes). */
|
|
338
|
+
callbackVerified?: OobVerification;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/** Fresh machine transcript produced inside the main agent's ConfirmFinding call. */
|
|
342
|
+
export type MainAgentVerification = {
|
|
343
|
+
at: string;
|
|
344
|
+
result: HarnessVerifyResult;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Persisted main-agent verdict; `confirmer` naming is retained for DB compatibility. */
|
|
348
|
+
export type MainAgentVerdictRecord = MainAgentVerdict & {
|
|
349
|
+
at: string;
|
|
350
|
+
reviewer: "main_agent";
|
|
351
|
+
/** Harness-owned phase-2 replay bound to this verdict. */
|
|
352
|
+
phase2Verification?: MainAgentVerification;
|
|
353
|
+
/** What the machine actually established; semantic vulnerability judgment remains main-agent-owned. */
|
|
354
|
+
proofStrength?: "predicate_differential" | "canary_differential";
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** @deprecated Compatibility alias for the legacy database/API field name. */
|
|
358
|
+
export type ConfirmerVerdictRecord = MainAgentVerdictRecord;
|
|
359
|
+
|
|
360
|
+
/** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
|
|
361
|
+
export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
|
|
362
|
+
|
|
213
363
|
export type CaseInput = {
|
|
214
364
|
title: string;
|
|
215
365
|
status?: CaseStatus;
|
|
@@ -239,6 +389,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
|
|
|
239
389
|
pocVerified?: CaseRecord["pocVerified"];
|
|
240
390
|
disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
|
|
241
391
|
controlVerified?: CaseRecord["controlVerified"];
|
|
392
|
+
pendingConfirmation?: CaseRecord["pendingConfirmation"];
|
|
393
|
+
confirmerVerdict?: CaseRecord["confirmerVerdict"];
|
|
242
394
|
reportedAt?: string;
|
|
243
395
|
reportPath?: string;
|
|
244
396
|
};
|
|
@@ -319,6 +471,101 @@ function detectWorkspaceRoot(): string {
|
|
|
319
471
|
);
|
|
320
472
|
}
|
|
321
473
|
|
|
474
|
+
export type PocEvidenceGcResult = {
|
|
475
|
+
scanned: number;
|
|
476
|
+
removed: number;
|
|
477
|
+
skipped: boolean;
|
|
478
|
+
reason?: string;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Delete only old harness evidence copies that no ledger row or pending
|
|
483
|
+
* confirmation bundle references. Malformed pending JSON fails closed because
|
|
484
|
+
* it may contain paths we cannot safely identify.
|
|
485
|
+
*/
|
|
486
|
+
function gcOrphanedPocEvidenceForDb(db: DatabaseSync, nowMs = Date.now()): PocEvidenceGcResult {
|
|
487
|
+
const evidenceDir = join(detectWorkspaceRoot(), ".pi", "poc-evidence");
|
|
488
|
+
if (!existsSync(evidenceDir)) return { scanned: 0, removed: 0, skipped: false };
|
|
489
|
+
try {
|
|
490
|
+
const dirStat = lstatSync(evidenceDir);
|
|
491
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
492
|
+
return {
|
|
493
|
+
scanned: 0,
|
|
494
|
+
removed: 0,
|
|
495
|
+
skipped: true,
|
|
496
|
+
reason: "poc-evidence is not a regular directory",
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const protectedNames = new Set<string>();
|
|
501
|
+
const items = db
|
|
502
|
+
.prepare("SELECT artifact_path FROM evidence_items WHERE artifact_path IS NOT NULL")
|
|
503
|
+
.all() as { artifact_path: string }[];
|
|
504
|
+
for (const item of items) protectedNames.add(basename(item.artifact_path));
|
|
505
|
+
|
|
506
|
+
const collectPendingPaths = (value: unknown): void => {
|
|
507
|
+
if (Array.isArray(value)) {
|
|
508
|
+
for (const item of value) collectPendingPaths(item);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (typeof value !== "object" || value === null) return;
|
|
512
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
513
|
+
if (key === "evidencePath" && typeof nested === "string") {
|
|
514
|
+
protectedNames.add(basename(nested));
|
|
515
|
+
} else {
|
|
516
|
+
collectPendingPaths(nested);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const pendingRows = db
|
|
522
|
+
.prepare(
|
|
523
|
+
"SELECT pending_confirmation_json FROM cases WHERE pending_confirmation_json IS NOT NULL",
|
|
524
|
+
)
|
|
525
|
+
.all() as { pending_confirmation_json: string }[];
|
|
526
|
+
for (const row of pendingRows) {
|
|
527
|
+
let pending: unknown;
|
|
528
|
+
try {
|
|
529
|
+
pending = JSON.parse(row.pending_confirmation_json);
|
|
530
|
+
} catch {
|
|
531
|
+
return {
|
|
532
|
+
scanned: 0,
|
|
533
|
+
removed: 0,
|
|
534
|
+
skipped: true,
|
|
535
|
+
reason: "malformed pending confirmation JSON",
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
collectPendingPaths(pending);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let scanned = 0;
|
|
542
|
+
let removed = 0;
|
|
543
|
+
for (const entry of readdirSync(evidenceDir, { withFileTypes: true })) {
|
|
544
|
+
if (!entry.name.endsWith(".evidence.json") || !entry.isFile()) continue;
|
|
545
|
+
scanned++;
|
|
546
|
+
if (protectedNames.has(entry.name)) continue;
|
|
547
|
+
const candidate = join(evidenceDir, entry.name);
|
|
548
|
+
const file = lstatSync(candidate);
|
|
549
|
+
if (!file.isFile() || nowMs - file.mtimeMs < POC_EVIDENCE_GC_GRACE_MS) continue;
|
|
550
|
+
unlinkSync(candidate);
|
|
551
|
+
removed++;
|
|
552
|
+
}
|
|
553
|
+
return { scanned, removed, skipped: false };
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return {
|
|
556
|
+
scanned: 0,
|
|
557
|
+
removed: 0,
|
|
558
|
+
skipped: true,
|
|
559
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Run the same conservative orphan sweep used when the ledger opens. */
|
|
565
|
+
export function gcOrphanedPocEvidence(nowMs = Date.now()): PocEvidenceGcResult {
|
|
566
|
+
return gcOrphanedPocEvidenceForDb(getDb(), nowMs);
|
|
567
|
+
}
|
|
568
|
+
|
|
322
569
|
export function getCasefilePath(): string {
|
|
323
570
|
if (ledgerPathOverride) return ledgerPathOverride;
|
|
324
571
|
// Trim BEFORE the truthiness check: a whitespace-only value must not
|
|
@@ -347,11 +594,18 @@ function getDb(): DatabaseSync {
|
|
|
347
594
|
|
|
348
595
|
const dbPath = getCasefilePath();
|
|
349
596
|
const dbDir = dirname(dbPath);
|
|
350
|
-
|
|
597
|
+
const workspace = detectWorkspaceRoot();
|
|
598
|
+
const defaultStateDir = join(workspace, ".pi");
|
|
599
|
+
if (resolve(dbDir) === resolve(defaultStateDir)) {
|
|
600
|
+
ensureSafeStateDirectory(workspace, [".pi"]);
|
|
601
|
+
} else if (!existsSync(dbDir)) {
|
|
351
602
|
try {
|
|
352
603
|
mkdirSync(dbDir, { recursive: true });
|
|
353
604
|
} catch {}
|
|
354
605
|
}
|
|
606
|
+
for (const candidate of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
607
|
+
assertSafeRegularFile(candidate, "Casefile database state");
|
|
608
|
+
}
|
|
355
609
|
|
|
356
610
|
const db = new DatabaseSync(dbPath);
|
|
357
611
|
// Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
|
|
@@ -371,6 +625,7 @@ function getDb(): DatabaseSync {
|
|
|
371
625
|
id TEXT PRIMARY KEY,
|
|
372
626
|
title TEXT NOT NULL,
|
|
373
627
|
status TEXT NOT NULL,
|
|
628
|
+
ever_advanced INTEGER NOT NULL DEFAULT 0,
|
|
374
629
|
confidence TEXT NOT NULL,
|
|
375
630
|
severity TEXT,
|
|
376
631
|
priority TEXT,
|
|
@@ -390,6 +645,8 @@ function getDb(): DatabaseSync {
|
|
|
390
645
|
poc_verified_json TEXT, -- JSON object
|
|
391
646
|
disconfirmation TEXT,
|
|
392
647
|
disconfirmation_verified_json TEXT, -- JSON object
|
|
648
|
+
pending_confirmation_json TEXT, -- JSON object
|
|
649
|
+
confirmer_verdict_json TEXT, -- JSON object
|
|
393
650
|
reported_at TEXT,
|
|
394
651
|
report_path TEXT,
|
|
395
652
|
created_at TEXT NOT NULL,
|
|
@@ -428,6 +685,21 @@ function getDb(): DatabaseSync {
|
|
|
428
685
|
if (!caseCols.some((c) => c.name === "control_verified_json")) {
|
|
429
686
|
db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
|
|
430
687
|
}
|
|
688
|
+
if (!caseCols.some((c) => c.name === "pending_confirmation_json")) {
|
|
689
|
+
db.exec("ALTER TABLE cases ADD COLUMN pending_confirmation_json TEXT");
|
|
690
|
+
}
|
|
691
|
+
if (!caseCols.some((c) => c.name === "confirmer_verdict_json")) {
|
|
692
|
+
db.exec("ALTER TABLE cases ADD COLUMN confirmer_verdict_json TEXT");
|
|
693
|
+
}
|
|
694
|
+
if (!caseCols.some((c) => c.name === "ever_advanced")) {
|
|
695
|
+
db.exec("ALTER TABLE cases ADD COLUMN ever_advanced INTEGER NOT NULL DEFAULT 0");
|
|
696
|
+
// Backfill: a case that is (or was) past hypothesis has reached an
|
|
697
|
+
// advanced state. Terminal rows can no longer be mutated, but marking them
|
|
698
|
+
// keeps the flag consistent for history/context reads.
|
|
699
|
+
db.exec(
|
|
700
|
+
"UPDATE cases SET ever_advanced = 1 WHERE status IN ('investigating','confirmed','blocked','killed','reported')",
|
|
701
|
+
);
|
|
702
|
+
}
|
|
431
703
|
|
|
432
704
|
// Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
|
|
433
705
|
db.exec(`
|
|
@@ -473,6 +745,9 @@ function getDb(): DatabaseSync {
|
|
|
473
745
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
|
|
474
746
|
|
|
475
747
|
dbInstance = db;
|
|
748
|
+
// Best-effort housekeeping: failures and ambiguous state fail closed and do
|
|
749
|
+
// not prevent the ledger from opening.
|
|
750
|
+
gcOrphanedPocEvidenceForDb(db);
|
|
476
751
|
return db;
|
|
477
752
|
}
|
|
478
753
|
|
|
@@ -507,6 +782,7 @@ function mapRow(
|
|
|
507
782
|
id: row.id,
|
|
508
783
|
title: row.title,
|
|
509
784
|
status: row.status as CaseStatus,
|
|
785
|
+
everAdvanced: row.ever_advanced === 1,
|
|
510
786
|
confidence: row.confidence as CaseConfidence,
|
|
511
787
|
severity: row.severity as CaseSeverity | undefined,
|
|
512
788
|
priority: row.priority as CasePriority | undefined,
|
|
@@ -528,6 +804,8 @@ function mapRow(
|
|
|
528
804
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
529
805
|
disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
|
|
530
806
|
controlVerified: safeParseObject(row.control_verified_json),
|
|
807
|
+
pendingConfirmation: safeParseObject(row.pending_confirmation_json),
|
|
808
|
+
confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
|
|
531
809
|
reportedAt: row.reported_at || undefined,
|
|
532
810
|
reportPath: row.report_path || undefined,
|
|
533
811
|
evidenceItems,
|
|
@@ -673,16 +951,20 @@ function validateCase(record: CaseRecord): void {
|
|
|
673
951
|
);
|
|
674
952
|
}
|
|
675
953
|
// Keep this gate in lockstep with promoteFindingResult: a case may only be
|
|
676
|
-
// CONFIRMED when it has evidence, a PoC, demonstrated impact,
|
|
954
|
+
// CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
|
|
955
|
+
// and a named target (what host/repo/scope this affects).
|
|
677
956
|
if (
|
|
678
957
|
record.status === "confirmed" &&
|
|
679
958
|
(!record.evidence ||
|
|
680
959
|
!record.poc ||
|
|
681
960
|
!record.impact ||
|
|
682
961
|
!record.severity ||
|
|
962
|
+
!record.target ||
|
|
683
963
|
!record.disconfirmation)
|
|
684
964
|
) {
|
|
685
|
-
throw new Error(
|
|
965
|
+
throw new Error(
|
|
966
|
+
"Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
|
|
967
|
+
);
|
|
686
968
|
}
|
|
687
969
|
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
688
970
|
throw new Error("Blocked cases require at least one blocker");
|
|
@@ -698,9 +980,6 @@ function validateCase(record: CaseRecord): void {
|
|
|
698
980
|
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
699
981
|
);
|
|
700
982
|
}
|
|
701
|
-
// A case becomes REPORTED only after the report FILE exists on disk (the
|
|
702
|
-
// report writer writes it at the path CaseContext recorded). Require both
|
|
703
|
-
// here so validation stays consistent with the confirmed→reported gate.
|
|
704
983
|
// A case becomes REPORTED only after a report FILE that passes the content
|
|
705
984
|
// gate exists on disk (the report writer writes it at the path CaseContext
|
|
706
985
|
// recorded). Existence is not enough: any non-empty file — or a directory —
|
|
@@ -774,21 +1053,33 @@ const REPORT_REQUIRED_SECTIONS = ["summary", "impact", "remediation"];
|
|
|
774
1053
|
* text (workflow.ts imports this) must not drift apart.
|
|
775
1054
|
*/
|
|
776
1055
|
export const KILL_REASON_VALUES = [
|
|
1056
|
+
"unreachable",
|
|
777
1057
|
"intended_behavior",
|
|
778
1058
|
"duplicate",
|
|
779
1059
|
"framework_protection",
|
|
1060
|
+
"input_validation_blocks",
|
|
1061
|
+
"requires_privilege_attacker_lacks",
|
|
780
1062
|
"exploit_unreliable",
|
|
781
1063
|
"insufficient_impact",
|
|
782
1064
|
"environmental_issue",
|
|
783
1065
|
"not_applicable",
|
|
784
1066
|
"out_of_scope",
|
|
785
|
-
"
|
|
1067
|
+
"test_artifact",
|
|
786
1068
|
"no_attack_path",
|
|
787
1069
|
"refuted",
|
|
788
1070
|
] as const;
|
|
789
1071
|
export type KillReason = (typeof KILL_REASON_VALUES)[number];
|
|
790
1072
|
|
|
791
|
-
|
|
1073
|
+
/**
|
|
1074
|
+
* Matches a kill reason whether the agent wrote the canonical token
|
|
1075
|
+
* ("out_of_scope"), a spaced form ("out of scope"), or hyphenated
|
|
1076
|
+
* ("out-of-scope") — the underscore spelling is machine vocabulary; free text
|
|
1077
|
+
* must not be rejected just because it reads naturally.
|
|
1078
|
+
*/
|
|
1079
|
+
const KILL_REASON_PATTERN = new RegExp(
|
|
1080
|
+
`\\b(${KILL_REASON_VALUES.map((v) => v.replace(/_/g, "[ _-]+")).join("|")})\\b`,
|
|
1081
|
+
"i",
|
|
1082
|
+
);
|
|
792
1083
|
|
|
793
1084
|
function validateTransition(
|
|
794
1085
|
from: CaseStatus,
|
|
@@ -813,28 +1104,34 @@ function validateTransition(
|
|
|
813
1104
|
|
|
814
1105
|
if (to === "killed") {
|
|
815
1106
|
// Black-cat rule: a kill must be justified. Valid iff (a) a refutation
|
|
816
|
-
// evidence item exists for this case, or (b) — only for
|
|
817
|
-
//
|
|
818
|
-
// vocabulary (matches workflow.ts). Once a case
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
1107
|
+
// evidence item exists for this case, or (b) — only for cases that have
|
|
1108
|
+
// NEVER reached investigating/confirmed — the update states a kill reason
|
|
1109
|
+
// from the KILLED catalog vocabulary (matches workflow.ts). Once a case
|
|
1110
|
+
// reached investigating or confirmed (everAdvanced — immune to demotion
|
|
1111
|
+
// round-trips), a keyword in free text is NOT enough: the kill must be
|
|
1112
|
+
// backed by a real refutation evidence item (EvidenceAdd role=refutation —
|
|
1113
|
+
// the disprove attempt that ended the lead).
|
|
822
1114
|
const items = current ? listEvidenceItems(current.id) : [];
|
|
823
1115
|
if (!items.some((e) => e.role === "refutation" && e.sha256)) {
|
|
824
|
-
const advanced = current?.
|
|
825
|
-
const text = [
|
|
1116
|
+
const advanced = current?.everAdvanced === true;
|
|
1117
|
+
const text = [
|
|
1118
|
+
update.nextStep,
|
|
1119
|
+
(update.assumptions ?? []).join(" "),
|
|
1120
|
+
(update.blockers ?? []).join(" "),
|
|
1121
|
+
update.evidence,
|
|
1122
|
+
]
|
|
826
1123
|
.filter(Boolean)
|
|
827
1124
|
.join(" ");
|
|
828
1125
|
if (advanced || !KILL_REASON_PATTERN.test(text)) {
|
|
829
1126
|
throw new Error(
|
|
830
1127
|
advanced
|
|
831
|
-
? "Cannot kill an investigating/confirmed
|
|
1128
|
+
? "Cannot kill an advanced case (ever reached investigating/confirmed) without ARTIFACT-BACKED refutation evidence: add " +
|
|
832
1129
|
"EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
|
|
833
1130
|
"that ended this lead) before killing."
|
|
834
1131
|
: "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
|
|
835
|
-
"artifact_path recommended) or state a kill reason in assumptions/nextStep " +
|
|
1132
|
+
"artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
|
|
836
1133
|
"(intended_behavior, duplicate, framework_protection, out_of_scope, " +
|
|
837
|
-
"
|
|
1134
|
+
"insufficient_impact, no_attack_path, ...)",
|
|
838
1135
|
);
|
|
839
1136
|
}
|
|
840
1137
|
}
|
|
@@ -862,7 +1159,7 @@ function validateTransition(
|
|
|
862
1159
|
},
|
|
863
1160
|
investigating: {
|
|
864
1161
|
confirmed: () =>
|
|
865
|
-
"investigating → confirmed requires a verified PoC run; use the
|
|
1162
|
+
"investigating → confirmed requires a verified PoC run; use the PromoteFinding tool",
|
|
866
1163
|
hypothesis: () => null,
|
|
867
1164
|
},
|
|
868
1165
|
confirmed: {
|
|
@@ -923,6 +1220,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
923
1220
|
id,
|
|
924
1221
|
title,
|
|
925
1222
|
status: input.status ?? existing?.status ?? "hypothesis",
|
|
1223
|
+
// Once a case has been investigating/confirmed it never forgets — the kill
|
|
1224
|
+
// gate must not be defeatable by demoting first.
|
|
1225
|
+
everAdvanced:
|
|
1226
|
+
existing?.everAdvanced === true ||
|
|
1227
|
+
input.status === "investigating" ||
|
|
1228
|
+
input.status === "confirmed",
|
|
926
1229
|
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
927
1230
|
severity: input.severity ?? existing?.severity,
|
|
928
1231
|
priority: input.priority ?? existing?.priority,
|
|
@@ -948,6 +1251,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
948
1251
|
: existing?.disconfirmation,
|
|
949
1252
|
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
950
1253
|
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
1254
|
+
pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
|
|
1255
|
+
confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
|
|
951
1256
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
952
1257
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
953
1258
|
evidenceItems: existing?.evidenceItems ?? [],
|
|
@@ -1207,8 +1512,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
|
|
|
1207
1512
|
const n = titles.length;
|
|
1208
1513
|
const weights = new Map<string, number>();
|
|
1209
1514
|
for (const [token, docs] of df) {
|
|
1210
|
-
// +1
|
|
1211
|
-
|
|
1515
|
+
// ln((n+1)/(docs+1)) — NO +1 baseline. A token in every title scores ~0
|
|
1516
|
+
// (ln 1), a token in one title scores ln((n+1)/2) > 1 for n >= 3. The old
|
|
1517
|
+
// `1 + ln(...)` made every weight >= 1, so the weighted half of the hybrid
|
|
1518
|
+
// gate was vacuous (weightedSum >= sharedCount always) and generic
|
|
1519
|
+
// vocabulary could never be down-weighted.
|
|
1520
|
+
weights.set(token, Math.log((n + 1) / (docs + 1)));
|
|
1212
1521
|
}
|
|
1213
1522
|
return weights;
|
|
1214
1523
|
}
|
|
@@ -1258,21 +1567,24 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1258
1567
|
// wipe case_links when updating an existing primary key.
|
|
1259
1568
|
const stmt = db.prepare(`
|
|
1260
1569
|
INSERT INTO cases (
|
|
1261
|
-
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
1570
|
+
id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
|
|
1262
1571
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
1263
1572
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
1264
1573
|
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
1574
|
+
pending_confirmation_json, confirmer_verdict_json,
|
|
1265
1575
|
reported_at, report_path, created_at, updated_at
|
|
1266
1576
|
) VALUES (
|
|
1267
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1577
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1268
1578
|
?, ?, ?, ?, ?, ?,
|
|
1269
1579
|
?, ?, ?, ?, ?,
|
|
1270
1580
|
?, ?, ?, ?,
|
|
1581
|
+
?, ?,
|
|
1271
1582
|
?, ?, ?, ?
|
|
1272
1583
|
)
|
|
1273
1584
|
ON CONFLICT(id) DO UPDATE SET
|
|
1274
1585
|
title = excluded.title,
|
|
1275
1586
|
status = excluded.status,
|
|
1587
|
+
ever_advanced = excluded.ever_advanced,
|
|
1276
1588
|
confidence = excluded.confidence,
|
|
1277
1589
|
severity = excluded.severity,
|
|
1278
1590
|
priority = excluded.priority,
|
|
@@ -1294,6 +1606,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1294
1606
|
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
1295
1607
|
disprove_if_json = excluded.disprove_if_json,
|
|
1296
1608
|
control_verified_json = excluded.control_verified_json,
|
|
1609
|
+
pending_confirmation_json = excluded.pending_confirmation_json,
|
|
1610
|
+
confirmer_verdict_json = excluded.confirmer_verdict_json,
|
|
1297
1611
|
reported_at = excluded.reported_at,
|
|
1298
1612
|
report_path = excluded.report_path,
|
|
1299
1613
|
created_at = excluded.created_at,
|
|
@@ -1304,6 +1618,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1304
1618
|
record.id,
|
|
1305
1619
|
record.title,
|
|
1306
1620
|
record.status,
|
|
1621
|
+
record.everAdvanced ? 1 : 0,
|
|
1307
1622
|
record.confidence,
|
|
1308
1623
|
record.severity || null,
|
|
1309
1624
|
record.priority || null,
|
|
@@ -1325,6 +1640,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1325
1640
|
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
1326
1641
|
JSON.stringify(record.disproveIf),
|
|
1327
1642
|
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
1643
|
+
record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
|
|
1644
|
+
record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
|
|
1328
1645
|
record.reportedAt || null,
|
|
1329
1646
|
record.reportPath || null,
|
|
1330
1647
|
record.createdAt,
|
|
@@ -1372,22 +1689,29 @@ export function addEvidenceItemResult(
|
|
|
1372
1689
|
if (!summary) throw new Error("Evidence summary must not be empty");
|
|
1373
1690
|
|
|
1374
1691
|
let artifactPath: string | undefined;
|
|
1692
|
+
|
|
1375
1693
|
let sha256: string | undefined;
|
|
1376
1694
|
if (input.artifactPath) {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1695
|
+
const artifact = readWorkspaceArtifact(input.artifactPath);
|
|
1696
|
+
artifactPath = basename(artifact.path);
|
|
1697
|
+
sha256 = createHash("sha256").update(artifact.bytes).digest("hex");
|
|
1698
|
+
// Durable copy: artifact_path stores the basename only (path-leak guard),
|
|
1699
|
+
// so the bytes must survive somewhere re-verifiable by the sha256. Copy
|
|
1700
|
+
// into <ledger-dir>/evidence-items/<sha256>.bin — the location is
|
|
1701
|
+
// derivable from the hash column, so no new column or path persistence.
|
|
1702
|
+
const ledgerDir = dirname(getCasefilePath());
|
|
1703
|
+
const evidenceDir = ensureSafeStateDirectory(ledgerDir, ["evidence-items"]);
|
|
1704
|
+
const durable = join(evidenceDir, `${sha256}.bin`);
|
|
1705
|
+
if (!assertSafeRegularFile(durable, "Durable evidence artifact")) {
|
|
1706
|
+
writeSafeFileExclusive(durable, artifact.bytes);
|
|
1707
|
+
} else {
|
|
1708
|
+
const durableHash = createHash("sha256")
|
|
1709
|
+
.update(readSafeFile(durable, "Durable evidence artifact"))
|
|
1710
|
+
.digest("hex");
|
|
1711
|
+
if (durableHash !== sha256) {
|
|
1712
|
+
throw new Error(`Durable evidence artifact hash mismatch: ${durable}`);
|
|
1713
|
+
}
|
|
1388
1714
|
}
|
|
1389
|
-
artifactPath = basename(input.artifactPath);
|
|
1390
|
-
sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
|
|
1391
1715
|
}
|
|
1392
1716
|
|
|
1393
1717
|
const item: EvidenceItem = {
|
|
@@ -1621,6 +1945,8 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
1621
1945
|
pocVerified: undefined,
|
|
1622
1946
|
disconfirmationVerified: undefined,
|
|
1623
1947
|
controlVerified: undefined,
|
|
1948
|
+
confirmerVerdict: undefined,
|
|
1949
|
+
pendingConfirmation: undefined,
|
|
1624
1950
|
};
|
|
1625
1951
|
}
|
|
1626
1952
|
|
|
@@ -1682,67 +2008,188 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
1682
2008
|
});
|
|
1683
2009
|
}
|
|
1684
2010
|
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2011
|
+
function validateRunEvidence(run: PocEvidenceRun, label: string): void {
|
|
2012
|
+
if (!run.completed) {
|
|
2013
|
+
throw new Error(`${label} did not complete; a crash is not evidence`);
|
|
2014
|
+
}
|
|
2015
|
+
if (!run.outputComplete) {
|
|
2016
|
+
throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
|
|
2017
|
+
}
|
|
2018
|
+
if (run.exitCode !== 0) {
|
|
2019
|
+
throw new Error(
|
|
2020
|
+
`${label} exited with ${run.exitCode}; exit 0 is required for a complete run but is never sufficient proof`,
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
if (!run.evidence || !run.evidenceSha256) {
|
|
2024
|
+
throw new Error(
|
|
2025
|
+
`${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
2028
|
+
if (!evidenceNonceMatches(run.evidence, run.nonce)) {
|
|
2029
|
+
throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
|
|
2030
|
+
}
|
|
2031
|
+
const parsed = parsePoCEvidence(run.evidence);
|
|
2032
|
+
if (!parsed.ok) {
|
|
2033
|
+
throw new Error(`${label} evidence contract invalid: ${parsed.error}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (!run.evidencePath) {
|
|
2036
|
+
throw new Error(`${label} has no durable evidencePath; ephemeral evidence cannot confirm`);
|
|
2037
|
+
}
|
|
2038
|
+
const artifact = readWorkspaceArtifact(run.evidencePath);
|
|
2039
|
+
if (artifact.bytes.byteLength > POC_EVIDENCE_MAX_BYTES) {
|
|
2040
|
+
throw new Error(
|
|
2041
|
+
`${label} durable evidence exceeds ${POC_EVIDENCE_MAX_BYTES} bytes; evidence cannot be revalidated safely`,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
const durableHash = createHash("sha256").update(artifact.bytes).digest("hex");
|
|
2045
|
+
if (durableHash !== run.evidenceSha256) {
|
|
2046
|
+
throw new Error(`${label} durable evidence hash does not match evidenceSha256`);
|
|
2047
|
+
}
|
|
2048
|
+
let durableRaw: unknown;
|
|
2049
|
+
try {
|
|
2050
|
+
durableRaw = JSON.parse(artifact.bytes.toString("utf8"));
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
throw new Error(`${label} durable evidence is not valid JSON: ${(error as Error).message}`);
|
|
2053
|
+
}
|
|
2054
|
+
const durable = parsePoCEvidence(durableRaw);
|
|
2055
|
+
if (!durable.ok) {
|
|
2056
|
+
throw new Error(`${label} durable evidence contract invalid: ${durable.error}`);
|
|
2057
|
+
}
|
|
2058
|
+
if (
|
|
2059
|
+
normalizeEvidence(durable.evidence) !== normalizeEvidence(run.evidence) ||
|
|
2060
|
+
JSON.stringify(durable.evidence.observations) !== JSON.stringify(run.evidence.observations)
|
|
2061
|
+
) {
|
|
2062
|
+
throw new Error(`${label} durable evidence bytes do not match the stored evidence object`);
|
|
2063
|
+
}
|
|
1705
2064
|
}
|
|
1706
2065
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
if (
|
|
1716
|
-
if (!v.ranAt || Number.isNaN(Date.parse(v.ranAt))) {
|
|
1717
|
-
throw new Error(`${label} verification ranAt must be an ISO timestamp`);
|
|
1718
|
-
}
|
|
1719
|
-
if (typeof v.sandbox !== "boolean") throw new Error(`${label} verification sandbox flag missing`);
|
|
1720
|
-
if (v.completed !== true) throw new Error(`${label} verification did not complete`);
|
|
1721
|
-
if (v.outputComplete !== true) {
|
|
2066
|
+
/** Determinism + differential on normalized evidence (nonce/observations stripped). */
|
|
2067
|
+
function assertEvidenceDifferential(bundle: PendingConfirmation): void {
|
|
2068
|
+
const [r1, r2] = bundle.targetRuns;
|
|
2069
|
+
if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
|
|
2070
|
+
throw new Error(
|
|
2071
|
+
"Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
|
|
1722
2075
|
throw new Error(
|
|
1723
|
-
|
|
2076
|
+
"Control run produced identical evidence to the target — the claimed impact is not target-dependent",
|
|
1724
2077
|
);
|
|
1725
2078
|
}
|
|
1726
|
-
|
|
1727
|
-
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function assertMachineConfirmation(bundle: PendingConfirmation): void {
|
|
2082
|
+
const oob = bundle.callbackVerified;
|
|
2083
|
+
if (oob?.attempted) {
|
|
2084
|
+
if (oob.targetHits === 0) {
|
|
2085
|
+
throw new Error(
|
|
2086
|
+
`OOB VERIFY FAILED: no interaction with the target-run callback token. ${oob.note}`,
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
if (oob.controlHits > 0) {
|
|
2090
|
+
throw new Error(
|
|
2091
|
+
`OOB VERIFY FAILED: the control-run callback token received ${oob.controlHits} interaction(s) — the callback is not target-dependent. ${oob.note}`,
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
if (oob.sourceSeparated !== true) {
|
|
2095
|
+
throw new Error(
|
|
2096
|
+
"OOB VERIFY FAILED: callback source separation was not established. " +
|
|
2097
|
+
"A loopback listener reachable by the PoC is diagnostic telemetry, not proof that the target caused the interaction.",
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
return;
|
|
1728
2101
|
}
|
|
1729
|
-
|
|
2102
|
+
|
|
2103
|
+
assertHarnessTargetOnly(
|
|
2104
|
+
bundle.harnessVerified,
|
|
2105
|
+
"HARNESS DIFFERENTIAL FAILED",
|
|
2106
|
+
"no machine-owned target/control replay was recorded",
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function assertHarnessTargetOnly(
|
|
2111
|
+
harness: HarnessVerifyResult | undefined,
|
|
2112
|
+
label: string,
|
|
2113
|
+
missingNote: string,
|
|
2114
|
+
): asserts harness is HarnessVerifyResult {
|
|
2115
|
+
if (
|
|
2116
|
+
!harness?.attempted ||
|
|
2117
|
+
harness.pass !== true ||
|
|
2118
|
+
harness.differential !== "target_only" ||
|
|
2119
|
+
harness.target?.matched !== true ||
|
|
2120
|
+
harness.control?.matched !== false
|
|
2121
|
+
) {
|
|
2122
|
+
throw new Error(`${label}: ${harness?.note ?? missingNote}`);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function assertHarnessCanary(
|
|
2127
|
+
harness: HarnessVerifyResult | undefined,
|
|
2128
|
+
required: boolean,
|
|
2129
|
+
label: string,
|
|
2130
|
+
): void {
|
|
2131
|
+
if (!required) return;
|
|
2132
|
+
if (
|
|
2133
|
+
harness?.canary?.attempted !== true ||
|
|
2134
|
+
harness.canary.pass !== true ||
|
|
2135
|
+
harness.canary.targetObserved !== true ||
|
|
2136
|
+
harness.canary.controlObserved !== false ||
|
|
2137
|
+
harness.proofStrength !== "canary_differential"
|
|
2138
|
+
) {
|
|
2139
|
+
throw new Error(`${label}: ${harness?.canary?.note ?? "required canary transcript missing"}`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function assertMainAgentVerification(
|
|
2144
|
+
bundle: PendingConfirmation,
|
|
2145
|
+
verification: MainAgentVerification | undefined,
|
|
2146
|
+
): asserts verification is MainAgentVerification {
|
|
2147
|
+
if (!verification) {
|
|
1730
2148
|
throw new Error(
|
|
1731
|
-
|
|
2149
|
+
"MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
|
|
1732
2150
|
);
|
|
1733
2151
|
}
|
|
1734
|
-
|
|
2152
|
+
const at = Date.parse(verification.at);
|
|
2153
|
+
const bundleAt = Date.parse(bundle.ranAt);
|
|
2154
|
+
const now = Date.now();
|
|
2155
|
+
if (
|
|
2156
|
+
!Number.isFinite(at) ||
|
|
2157
|
+
!Number.isFinite(bundleAt) ||
|
|
2158
|
+
at < bundleAt ||
|
|
2159
|
+
at > now + 30_000 ||
|
|
2160
|
+
now - at > 5 * 60 * 1000
|
|
2161
|
+
) {
|
|
1735
2162
|
throw new Error(
|
|
1736
|
-
|
|
2163
|
+
"MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
|
|
1737
2164
|
);
|
|
1738
2165
|
}
|
|
2166
|
+
assertHarnessTargetOnly(
|
|
2167
|
+
verification.result,
|
|
2168
|
+
"MAIN-AGENT REPLAY FAILED",
|
|
2169
|
+
"no fresh phase-2 target/control replay was recorded",
|
|
2170
|
+
);
|
|
2171
|
+
assertHarnessCanary(
|
|
2172
|
+
verification.result,
|
|
2173
|
+
bundle.targetRuns[0].evidence.verify.canary !== undefined,
|
|
2174
|
+
"MAIN-AGENT CANARY FAILED",
|
|
2175
|
+
);
|
|
2176
|
+
const targetUrl = verification.result.target?.url;
|
|
2177
|
+
const controlUrl = verification.result.control?.url;
|
|
2178
|
+
const targetIdentity = bundle.targetRuns[0].target;
|
|
2179
|
+
if (!targetUrl || verifyUrlBindingError(targetUrl, targetIdentity)) {
|
|
2180
|
+
throw new Error("MAIN-AGENT REPLAY FAILED: target transcript is not bound to the case target");
|
|
2181
|
+
}
|
|
2182
|
+
if (!controlUrl || verifyUrlBindingError(controlUrl, bundle.controlTarget)) {
|
|
2183
|
+
throw new Error("MAIN-AGENT REPLAY FAILED: control transcript is not bound to control_target");
|
|
2184
|
+
}
|
|
1739
2185
|
}
|
|
1740
2186
|
|
|
1741
2187
|
/**
|
|
1742
|
-
* Gate for
|
|
1743
|
-
* poc/evidence/impact/severity.
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
2188
|
+
* Gate for phase 1 of promotion: case must exist, be investigating, and have
|
|
2189
|
+
* poc/evidence/impact/severity/target. The disconfirmation is provided by the
|
|
2190
|
+
* main agent at confirm time, so it is NOT a precondition here. Returns the
|
|
2191
|
+
* record when promotable, throws otherwise. Exported so PromoteFinding can
|
|
2192
|
+
* validate BEFORE paying for (potentially slow) sandboxed PoC runs.
|
|
1746
2193
|
*/
|
|
1747
2194
|
export function assertPromotable(id: string): CaseRecord {
|
|
1748
2195
|
const current = getCaseById(id);
|
|
@@ -1750,7 +2197,7 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1750
2197
|
throw new Error(`Case not found: ${id}`);
|
|
1751
2198
|
}
|
|
1752
2199
|
if (current.status !== "investigating") {
|
|
1753
|
-
throw new Error(`
|
|
2200
|
+
throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
|
|
1754
2201
|
}
|
|
1755
2202
|
if (!current.poc) {
|
|
1756
2203
|
throw new Error("CONFIRMED requires poc; set poc on the case first");
|
|
@@ -1769,15 +2216,10 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1769
2216
|
"CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
|
|
1770
2217
|
);
|
|
1771
2218
|
}
|
|
1772
|
-
if (!current.disconfirmation) {
|
|
1773
|
-
throw new Error(
|
|
1774
|
-
"CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
|
|
1775
|
-
);
|
|
1776
|
-
}
|
|
1777
2219
|
// Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
|
|
1778
2220
|
// summary-only observation is agent prose about itself — promotion requires
|
|
1779
2221
|
// a real file with its SHA-256 as the initial signal. (The reproduction item
|
|
1780
|
-
// is always artifact-backed: the
|
|
2222
|
+
// is always artifact-backed: the gate writes it from the evidence hash.)
|
|
1781
2223
|
if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
|
|
1782
2224
|
throw new Error(
|
|
1783
2225
|
"Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
|
|
@@ -1788,171 +2230,302 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1788
2230
|
return current;
|
|
1789
2231
|
}
|
|
1790
2232
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
):
|
|
2233
|
+
/**
|
|
2234
|
+
* Phase 1: record the harness-observed evidence bundle on the case. The whole
|
|
2235
|
+
* contract is validated here — same-file control, nonce binding, run
|
|
2236
|
+
* completion, determinism across the two target runs, and the target/control
|
|
2237
|
+
* differential — so a bundle that cannot promote is rejected before the
|
|
2238
|
+
* main agent performs phase-2 review.
|
|
2239
|
+
*/
|
|
2240
|
+
export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
|
|
1799
2241
|
const db = getDb();
|
|
1800
2242
|
return withImmediateTransaction(db, () => {
|
|
1801
|
-
const current =
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
// Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
|
|
1805
|
-
// promotion — sandboxed and live alike. The control run must be the same
|
|
1806
|
-
// script against a DISTINCT baseline target, with complete captured output.
|
|
1807
|
-
const liveness = controlLivenessMarker?.trim();
|
|
1808
|
-
if (!liveness) {
|
|
2243
|
+
const current = getCaseById(id);
|
|
2244
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
2245
|
+
if (current.status !== "investigating") {
|
|
1809
2246
|
throw new Error(
|
|
1810
|
-
|
|
1811
|
-
"after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
|
|
2247
|
+
`Pending confirmation requires an investigating case (current: ${current.status})`,
|
|
1812
2248
|
);
|
|
1813
2249
|
}
|
|
1814
|
-
|
|
1815
|
-
if (!
|
|
2250
|
+
if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
|
|
2251
|
+
if (bundle.targetRuns.length !== 2 || !bundle.controlRun) {
|
|
2252
|
+
throw new Error("Pending confirmation requires two target runs and one control run");
|
|
2253
|
+
}
|
|
2254
|
+
if (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget) {
|
|
2255
|
+
throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
|
|
2256
|
+
}
|
|
2257
|
+
// Control-target binding (machine-verified here, not just in the tool
|
|
2258
|
+
// layer): the control run must actually have targeted the declared
|
|
2259
|
+
// control_target, that target must differ from the target runs' target,
|
|
2260
|
+
// and the control target must differ from the case's target — otherwise
|
|
2261
|
+
// "the control demonstrated nothing on the vulnerable target" passes.
|
|
2262
|
+
const targetRunTarget = bundle.targetRuns[0]?.target;
|
|
2263
|
+
if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
|
|
1816
2264
|
throw new Error(
|
|
1817
|
-
"
|
|
1818
|
-
"promoteFindingResult refuses to promote on exit 0 alone.",
|
|
2265
|
+
"Pending confirmation requires both target runs against the same case target",
|
|
1819
2266
|
);
|
|
1820
2267
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2268
|
+
if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
|
|
2269
|
+
throw new Error(
|
|
2270
|
+
"CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
|
|
2271
|
+
"against a different host than the one declared proves nothing.",
|
|
2272
|
+
);
|
|
1825
2273
|
}
|
|
1826
|
-
if (
|
|
2274
|
+
if (bundle.controlRun.target === targetRunTarget) {
|
|
1827
2275
|
throw new Error(
|
|
1828
|
-
"
|
|
2276
|
+
"CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
|
|
2277
|
+
"the claimed impact is not target-dependent.",
|
|
1829
2278
|
);
|
|
1830
2279
|
}
|
|
1831
|
-
|
|
1832
|
-
assertVerificationRecord(
|
|
1833
|
-
"Disconfirmation",
|
|
1834
|
-
disconfirmationVerification,
|
|
1835
|
-
"disconfirmation",
|
|
1836
|
-
caseTarget,
|
|
1837
|
-
);
|
|
1838
|
-
|
|
1839
|
-
if (verification.exitCode !== 0) {
|
|
2280
|
+
if (bundle.controlTarget === current.target) {
|
|
1840
2281
|
throw new Error(
|
|
1841
|
-
|
|
2282
|
+
"CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
|
|
2283
|
+
"against the vulnerable target proves nothing.",
|
|
1842
2284
|
);
|
|
1843
2285
|
}
|
|
1844
|
-
|
|
1845
|
-
// Same-file contract: the control must be the SAME script as the PoC
|
|
1846
|
-
// (differing only via PI_POC_MODE / PI_POC_TARGET). The tool enforces this
|
|
1847
|
-
// before running; the ledger re-checks so a direct caller cannot bypass it.
|
|
2286
|
+
// Same-file contract re-checked at store time (the tool already checked).
|
|
1848
2287
|
let pocHash: string | undefined;
|
|
1849
2288
|
let controlHash: string | undefined;
|
|
1850
2289
|
try {
|
|
1851
|
-
pocHash = createHash("sha256").update(readFileSync(
|
|
1852
|
-
controlHash = createHash("sha256")
|
|
1853
|
-
.update(readFileSync(controlVerification.path))
|
|
1854
|
-
.digest("hex");
|
|
2290
|
+
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
2291
|
+
controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
|
|
1855
2292
|
} catch {
|
|
1856
2293
|
pocHash = undefined;
|
|
1857
2294
|
controlHash = undefined;
|
|
1858
2295
|
}
|
|
1859
2296
|
if (!pocHash || !controlHash || pocHash !== controlHash) {
|
|
1860
2297
|
throw new Error(
|
|
1861
|
-
"
|
|
1862
|
-
"(sha256
|
|
1863
|
-
"A separately written control file proves nothing.",
|
|
2298
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
|
|
2299
|
+
"(sha256 mismatch). A separately written control file proves nothing.",
|
|
1864
2300
|
);
|
|
1865
2301
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2302
|
+
if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
|
|
2303
|
+
throw new Error("pocSha256 does not match the PoC file on disk");
|
|
2304
|
+
}
|
|
2305
|
+
for (const run of [...bundle.targetRuns, bundle.controlRun]) {
|
|
2306
|
+
validateRunEvidence(run, `${run.mode} run`);
|
|
2307
|
+
}
|
|
2308
|
+
assertEvidenceDifferential(bundle);
|
|
2309
|
+
if (!bundle.callbackVerified?.attempted) {
|
|
2310
|
+
for (const run of bundle.targetRuns) {
|
|
2311
|
+
const bindingError = verifyUrlBindingError(run.evidence.verify.url, targetRunTarget);
|
|
2312
|
+
if (bindingError) throw new Error(`TARGET BINDING FAILED: ${bindingError}`);
|
|
2313
|
+
}
|
|
2314
|
+
const controlBindingError = verifyUrlBindingError(
|
|
2315
|
+
bundle.controlRun.evidence.verify.url,
|
|
2316
|
+
bundle.controlTarget,
|
|
1875
2317
|
);
|
|
2318
|
+
if (controlBindingError) {
|
|
2319
|
+
throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
|
|
2320
|
+
}
|
|
1876
2321
|
}
|
|
2322
|
+
// A clean exit and model-authored evidence are necessary inputs, never the
|
|
2323
|
+
// proof. Promotion requires a harness-observed target/control differential
|
|
2324
|
+
// or a harness-owned OOB interaction differential.
|
|
2325
|
+
assertMachineConfirmation(bundle);
|
|
2326
|
+
|
|
2327
|
+
const next = buildRecord({ pendingConfirmation: bundle }, current);
|
|
2328
|
+
validateCase(next);
|
|
2329
|
+
upsertCase(db, next);
|
|
2330
|
+
return next;
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
1877
2333
|
|
|
1878
|
-
|
|
1879
|
-
|
|
2334
|
+
/**
|
|
2335
|
+
* Phase 2: commit (or refuse) the promotion on the main agent's verdict.
|
|
2336
|
+
*
|
|
2337
|
+
* CONFIRMED requires the full bundle to still hold (completion, nonce,
|
|
2338
|
+
* determinism, differential), the PoC script to be unchanged since the runs
|
|
2339
|
+
* (pocSha256 — otherwise the main agent reviewed different bytes), and a
|
|
2340
|
+
* verdict accompanied by a fresh harness-owned target-only replay, a concrete
|
|
2341
|
+
* review note, and a disconfirmation attempt. NOT_CONFIRMED records the
|
|
2342
|
+
* verdict and keeps the case investigating — no tie-breaker.
|
|
2343
|
+
*/
|
|
2344
|
+
export function applyConfirmationResult(
|
|
2345
|
+
id: string,
|
|
2346
|
+
verdictInput: MainAgentVerdict,
|
|
2347
|
+
phase2Verification?: MainAgentVerification,
|
|
2348
|
+
authority: { startedAsSubagent: boolean } = {
|
|
2349
|
+
startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
|
|
2350
|
+
},
|
|
2351
|
+
): CaseUpdateResult {
|
|
2352
|
+
if (authority.startedAsSubagent) {
|
|
2353
|
+
throw new Error(
|
|
2354
|
+
"ConfirmFinding is reserved for the main/coordinator agent; worker processes cannot commit confirmation",
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
const db = getDb();
|
|
2358
|
+
return withImmediateTransaction(db, () => {
|
|
2359
|
+
const current = getCaseById(id);
|
|
2360
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
2361
|
+
if (current.status !== "investigating") {
|
|
2362
|
+
throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
|
|
2363
|
+
}
|
|
2364
|
+
const bundle = current.pendingConfirmation;
|
|
2365
|
+
if (!bundle) {
|
|
2366
|
+
throw new Error("No pending confirmation on this case — run PromoteFinding first");
|
|
2367
|
+
}
|
|
2368
|
+
// Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
|
|
2369
|
+
// NaN > TTL is false — a malformed timestamp must NOT make the bundle
|
|
2370
|
+
// immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
|
|
2371
|
+
const ranAtMs = Date.parse(bundle.ranAt);
|
|
2372
|
+
if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
|
|
1880
2373
|
throw new Error(
|
|
1881
|
-
|
|
1882
|
-
"exit 0 alone cannot promote to confirmed",
|
|
2374
|
+
"Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
|
|
1883
2375
|
);
|
|
1884
2376
|
}
|
|
2377
|
+
const parsed = validateMainAgentVerdict(verdictInput);
|
|
2378
|
+
if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
|
|
2379
|
+
const verdict = parsed.verdict;
|
|
2380
|
+
const canaryRequested = bundle.targetRuns[0].evidence.verify.canary !== undefined;
|
|
2381
|
+
if (verdict.verdict === "CONFIRMED") {
|
|
2382
|
+
if (canaryRequested && verdict.canary_assessment !== "verified") {
|
|
2383
|
+
throw new Error(
|
|
2384
|
+
"CONFIRMED canary mismatch: evidence requested a harness canary, so canary_assessment must be verified",
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
if (!canaryRequested && verdict.canary_assessment !== "not_applicable") {
|
|
2388
|
+
throw new Error(
|
|
2389
|
+
"CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
const recorded: MainAgentVerdictRecord = {
|
|
2394
|
+
...verdict,
|
|
2395
|
+
at: new Date().toISOString(),
|
|
2396
|
+
reviewer: "main_agent",
|
|
2397
|
+
phase2Verification: verdict.verdict === "CONFIRMED" ? phase2Verification : undefined,
|
|
2398
|
+
proofStrength:
|
|
2399
|
+
verdict.verdict === "CONFIRMED"
|
|
2400
|
+
? canaryRequested
|
|
2401
|
+
? "canary_differential"
|
|
2402
|
+
: "predicate_differential"
|
|
2403
|
+
: undefined,
|
|
2404
|
+
};
|
|
1885
2405
|
|
|
1886
|
-
if (
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2406
|
+
if (verdict.verdict === "NOT_CONFIRMED") {
|
|
2407
|
+
const note = `main agent NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
|
|
2408
|
+
const next = buildRecord(
|
|
2409
|
+
{
|
|
2410
|
+
confirmerVerdict: recorded,
|
|
2411
|
+
pendingConfirmation: undefined,
|
|
2412
|
+
assumptions: [...(current.assumptions ?? []), note],
|
|
2413
|
+
},
|
|
2414
|
+
current,
|
|
1890
2415
|
);
|
|
2416
|
+
// buildRecord's nullish fallback preserves the old value; consume the
|
|
2417
|
+
// rejected attempt explicitly so a retry must produce fresh evidence.
|
|
2418
|
+
next.pendingConfirmation = undefined;
|
|
2419
|
+
validateCase(next);
|
|
2420
|
+
upsertCase(db, next);
|
|
2421
|
+
return { record: next, changed: true };
|
|
1891
2422
|
}
|
|
1892
2423
|
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1896
|
-
|
|
2424
|
+
// CONFIRMED — re-validate the whole bundle (defense in depth; the case may
|
|
2425
|
+
// have been touched between phase 1 and the verdict).
|
|
2426
|
+
for (const run of [...bundle.targetRuns, bundle.controlRun]) {
|
|
2427
|
+
validateRunEvidence(run, `${run.mode} run`);
|
|
2428
|
+
}
|
|
2429
|
+
assertEvidenceDifferential(bundle);
|
|
2430
|
+
assertMachineConfirmation(bundle);
|
|
2431
|
+
assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
|
|
2432
|
+
let pocHash: string | undefined;
|
|
1897
2433
|
try {
|
|
1898
|
-
|
|
2434
|
+
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
1899
2435
|
} catch {
|
|
1900
|
-
|
|
2436
|
+
pocHash = undefined;
|
|
2437
|
+
}
|
|
2438
|
+
if (!pocHash || pocHash !== bundle.pocSha256) {
|
|
2439
|
+
throw new Error(
|
|
2440
|
+
"PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
// The case target must still be the host the PoC ran against, and still
|
|
2444
|
+
// differ from the control target. The evidence proves nothing about a
|
|
2445
|
+
// target the case adopted after the runs.
|
|
2446
|
+
const targetRun = bundle.targetRuns[0];
|
|
2447
|
+
if (!current.target || current.target !== targetRun.target) {
|
|
2448
|
+
throw new Error(
|
|
2449
|
+
"Case target changed since the PoC runs — re-run PromoteFinding against the current target " +
|
|
2450
|
+
`(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
if (current.target === bundle.controlTarget) {
|
|
2454
|
+
throw new Error(
|
|
2455
|
+
"Case target now equals the control target — the claimed impact is not target-dependent; " +
|
|
2456
|
+
"re-run PromoteFinding with a distinct control_target.",
|
|
2457
|
+
);
|
|
1901
2458
|
}
|
|
1902
2459
|
|
|
1903
|
-
//
|
|
1904
|
-
// file than the PoC (same hash = the model re-used its PoC as "the initial
|
|
1905
|
-
// signal"), a different basename, and it must predate the PoC run.
|
|
2460
|
+
// The observation must predate the repro (provenance guard).
|
|
1906
2461
|
const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
|
|
1907
|
-
if (observation) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
);
|
|
1913
|
-
}
|
|
1914
|
-
if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
|
|
1915
|
-
throw new Error(
|
|
1916
|
-
"Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
|
|
1917
|
-
"The initial signal must be a separate captured artifact.",
|
|
1918
|
-
);
|
|
1919
|
-
}
|
|
1920
|
-
if (observation.createdAt > verification.ranAt) {
|
|
1921
|
-
throw new Error(
|
|
1922
|
-
"Evidence chain invalid: the observation item was recorded after the PoC ran " +
|
|
1923
|
-
`(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
2462
|
+
if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
|
|
2463
|
+
throw new Error(
|
|
2464
|
+
"Evidence chain invalid: the observation item was recorded after the PoC ran " +
|
|
2465
|
+
`(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
|
|
2466
|
+
);
|
|
1926
2467
|
}
|
|
2468
|
+
|
|
2469
|
+
// Phase 1 proves the evidence floor. Phase 2 must freshly replay that same
|
|
2470
|
+
// request inside the main agent's ConfirmFinding call; a caller-provided
|
|
2471
|
+
// boolean is not accepted as proof of re-execution.
|
|
2472
|
+
assertMainAgentVerification(bundle, phase2Verification);
|
|
2473
|
+
|
|
1927
2474
|
const reproductionItem: EvidenceItem = {
|
|
1928
|
-
id: `ev_${stableShortId(`${id}\nreproduction\n${
|
|
2475
|
+
id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
|
|
1929
2476
|
caseId: id,
|
|
1930
2477
|
role: "reproduction",
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2478
|
+
// The runner preserves each run's evidence.json in a durable dir
|
|
2479
|
+
// (.pi/poc-evidence/) — the artifact the hash was computed over still
|
|
2480
|
+
// exists, so the item stays artifact-backed and re-verifiable.
|
|
2481
|
+
artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
|
|
2482
|
+
sha256: targetRun.evidenceSha256,
|
|
2483
|
+
summary: `PoC evidence accepted (2 target runs + control; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
|
|
2484
|
+
createdAt: targetRun.ranAt,
|
|
1935
2485
|
};
|
|
1936
2486
|
|
|
1937
2487
|
const newEvidence =
|
|
1938
2488
|
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
1939
|
-
`### PoC Execution Capture (${
|
|
1940
|
-
`- **
|
|
1941
|
-
`- **
|
|
1942
|
-
`- **
|
|
1943
|
-
|
|
2489
|
+
`### PoC Execution Capture (${targetRun.ranAt})\n` +
|
|
2490
|
+
`- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
|
|
2491
|
+
`- **Target:** ${targetRun.target}\n` +
|
|
2492
|
+
`- **Machine evidence:** ${recorded.proofStrength} (a differential is not by itself proof of exploitation)\n` +
|
|
2493
|
+
`- **Main-agent reviewer:** ${verdict.model ?? "unknown model"} — semantic confirmation\n` +
|
|
2494
|
+
`#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
|
|
1944
2495
|
|
|
1945
2496
|
const update: NormalizedCaseInput = {
|
|
1946
2497
|
status: "confirmed",
|
|
1947
|
-
pocVerified:
|
|
1948
|
-
|
|
1949
|
-
|
|
2498
|
+
pocVerified: {
|
|
2499
|
+
path: bundle.pocPath,
|
|
2500
|
+
exitCode: targetRun.exitCode,
|
|
2501
|
+
ranAt: targetRun.ranAt,
|
|
2502
|
+
output: targetRun.output,
|
|
2503
|
+
sandbox: targetRun.sandbox,
|
|
2504
|
+
completed: true,
|
|
2505
|
+
outputComplete: true,
|
|
2506
|
+
mode: "poc",
|
|
2507
|
+
target: targetRun.target,
|
|
2508
|
+
},
|
|
2509
|
+
controlVerified: {
|
|
2510
|
+
path: bundle.controlPath,
|
|
2511
|
+
exitCode: bundle.controlRun.exitCode,
|
|
2512
|
+
ranAt: bundle.controlRun.ranAt,
|
|
2513
|
+
output: bundle.controlRun.output,
|
|
2514
|
+
sandbox: bundle.controlRun.sandbox,
|
|
2515
|
+
completed: true,
|
|
2516
|
+
outputComplete: true,
|
|
2517
|
+
mode: "control",
|
|
2518
|
+
target: bundle.controlRun.target,
|
|
2519
|
+
},
|
|
2520
|
+
disconfirmation: verdict.disconfirmation_attempt,
|
|
2521
|
+
confirmerVerdict: recorded,
|
|
2522
|
+
pendingConfirmation: undefined,
|
|
1950
2523
|
evidence: newEvidence,
|
|
1951
2524
|
};
|
|
1952
2525
|
|
|
1953
2526
|
const next = buildRecord(update, current);
|
|
2527
|
+
next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
|
|
1954
2528
|
validateCase(next);
|
|
1955
|
-
|
|
1956
2529
|
insertEvidenceItem(db, reproductionItem);
|
|
1957
2530
|
upsertCase(db, next);
|
|
1958
2531
|
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
@@ -2031,8 +2604,21 @@ function eTLDPlus1(host: string): string {
|
|
|
2031
2604
|
return parts.slice(-2).join(".");
|
|
2032
2605
|
}
|
|
2033
2606
|
|
|
2607
|
+
/**
|
|
2608
|
+
* Ruled-out phrasings that must not contribute to chain matching. Sentence
|
|
2609
|
+
* granularity keeps the positive signals intact: "no CSRF token on /transfer"
|
|
2610
|
+
* (a reason XSS→state-change chains) is NOT dropped — only explicit
|
|
2611
|
+
* "this class is not a finding" sentences are.
|
|
2612
|
+
*/
|
|
2613
|
+
const CHAIN_NEGATION_RE =
|
|
2614
|
+
/\b(not vulnerable|not susceptible|not exploitable|not present|not found|not affected|ruled out|no vulnerability|no vuln|no evidence of|absence of|false positive|not a finding|no issue found|dismissed|non-?vulnerable|not reachable)\b/i;
|
|
2615
|
+
|
|
2034
2616
|
function chainText(c: CaseRecord): string {
|
|
2035
|
-
|
|
2617
|
+
const raw = [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
|
|
2618
|
+
return raw
|
|
2619
|
+
.split(/[.;\n]+/)
|
|
2620
|
+
.filter((s) => !CHAIN_NEGATION_RE.test(s))
|
|
2621
|
+
.join(" ");
|
|
2036
2622
|
}
|
|
2037
2623
|
|
|
2038
2624
|
function hasChainClass(c: CaseRecord, re: RegExp): boolean {
|
|
@@ -2062,23 +2648,29 @@ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
|
|
|
2062
2648
|
return eTLDPlus1(ta) === eTLDPlus1(tb);
|
|
2063
2649
|
}
|
|
2064
2650
|
|
|
2065
|
-
/**
|
|
2066
|
-
* Scan non-terminal cases for exploitable chains (CyberStrike-style detection
|
|
2067
|
-
* over XPI's case records). Emits ranked suggestions; the agent decides
|
|
2068
|
-
* whether to CaseLink or open an escalation case.
|
|
2069
|
-
*/
|
|
2070
2651
|
export function suggestChains(caseId?: string): ChainSuggestion[] {
|
|
2071
2652
|
// Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
|
|
2072
2653
|
// to suggestions involving that case (filtering the inputs first would drop
|
|
2073
2654
|
// unlinked partner cases and kill cross-case pairing).
|
|
2074
2655
|
const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
|
|
2656
|
+
// Already-linked pairs are existing knowledge, not a missed combination —
|
|
2657
|
+
// suggesting them again is noise. One query for every link row.
|
|
2658
|
+
const linkedPairs = new Set<string>();
|
|
2659
|
+
const linkRows = getDb().prepare("SELECT source_id, target_id FROM case_links").all() as {
|
|
2660
|
+
source_id: string;
|
|
2661
|
+
target_id: string;
|
|
2662
|
+
}[];
|
|
2663
|
+
for (const row of linkRows) linkedPairs.add([row.source_id, row.target_id].sort().join("+"));
|
|
2075
2664
|
const suggestions: ChainSuggestion[] = [];
|
|
2076
2665
|
const seen = new Set<string>();
|
|
2077
2666
|
const confirmed = (c: CaseRecord) => c.status === "confirmed";
|
|
2078
2667
|
const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
|
|
2079
2668
|
const both = confirmed(a) && (!b || confirmed(b));
|
|
2080
2669
|
const one = confirmed(a) || (b ? confirmed(b) : false);
|
|
2081
|
-
|
|
2670
|
+
const anyHypothesis = a.status === "hypothesis" || (b ? b.status === "hypothesis" : false);
|
|
2671
|
+
if (both) return 90;
|
|
2672
|
+
if (anyHypothesis) return 40; // unproven primitives chain weakly
|
|
2673
|
+
return one ? 75 : 60;
|
|
2082
2674
|
};
|
|
2083
2675
|
const add = (
|
|
2084
2676
|
pattern: ChainPattern,
|
|
@@ -2087,6 +2679,7 @@ export function suggestChains(caseId?: string): ChainSuggestion[] {
|
|
|
2087
2679
|
rationale: string,
|
|
2088
2680
|
kind?: CaseLinkKind,
|
|
2089
2681
|
) => {
|
|
2682
|
+
if (b && linkedPairs.has([a.id, b.id].sort().join("+"))) return; // already known
|
|
2090
2683
|
const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
|
|
2091
2684
|
if (seen.has(key)) return;
|
|
2092
2685
|
seen.add(key);
|
|
@@ -2360,12 +2953,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
2360
2953
|
|
|
2361
2954
|
const query = options.query?.trim().toLowerCase();
|
|
2362
2955
|
if (query) {
|
|
2363
|
-
|
|
2956
|
+
// Escape LIKE wildcards so a query containing % or _ matches literally
|
|
2957
|
+
// instead of acting as a pattern ("100%" must not match "1000"). The
|
|
2958
|
+
// backslash is the escape char, so it is escaped first.
|
|
2959
|
+
const escaped = query.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
2960
|
+
const likeParam = `%${escaped}%`;
|
|
2364
2961
|
if (options.field) {
|
|
2365
|
-
where.push(`lower(${options.field}) LIKE
|
|
2962
|
+
where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
|
|
2366
2963
|
params.push(likeParam);
|
|
2367
2964
|
} else {
|
|
2368
|
-
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE
|
|
2965
|
+
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ? ESCAPE '\\'`).join(" OR ");
|
|
2369
2966
|
where.push(`(${ors})`);
|
|
2370
2967
|
for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
|
|
2371
2968
|
}
|
|
@@ -2405,8 +3002,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
|
|
|
2405
3002
|
total: number;
|
|
2406
3003
|
} {
|
|
2407
3004
|
const db = getDb();
|
|
2408
|
-
|
|
2409
|
-
|
|
3005
|
+
// NaN is not clamped by Math.min/max (it passes through) and SQLite binds it
|
|
3006
|
+
// as NULL, which disables LIMIT — fall back to the default instead.
|
|
3007
|
+
const rawLimit = Number.isFinite(options.limit) ? options.limit : undefined;
|
|
3008
|
+
const rawOffset = Number.isFinite(options.offset) ? options.offset : undefined;
|
|
3009
|
+
const limit = Math.max(1, Math.min(rawLimit ?? 50, 200));
|
|
3010
|
+
const offset = Math.max(0, rawOffset ?? 0);
|
|
2410
3011
|
|
|
2411
3012
|
const { whereSql, orderSql, params } = buildCaseWhere(options);
|
|
2412
3013
|
|
|
@@ -2496,7 +3097,10 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
2496
3097
|
} else if (Array.isArray(val)) {
|
|
2497
3098
|
display = val.join(", ");
|
|
2498
3099
|
} else if (typeof val === "object") {
|
|
2499
|
-
|
|
3100
|
+
// Path-leak guard (consistent with buildCompleteRecord): verification
|
|
3101
|
+
// objects and the pending bundle carry local script/evidence paths —
|
|
3102
|
+
// show basenames only.
|
|
3103
|
+
display = JSON.stringify(redactPaths(val));
|
|
2500
3104
|
} else {
|
|
2501
3105
|
display = String(val);
|
|
2502
3106
|
}
|
|
@@ -2528,25 +3132,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
|
|
|
2528
3132
|
* must not balloon the report context into megabytes. */
|
|
2529
3133
|
const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
|
|
2530
3134
|
|
|
3135
|
+
/**
|
|
3136
|
+
* Recursively redact local filesystem paths to basenames in a serialized
|
|
3137
|
+
* object. Covers the verification records (path), the pending confirmation
|
|
3138
|
+
* bundle (pocPath/controlPath) and preserved evidence copies (evidencePath) —
|
|
3139
|
+
* the context bundle must never leak the researcher's local paths.
|
|
3140
|
+
*/
|
|
3141
|
+
function redactPaths(value: unknown, seen = new Set<object>()): unknown {
|
|
3142
|
+
if (Array.isArray(value)) return value.map((v) => redactPaths(v, seen));
|
|
3143
|
+
if (typeof value !== "object" || value === null) return value;
|
|
3144
|
+
if (seen.has(value)) return value;
|
|
3145
|
+
seen.add(value);
|
|
3146
|
+
const out: Record<string, unknown> = {};
|
|
3147
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3148
|
+
if (
|
|
3149
|
+
typeof v === "string" &&
|
|
3150
|
+
(k === "path" || k === "pocPath" || k === "controlPath" || k === "evidencePath")
|
|
3151
|
+
) {
|
|
3152
|
+
out[k] = basename(v) || v;
|
|
3153
|
+
} else {
|
|
3154
|
+
out[k] = redactPaths(v, seen);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
return out;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
2531
3160
|
function buildCompleteRecord(current: CaseRecord): string {
|
|
2532
3161
|
const rows: string[] = [];
|
|
2533
3162
|
for (const [k, v] of Object.entries(current)) {
|
|
2534
3163
|
if (v === undefined || v === null || v === "") continue;
|
|
2535
|
-
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
if (
|
|
2540
|
-
(k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
|
|
2541
|
-
v &&
|
|
2542
|
-
typeof v === "object"
|
|
2543
|
-
) {
|
|
2544
|
-
const redacted = {
|
|
2545
|
-
...(v as Record<string, unknown>),
|
|
2546
|
-
path: basename((v as { path?: string }).path ?? ""),
|
|
2547
|
-
};
|
|
2548
|
-
display = JSON.stringify(redacted, null, 2);
|
|
2549
|
-
}
|
|
3164
|
+
// Path-leak guard: verification objects + the pending bundle carry the
|
|
3165
|
+
// researcher's local PoC/disconfirmation/control/evidence paths — show
|
|
3166
|
+
// basenames only (the dedicated log sections do the same).
|
|
3167
|
+
const display = typeof v === "object" ? JSON.stringify(redactPaths(v), null, 2) : String(v);
|
|
2550
3168
|
rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
|
|
2551
3169
|
}
|
|
2552
3170
|
return rows.join("\n");
|
|
@@ -2627,11 +3245,13 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2627
3245
|
: "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
|
|
2628
3246
|
}
|
|
2629
3247
|
|
|
2630
|
-
export
|
|
3248
|
+
export type CaseContextResult = {
|
|
2631
3249
|
path: string;
|
|
2632
3250
|
contextPath: string;
|
|
2633
3251
|
record: CaseRecord;
|
|
2634
|
-
}
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
export function writeCaseContext(id: string): CaseContextResult {
|
|
2635
3255
|
const current = getCaseById(id);
|
|
2636
3256
|
if (!current) throw new Error(`Case not found: ${id}`);
|
|
2637
3257
|
if (current.status !== "confirmed" && current.status !== "reported") {
|
|
@@ -2702,13 +3322,13 @@ export function writeCaseContext(id: string): {
|
|
|
2702
3322
|
current.pocVerified
|
|
2703
3323
|
? mdSection(
|
|
2704
3324
|
"PoC Verification Log",
|
|
2705
|
-
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
|
|
3325
|
+
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n- **Target:** ${current.pocVerified.target ?? "not recorded"}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
|
|
2706
3326
|
)
|
|
2707
3327
|
: undefined,
|
|
2708
3328
|
current.controlVerified
|
|
2709
3329
|
? mdSection(
|
|
2710
3330
|
"Control-Target Check (anti-cheat)",
|
|
2711
|
-
`### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **
|
|
3331
|
+
`### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Control target:** ${current.controlVerified.target ?? "not recorded"}\n- **Differential (machine-checked):** control evidence differs from the target runs' evidence — the claimed impact is target-dependent (assertEvidenceDifferential, re-checked at confirm).\n- **Note:** zero exit is necessary run integrity, never vulnerability proof; output markers are diagnostic only. The machine floor is the harness differential plus main-agent review.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
|
|
2712
3332
|
)
|
|
2713
3333
|
: undefined,
|
|
2714
3334
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|