@xaccefy/pi-casefile 0.9.0 → 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 -6
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +3 -3
- package/src/evidence.ts +501 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +202 -94
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +491 -59
- package/src/pipeline-submit.ts +88 -33
- package/src/poc-runner.ts +67 -20
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +39 -24
- package/src/workflow.ts +25 -20
package/src/ledger.ts
CHANGED
|
@@ -11,15 +11,33 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
16
14
|
import {
|
|
17
|
-
|
|
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 {
|
|
18
27
|
evidenceNonceMatches,
|
|
28
|
+
type MainAgentVerdict,
|
|
19
29
|
normalizeEvidence,
|
|
20
30
|
type PoCEvidence,
|
|
21
|
-
|
|
31
|
+
parsePoCEvidence,
|
|
32
|
+
validateMainAgentVerdict,
|
|
22
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";
|
|
23
41
|
import {
|
|
24
42
|
findWorkspaceRoot,
|
|
25
43
|
getScratchpadRoot,
|
|
@@ -53,6 +71,51 @@ export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
|
53
71
|
|
|
54
72
|
/** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
|
|
55
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
|
+
}
|
|
56
119
|
|
|
57
120
|
/** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
|
|
58
121
|
export const EVIDENCE_ROLE_VALUES = [
|
|
@@ -198,9 +261,9 @@ export type CaseRecord = {
|
|
|
198
261
|
disconfirmationVerified?: PocVerificationRecord;
|
|
199
262
|
/** Verification of a control-target run (set only by the confirmation gate). */
|
|
200
263
|
controlVerified?: PocVerificationRecord;
|
|
201
|
-
/** Phase-1 evidence bundle awaiting
|
|
264
|
+
/** Phase-1 evidence bundle awaiting main-agent review (ConfirmFinding). */
|
|
202
265
|
pendingConfirmation?: PendingConfirmation;
|
|
203
|
-
/** Last
|
|
266
|
+
/** Last phase-2 verdict (legacy field name retained for database compatibility). */
|
|
204
267
|
confirmerVerdict?: ConfirmerVerdictRecord;
|
|
205
268
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
206
269
|
reportedAt?: string;
|
|
@@ -239,7 +302,7 @@ export type PocEvidenceRun = {
|
|
|
239
302
|
sandbox: boolean;
|
|
240
303
|
completed: boolean;
|
|
241
304
|
outputComplete: boolean;
|
|
242
|
-
/** Display-sliced output (diagnostic; exit
|
|
305
|
+
/** Display-sliced output (diagnostic; zero exit is necessary, not proof). */
|
|
243
306
|
output: string;
|
|
244
307
|
evidence: PoCEvidence;
|
|
245
308
|
evidenceSha256: string;
|
|
@@ -249,10 +312,16 @@ export type PocEvidenceRun = {
|
|
|
249
312
|
evidencePath?: string;
|
|
250
313
|
};
|
|
251
314
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
|
|
256
325
|
export type PendingConfirmation = {
|
|
257
326
|
caseId: string;
|
|
258
327
|
ranAt: string;
|
|
@@ -263,10 +332,30 @@ export type PendingConfirmation = {
|
|
|
263
332
|
controlTarget: string;
|
|
264
333
|
targetRuns: [PocEvidenceRun, PocEvidenceRun];
|
|
265
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";
|
|
266
355
|
};
|
|
267
356
|
|
|
268
|
-
/**
|
|
269
|
-
export type ConfirmerVerdictRecord =
|
|
357
|
+
/** @deprecated Compatibility alias for the legacy database/API field name. */
|
|
358
|
+
export type ConfirmerVerdictRecord = MainAgentVerdictRecord;
|
|
270
359
|
|
|
271
360
|
/** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
|
|
272
361
|
export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
|
|
@@ -382,6 +471,101 @@ function detectWorkspaceRoot(): string {
|
|
|
382
471
|
);
|
|
383
472
|
}
|
|
384
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
|
+
|
|
385
569
|
export function getCasefilePath(): string {
|
|
386
570
|
if (ledgerPathOverride) return ledgerPathOverride;
|
|
387
571
|
// Trim BEFORE the truthiness check: a whitespace-only value must not
|
|
@@ -410,11 +594,18 @@ function getDb(): DatabaseSync {
|
|
|
410
594
|
|
|
411
595
|
const dbPath = getCasefilePath();
|
|
412
596
|
const dbDir = dirname(dbPath);
|
|
413
|
-
|
|
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)) {
|
|
414
602
|
try {
|
|
415
603
|
mkdirSync(dbDir, { recursive: true });
|
|
416
604
|
} catch {}
|
|
417
605
|
}
|
|
606
|
+
for (const candidate of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
607
|
+
assertSafeRegularFile(candidate, "Casefile database state");
|
|
608
|
+
}
|
|
418
609
|
|
|
419
610
|
const db = new DatabaseSync(dbPath);
|
|
420
611
|
// Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
|
|
@@ -554,6 +745,9 @@ function getDb(): DatabaseSync {
|
|
|
554
745
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
|
|
555
746
|
|
|
556
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);
|
|
557
751
|
return db;
|
|
558
752
|
}
|
|
559
753
|
|
|
@@ -786,9 +980,6 @@ function validateCase(record: CaseRecord): void {
|
|
|
786
980
|
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
787
981
|
);
|
|
788
982
|
}
|
|
789
|
-
// A case becomes REPORTED only after the report FILE exists on disk (the
|
|
790
|
-
// report writer writes it at the path CaseContext recorded). Require both
|
|
791
|
-
// here so validation stays consistent with the confirmed→reported gate.
|
|
792
983
|
// A case becomes REPORTED only after a report FILE that passes the content
|
|
793
984
|
// gate exists on disk (the report writer writes it at the path CaseContext
|
|
794
985
|
// recorded). Existence is not enough: any non-empty file — or a directory —
|
|
@@ -862,15 +1053,18 @@ const REPORT_REQUIRED_SECTIONS = ["summary", "impact", "remediation"];
|
|
|
862
1053
|
* text (workflow.ts imports this) must not drift apart.
|
|
863
1054
|
*/
|
|
864
1055
|
export const KILL_REASON_VALUES = [
|
|
1056
|
+
"unreachable",
|
|
865
1057
|
"intended_behavior",
|
|
866
1058
|
"duplicate",
|
|
867
1059
|
"framework_protection",
|
|
1060
|
+
"input_validation_blocks",
|
|
1061
|
+
"requires_privilege_attacker_lacks",
|
|
868
1062
|
"exploit_unreliable",
|
|
869
1063
|
"insufficient_impact",
|
|
870
1064
|
"environmental_issue",
|
|
871
1065
|
"not_applicable",
|
|
872
1066
|
"out_of_scope",
|
|
873
|
-
"
|
|
1067
|
+
"test_artifact",
|
|
874
1068
|
"no_attack_path",
|
|
875
1069
|
"refuted",
|
|
876
1070
|
] as const;
|
|
@@ -937,7 +1131,7 @@ function validateTransition(
|
|
|
937
1131
|
: "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
|
|
938
1132
|
"artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
|
|
939
1133
|
"(intended_behavior, duplicate, framework_protection, out_of_scope, " +
|
|
940
|
-
"
|
|
1134
|
+
"insufficient_impact, no_attack_path, ...)",
|
|
941
1135
|
);
|
|
942
1136
|
}
|
|
943
1137
|
}
|
|
@@ -965,7 +1159,7 @@ function validateTransition(
|
|
|
965
1159
|
},
|
|
966
1160
|
investigating: {
|
|
967
1161
|
confirmed: () =>
|
|
968
|
-
"investigating → confirmed requires a verified PoC run; use the
|
|
1162
|
+
"investigating → confirmed requires a verified PoC run; use the PromoteFinding tool",
|
|
969
1163
|
hypothesis: () => null,
|
|
970
1164
|
},
|
|
971
1165
|
confirmed: {
|
|
@@ -1495,22 +1689,29 @@ export function addEvidenceItemResult(
|
|
|
1495
1689
|
if (!summary) throw new Error("Evidence summary must not be empty");
|
|
1496
1690
|
|
|
1497
1691
|
let artifactPath: string | undefined;
|
|
1692
|
+
|
|
1498
1693
|
let sha256: string | undefined;
|
|
1499
1694
|
if (input.artifactPath) {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
+
}
|
|
1511
1714
|
}
|
|
1512
|
-
artifactPath = basename(input.artifactPath);
|
|
1513
|
-
sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
|
|
1514
1715
|
}
|
|
1515
1716
|
|
|
1516
1717
|
const item: EvidenceItem = {
|
|
@@ -1814,6 +2015,11 @@ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
|
|
|
1814
2015
|
if (!run.outputComplete) {
|
|
1815
2016
|
throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
|
|
1816
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
|
+
}
|
|
1817
2023
|
if (!run.evidence || !run.evidenceSha256) {
|
|
1818
2024
|
throw new Error(
|
|
1819
2025
|
`${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
|
|
@@ -1822,6 +2028,39 @@ function validateRunEvidence(run: PocEvidenceRun, label: string): void {
|
|
|
1822
2028
|
if (!evidenceNonceMatches(run.evidence, run.nonce)) {
|
|
1823
2029
|
throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
|
|
1824
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
|
+
}
|
|
1825
2064
|
}
|
|
1826
2065
|
|
|
1827
2066
|
/** Determinism + differential on normalized evidence (nonce/observations stripped). */
|
|
@@ -1839,10 +2078,116 @@ function assertEvidenceDifferential(bundle: PendingConfirmation): void {
|
|
|
1839
2078
|
}
|
|
1840
2079
|
}
|
|
1841
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;
|
|
2101
|
+
}
|
|
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) {
|
|
2148
|
+
throw new Error(
|
|
2149
|
+
"MAIN-AGENT REPLAY REQUIRED: ConfirmFinding must produce a fresh harness-owned target/control transcript",
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
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
|
+
) {
|
|
2162
|
+
throw new Error(
|
|
2163
|
+
"MAIN-AGENT REPLAY FAILED: transcript timestamp must be valid, newer than phase 1, and no more than 5 minutes old",
|
|
2164
|
+
);
|
|
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
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
1842
2187
|
/**
|
|
1843
2188
|
* Gate for phase 1 of promotion: case must exist, be investigating, and have
|
|
1844
2189
|
* poc/evidence/impact/severity/target. The disconfirmation is provided by the
|
|
1845
|
-
*
|
|
2190
|
+
* main agent at confirm time, so it is NOT a precondition here. Returns the
|
|
1846
2191
|
* record when promotable, throws otherwise. Exported so PromoteFinding can
|
|
1847
2192
|
* validate BEFORE paying for (potentially slow) sandboxed PoC runs.
|
|
1848
2193
|
*/
|
|
@@ -1890,7 +2235,7 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1890
2235
|
* contract is validated here — same-file control, nonce binding, run
|
|
1891
2236
|
* completion, determinism across the two target runs, and the target/control
|
|
1892
2237
|
* differential — so a bundle that cannot promote is rejected before the
|
|
1893
|
-
*
|
|
2238
|
+
* main agent performs phase-2 review.
|
|
1894
2239
|
*/
|
|
1895
2240
|
export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
|
|
1896
2241
|
const db = getDb();
|
|
@@ -1961,6 +2306,23 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
|
|
|
1961
2306
|
validateRunEvidence(run, `${run.mode} run`);
|
|
1962
2307
|
}
|
|
1963
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,
|
|
2317
|
+
);
|
|
2318
|
+
if (controlBindingError) {
|
|
2319
|
+
throw new Error(`CONTROL BINDING FAILED: ${controlBindingError}`);
|
|
2320
|
+
}
|
|
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);
|
|
1964
2326
|
|
|
1965
2327
|
const next = buildRecord({ pendingConfirmation: bundle }, current);
|
|
1966
2328
|
validateCase(next);
|
|
@@ -1970,19 +2332,28 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
|
|
|
1970
2332
|
}
|
|
1971
2333
|
|
|
1972
2334
|
/**
|
|
1973
|
-
* Phase 2: commit (or refuse) the promotion on
|
|
2335
|
+
* Phase 2: commit (or refuse) the promotion on the main agent's verdict.
|
|
1974
2336
|
*
|
|
1975
2337
|
* CONFIRMED requires the full bundle to still hold (completion, nonce,
|
|
1976
2338
|
* determinism, differential), the PoC script to be unchanged since the runs
|
|
1977
|
-
* (pocSha256 — otherwise the
|
|
1978
|
-
* verdict
|
|
1979
|
-
* and a disconfirmation attempt. NOT_CONFIRMED records the
|
|
1980
|
-
* the case investigating — no tie-breaker.
|
|
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.
|
|
1981
2343
|
*/
|
|
1982
2344
|
export function applyConfirmationResult(
|
|
1983
2345
|
id: string,
|
|
1984
|
-
verdictInput:
|
|
2346
|
+
verdictInput: MainAgentVerdict,
|
|
2347
|
+
phase2Verification?: MainAgentVerification,
|
|
2348
|
+
authority: { startedAsSubagent: boolean } = {
|
|
2349
|
+
startedAsSubagent: PROCESS_STARTED_AS_SUBAGENT || process.env.PI_SUBAGENT_CHILD === "1",
|
|
2350
|
+
},
|
|
1985
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
|
+
}
|
|
1986
2357
|
const db = getDb();
|
|
1987
2358
|
return withImmediateTransaction(db, () => {
|
|
1988
2359
|
const current = getCaseById(id);
|
|
@@ -2003,17 +2374,48 @@ export function applyConfirmationResult(
|
|
|
2003
2374
|
"Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
|
|
2004
2375
|
);
|
|
2005
2376
|
}
|
|
2006
|
-
const parsed =
|
|
2007
|
-
if (!parsed.ok) throw new Error(`Invalid
|
|
2377
|
+
const parsed = validateMainAgentVerdict(verdictInput);
|
|
2378
|
+
if (!parsed.ok) throw new Error(`Invalid main-agent confirmation verdict: ${parsed.error}`);
|
|
2008
2379
|
const verdict = parsed.verdict;
|
|
2009
|
-
const
|
|
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
|
+
};
|
|
2010
2405
|
|
|
2011
2406
|
if (verdict.verdict === "NOT_CONFIRMED") {
|
|
2012
|
-
const note = `
|
|
2407
|
+
const note = `main agent NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
|
|
2013
2408
|
const next = buildRecord(
|
|
2014
|
-
{
|
|
2409
|
+
{
|
|
2410
|
+
confirmerVerdict: recorded,
|
|
2411
|
+
pendingConfirmation: undefined,
|
|
2412
|
+
assumptions: [...(current.assumptions ?? []), note],
|
|
2413
|
+
},
|
|
2015
2414
|
current,
|
|
2016
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;
|
|
2017
2419
|
validateCase(next);
|
|
2018
2420
|
upsertCase(db, next);
|
|
2019
2421
|
return { record: next, changed: true };
|
|
@@ -2025,6 +2427,8 @@ export function applyConfirmationResult(
|
|
|
2025
2427
|
validateRunEvidence(run, `${run.mode} run`);
|
|
2026
2428
|
}
|
|
2027
2429
|
assertEvidenceDifferential(bundle);
|
|
2430
|
+
assertMachineConfirmation(bundle);
|
|
2431
|
+
assertHarnessCanary(bundle.harnessVerified, canaryRequested, "PHASE-1 CANARY FAILED");
|
|
2028
2432
|
let pocHash: string | undefined;
|
|
2029
2433
|
try {
|
|
2030
2434
|
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
@@ -2033,7 +2437,7 @@ export function applyConfirmationResult(
|
|
|
2033
2437
|
}
|
|
2034
2438
|
if (!pocHash || pocHash !== bundle.pocSha256) {
|
|
2035
2439
|
throw new Error(
|
|
2036
|
-
"PoC script changed since the runs — re-run PromoteFinding (the
|
|
2440
|
+
"PoC script changed since the runs — re-run PromoteFinding (the main agent must review the exact bytes that ran)",
|
|
2037
2441
|
);
|
|
2038
2442
|
}
|
|
2039
2443
|
// The case target must still be the host the PoC ran against, and still
|
|
@@ -2062,6 +2466,11 @@ export function applyConfirmationResult(
|
|
|
2062
2466
|
);
|
|
2063
2467
|
}
|
|
2064
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
|
+
|
|
2065
2474
|
const reproductionItem: EvidenceItem = {
|
|
2066
2475
|
id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
|
|
2067
2476
|
caseId: id,
|
|
@@ -2071,7 +2480,7 @@ export function applyConfirmationResult(
|
|
|
2071
2480
|
// exists, so the item stays artifact-backed and re-verifiable.
|
|
2072
2481
|
artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
|
|
2073
2482
|
sha256: targetRun.evidenceSha256,
|
|
2074
|
-
summary: `PoC evidence
|
|
2483
|
+
summary: `PoC evidence accepted (2 target runs + control; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
|
|
2075
2484
|
createdAt: targetRun.ranAt,
|
|
2076
2485
|
};
|
|
2077
2486
|
|
|
@@ -2080,7 +2489,8 @@ export function applyConfirmationResult(
|
|
|
2080
2489
|
`### PoC Execution Capture (${targetRun.ranAt})\n` +
|
|
2081
2490
|
`- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
|
|
2082
2491
|
`- **Target:** ${targetRun.target}\n` +
|
|
2083
|
-
`- **
|
|
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` +
|
|
2084
2494
|
`#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
|
|
2085
2495
|
|
|
2086
2496
|
const update: NormalizedCaseInput = {
|
|
@@ -2194,8 +2604,21 @@ function eTLDPlus1(host: string): string {
|
|
|
2194
2604
|
return parts.slice(-2).join(".");
|
|
2195
2605
|
}
|
|
2196
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
|
+
|
|
2197
2616
|
function chainText(c: CaseRecord): string {
|
|
2198
|
-
|
|
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(" ");
|
|
2199
2622
|
}
|
|
2200
2623
|
|
|
2201
2624
|
function hasChainClass(c: CaseRecord, re: RegExp): boolean {
|
|
@@ -2225,23 +2648,29 @@ function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
|
|
|
2225
2648
|
return eTLDPlus1(ta) === eTLDPlus1(tb);
|
|
2226
2649
|
}
|
|
2227
2650
|
|
|
2228
|
-
/**
|
|
2229
|
-
* Scan non-terminal cases for exploitable chains (CyberStrike-style detection
|
|
2230
|
-
* over XPI's case records). Emits ranked suggestions; the agent decides
|
|
2231
|
-
* whether to CaseLink or open an escalation case.
|
|
2232
|
-
*/
|
|
2233
2651
|
export function suggestChains(caseId?: string): ChainSuggestion[] {
|
|
2234
2652
|
// Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
|
|
2235
2653
|
// to suggestions involving that case (filtering the inputs first would drop
|
|
2236
2654
|
// unlinked partner cases and kill cross-case pairing).
|
|
2237
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("+"));
|
|
2238
2664
|
const suggestions: ChainSuggestion[] = [];
|
|
2239
2665
|
const seen = new Set<string>();
|
|
2240
2666
|
const confirmed = (c: CaseRecord) => c.status === "confirmed";
|
|
2241
2667
|
const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
|
|
2242
2668
|
const both = confirmed(a) && (!b || confirmed(b));
|
|
2243
2669
|
const one = confirmed(a) || (b ? confirmed(b) : false);
|
|
2244
|
-
|
|
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;
|
|
2245
2674
|
};
|
|
2246
2675
|
const add = (
|
|
2247
2676
|
pattern: ChainPattern,
|
|
@@ -2250,6 +2679,7 @@ export function suggestChains(caseId?: string): ChainSuggestion[] {
|
|
|
2250
2679
|
rationale: string,
|
|
2251
2680
|
kind?: CaseLinkKind,
|
|
2252
2681
|
) => {
|
|
2682
|
+
if (b && linkedPairs.has([a.id, b.id].sort().join("+"))) return; // already known
|
|
2253
2683
|
const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
|
|
2254
2684
|
if (seen.has(key)) return;
|
|
2255
2685
|
seen.add(key);
|
|
@@ -2815,11 +3245,13 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2815
3245
|
: "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
|
|
2816
3246
|
}
|
|
2817
3247
|
|
|
2818
|
-
export
|
|
3248
|
+
export type CaseContextResult = {
|
|
2819
3249
|
path: string;
|
|
2820
3250
|
contextPath: string;
|
|
2821
3251
|
record: CaseRecord;
|
|
2822
|
-
}
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
export function writeCaseContext(id: string): CaseContextResult {
|
|
2823
3255
|
const current = getCaseById(id);
|
|
2824
3256
|
if (!current) throw new Error(`Case not found: ${id}`);
|
|
2825
3257
|
if (current.status !== "confirmed" && current.status !== "reported") {
|
|
@@ -2896,7 +3328,7 @@ export function writeCaseContext(id: string): {
|
|
|
2896
3328
|
current.controlVerified
|
|
2897
3329
|
? mdSection(
|
|
2898
3330
|
"Control-Target Check (anti-cheat)",
|
|
2899
|
-
`### 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:** exit
|
|
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\`\`\``,
|
|
2900
3332
|
)
|
|
2901
3333
|
: undefined,
|
|
2902
3334
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|