@xaccefy/pi-casefile 0.8.2 → 0.9.0
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 +4 -3
- package/package.json +3 -5
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +294 -225
- package/src/ledger.ts +591 -396
- package/src/pipeline-submit.ts +279 -26
- package/src/poc-runner.ts +186 -65
- package/src/scratchpad.ts +106 -16
- package/src/workflow.ts +79 -16
package/src/ledger.ts
CHANGED
|
@@ -11,22 +11,22 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import {
|
|
15
|
-
type Dirent,
|
|
16
|
-
existsSync,
|
|
17
|
-
mkdirSync,
|
|
18
|
-
readdirSync,
|
|
19
|
-
readFileSync,
|
|
20
|
-
statSync,
|
|
21
|
-
writeFileSync,
|
|
22
|
-
} from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
23
15
|
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
type ConfirmerVerdict,
|
|
18
|
+
evidenceNonceMatches,
|
|
19
|
+
normalizeEvidence,
|
|
20
|
+
type PoCEvidence,
|
|
21
|
+
validateConfirmerVerdict,
|
|
22
|
+
} from "./evidence.ts";
|
|
24
23
|
import {
|
|
25
24
|
findWorkspaceRoot,
|
|
26
25
|
getScratchpadRoot,
|
|
27
26
|
PHASE_ORDER,
|
|
28
27
|
scratchpad_read,
|
|
29
28
|
scratchpad_resume,
|
|
29
|
+
scratchpad_runs,
|
|
30
30
|
} from "./scratchpad.ts";
|
|
31
31
|
import { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
32
32
|
|
|
@@ -164,6 +164,13 @@ export type CaseRecord = {
|
|
|
164
164
|
id: string;
|
|
165
165
|
title: string;
|
|
166
166
|
status: CaseStatus;
|
|
167
|
+
/**
|
|
168
|
+
* True once the case has EVER reached investigating or confirmed. The kill
|
|
169
|
+
* gate keys off this, not the current status: a demotion
|
|
170
|
+
* (investigating/confirmed -> hypothesis) must not let an advanced case die
|
|
171
|
+
* with a keyword in free text instead of artifact-backed refutation evidence.
|
|
172
|
+
*/
|
|
173
|
+
everAdvanced: boolean;
|
|
167
174
|
confidence: CaseConfidence;
|
|
168
175
|
severity?: CaseSeverity;
|
|
169
176
|
priority?: CasePriority;
|
|
@@ -186,29 +193,15 @@ export type CaseRecord = {
|
|
|
186
193
|
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
187
194
|
disconfirmation?: string;
|
|
188
195
|
/** Verification of an on-disk PoC run (set only by promoteFindingResult). */
|
|
189
|
-
pocVerified?:
|
|
190
|
-
path: string;
|
|
191
|
-
exitCode: number;
|
|
192
|
-
ranAt: string;
|
|
193
|
-
output?: string;
|
|
194
|
-
sandbox: boolean;
|
|
195
|
-
};
|
|
196
|
+
pocVerified?: PocVerificationRecord;
|
|
196
197
|
/** Verification of a disconfirmation run (set only by promoteFindingResult). */
|
|
197
|
-
disconfirmationVerified?:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
/** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
|
|
205
|
-
controlVerified?: {
|
|
206
|
-
path: string;
|
|
207
|
-
exitCode: number;
|
|
208
|
-
ranAt: string;
|
|
209
|
-
output?: string;
|
|
210
|
-
sandbox: boolean;
|
|
211
|
-
};
|
|
198
|
+
disconfirmationVerified?: PocVerificationRecord;
|
|
199
|
+
/** Verification of a control-target run (set only by the confirmation gate). */
|
|
200
|
+
controlVerified?: PocVerificationRecord;
|
|
201
|
+
/** Phase-1 evidence bundle awaiting a confirmer verdict (ConfirmFinding). */
|
|
202
|
+
pendingConfirmation?: PendingConfirmation;
|
|
203
|
+
/** Last confirmer verdict (CONFIRMED commits the promotion; NOT_CONFIRMED keeps investigating). */
|
|
204
|
+
confirmerVerdict?: ConfirmerVerdictRecord;
|
|
212
205
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
213
206
|
reportedAt?: string;
|
|
214
207
|
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
@@ -223,6 +216,61 @@ export type CaseRecord = {
|
|
|
223
216
|
updatedAt: string;
|
|
224
217
|
};
|
|
225
218
|
|
|
219
|
+
export type PocVerificationRecord = {
|
|
220
|
+
path: string;
|
|
221
|
+
exitCode: number;
|
|
222
|
+
ranAt: string;
|
|
223
|
+
output?: string;
|
|
224
|
+
sandbox: boolean;
|
|
225
|
+
completed?: boolean;
|
|
226
|
+
outputComplete?: boolean;
|
|
227
|
+
mode?: string;
|
|
228
|
+
target?: string;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** One harness-observed PoC run with its validated, nonce-bound evidence. */
|
|
232
|
+
export type PocEvidenceRun = {
|
|
233
|
+
mode: "poc" | "control";
|
|
234
|
+
target: string;
|
|
235
|
+
/** The run's PI_POC_NONCE — evidence.nonce must equal it (binds evidence to the run). */
|
|
236
|
+
nonce: string;
|
|
237
|
+
ranAt: string;
|
|
238
|
+
exitCode: number;
|
|
239
|
+
sandbox: boolean;
|
|
240
|
+
completed: boolean;
|
|
241
|
+
outputComplete: boolean;
|
|
242
|
+
/** Display-sliced output (diagnostic; exit codes are not gates). */
|
|
243
|
+
output: string;
|
|
244
|
+
evidence: PoCEvidence;
|
|
245
|
+
evidenceSha256: string;
|
|
246
|
+
/** Absolute path to the PRESERVED copy of this run's evidence.json (the
|
|
247
|
+
* runner copies the temp file into a durable .pi/poc-evidence/ dir; the
|
|
248
|
+
* reproduction item references it so the stored hash stays verifiable). */
|
|
249
|
+
evidencePath?: string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Phase-1 bundle PromoteFinding records; ConfirmFinding commits on a verdict.
|
|
254
|
+
* Contains everything the confirmer reviews and the ledger re-checks.
|
|
255
|
+
*/
|
|
256
|
+
export type PendingConfirmation = {
|
|
257
|
+
caseId: string;
|
|
258
|
+
ranAt: string;
|
|
259
|
+
pocPath: string;
|
|
260
|
+
/** SHA-256 of the PoC script AT RUN TIME — re-hashed at confirm to catch edits. */
|
|
261
|
+
pocSha256: string;
|
|
262
|
+
controlPath: string;
|
|
263
|
+
controlTarget: string;
|
|
264
|
+
targetRuns: [PocEvidenceRun, PocEvidenceRun];
|
|
265
|
+
controlRun: PocEvidenceRun;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Persisted confirmer verdict with the commit timestamp. */
|
|
269
|
+
export type ConfirmerVerdictRecord = ConfirmerVerdict & { at: string };
|
|
270
|
+
|
|
271
|
+
/** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
|
|
272
|
+
export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
|
|
273
|
+
|
|
226
274
|
export type CaseInput = {
|
|
227
275
|
title: string;
|
|
228
276
|
status?: CaseStatus;
|
|
@@ -252,6 +300,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
|
|
|
252
300
|
pocVerified?: CaseRecord["pocVerified"];
|
|
253
301
|
disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
|
|
254
302
|
controlVerified?: CaseRecord["controlVerified"];
|
|
303
|
+
pendingConfirmation?: CaseRecord["pendingConfirmation"];
|
|
304
|
+
confirmerVerdict?: CaseRecord["confirmerVerdict"];
|
|
255
305
|
reportedAt?: string;
|
|
256
306
|
reportPath?: string;
|
|
257
307
|
};
|
|
@@ -367,6 +417,13 @@ function getDb(): DatabaseSync {
|
|
|
367
417
|
}
|
|
368
418
|
|
|
369
419
|
const db = new DatabaseSync(dbPath);
|
|
420
|
+
// Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
|
|
421
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
422
|
+
try {
|
|
423
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
424
|
+
} catch {
|
|
425
|
+
// Some filesystems/backends reject WAL; rollback journal still works.
|
|
426
|
+
}
|
|
370
427
|
// Enable foreign-key enforcement so ON DELETE CASCADE actually fires
|
|
371
428
|
// (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
|
|
372
429
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -377,6 +434,7 @@ function getDb(): DatabaseSync {
|
|
|
377
434
|
id TEXT PRIMARY KEY,
|
|
378
435
|
title TEXT NOT NULL,
|
|
379
436
|
status TEXT NOT NULL,
|
|
437
|
+
ever_advanced INTEGER NOT NULL DEFAULT 0,
|
|
380
438
|
confidence TEXT NOT NULL,
|
|
381
439
|
severity TEXT,
|
|
382
440
|
priority TEXT,
|
|
@@ -396,6 +454,8 @@ function getDb(): DatabaseSync {
|
|
|
396
454
|
poc_verified_json TEXT, -- JSON object
|
|
397
455
|
disconfirmation TEXT,
|
|
398
456
|
disconfirmation_verified_json TEXT, -- JSON object
|
|
457
|
+
pending_confirmation_json TEXT, -- JSON object
|
|
458
|
+
confirmer_verdict_json TEXT, -- JSON object
|
|
399
459
|
reported_at TEXT,
|
|
400
460
|
report_path TEXT,
|
|
401
461
|
created_at TEXT NOT NULL,
|
|
@@ -434,6 +494,21 @@ function getDb(): DatabaseSync {
|
|
|
434
494
|
if (!caseCols.some((c) => c.name === "control_verified_json")) {
|
|
435
495
|
db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
|
|
436
496
|
}
|
|
497
|
+
if (!caseCols.some((c) => c.name === "pending_confirmation_json")) {
|
|
498
|
+
db.exec("ALTER TABLE cases ADD COLUMN pending_confirmation_json TEXT");
|
|
499
|
+
}
|
|
500
|
+
if (!caseCols.some((c) => c.name === "confirmer_verdict_json")) {
|
|
501
|
+
db.exec("ALTER TABLE cases ADD COLUMN confirmer_verdict_json TEXT");
|
|
502
|
+
}
|
|
503
|
+
if (!caseCols.some((c) => c.name === "ever_advanced")) {
|
|
504
|
+
db.exec("ALTER TABLE cases ADD COLUMN ever_advanced INTEGER NOT NULL DEFAULT 0");
|
|
505
|
+
// Backfill: a case that is (or was) past hypothesis has reached an
|
|
506
|
+
// advanced state. Terminal rows can no longer be mutated, but marking them
|
|
507
|
+
// keeps the flag consistent for history/context reads.
|
|
508
|
+
db.exec(
|
|
509
|
+
"UPDATE cases SET ever_advanced = 1 WHERE status IN ('investigating','confirmed','blocked','killed','reported')",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
437
512
|
|
|
438
513
|
// Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
|
|
439
514
|
db.exec(`
|
|
@@ -513,6 +588,7 @@ function mapRow(
|
|
|
513
588
|
id: row.id,
|
|
514
589
|
title: row.title,
|
|
515
590
|
status: row.status as CaseStatus,
|
|
591
|
+
everAdvanced: row.ever_advanced === 1,
|
|
516
592
|
confidence: row.confidence as CaseConfidence,
|
|
517
593
|
severity: row.severity as CaseSeverity | undefined,
|
|
518
594
|
priority: row.priority as CasePriority | undefined,
|
|
@@ -534,6 +610,8 @@ function mapRow(
|
|
|
534
610
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
535
611
|
disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
|
|
536
612
|
controlVerified: safeParseObject(row.control_verified_json),
|
|
613
|
+
pendingConfirmation: safeParseObject(row.pending_confirmation_json),
|
|
614
|
+
confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
|
|
537
615
|
reportedAt: row.reported_at || undefined,
|
|
538
616
|
reportPath: row.report_path || undefined,
|
|
539
617
|
evidenceItems,
|
|
@@ -679,16 +757,20 @@ function validateCase(record: CaseRecord): void {
|
|
|
679
757
|
);
|
|
680
758
|
}
|
|
681
759
|
// Keep this gate in lockstep with promoteFindingResult: a case may only be
|
|
682
|
-
// CONFIRMED when it has evidence, a PoC, demonstrated impact,
|
|
760
|
+
// CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
|
|
761
|
+
// and a named target (what host/repo/scope this affects).
|
|
683
762
|
if (
|
|
684
763
|
record.status === "confirmed" &&
|
|
685
764
|
(!record.evidence ||
|
|
686
765
|
!record.poc ||
|
|
687
766
|
!record.impact ||
|
|
688
767
|
!record.severity ||
|
|
768
|
+
!record.target ||
|
|
689
769
|
!record.disconfirmation)
|
|
690
770
|
) {
|
|
691
|
-
throw new Error(
|
|
771
|
+
throw new Error(
|
|
772
|
+
"Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
|
|
773
|
+
);
|
|
692
774
|
}
|
|
693
775
|
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
694
776
|
throw new Error("Blocked cases require at least one blocker");
|
|
@@ -794,7 +876,16 @@ export const KILL_REASON_VALUES = [
|
|
|
794
876
|
] as const;
|
|
795
877
|
export type KillReason = (typeof KILL_REASON_VALUES)[number];
|
|
796
878
|
|
|
797
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Matches a kill reason whether the agent wrote the canonical token
|
|
881
|
+
* ("out_of_scope"), a spaced form ("out of scope"), or hyphenated
|
|
882
|
+
* ("out-of-scope") — the underscore spelling is machine vocabulary; free text
|
|
883
|
+
* must not be rejected just because it reads naturally.
|
|
884
|
+
*/
|
|
885
|
+
const KILL_REASON_PATTERN = new RegExp(
|
|
886
|
+
`\\b(${KILL_REASON_VALUES.map((v) => v.replace(/_/g, "[ _-]+")).join("|")})\\b`,
|
|
887
|
+
"i",
|
|
888
|
+
);
|
|
798
889
|
|
|
799
890
|
function validateTransition(
|
|
800
891
|
from: CaseStatus,
|
|
@@ -819,26 +910,32 @@ function validateTransition(
|
|
|
819
910
|
|
|
820
911
|
if (to === "killed") {
|
|
821
912
|
// Black-cat rule: a kill must be justified. Valid iff (a) a refutation
|
|
822
|
-
// evidence item exists for this case, or (b) — only for
|
|
823
|
-
//
|
|
824
|
-
// vocabulary (matches workflow.ts). Once a case
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
913
|
+
// evidence item exists for this case, or (b) — only for cases that have
|
|
914
|
+
// NEVER reached investigating/confirmed — the update states a kill reason
|
|
915
|
+
// from the KILLED catalog vocabulary (matches workflow.ts). Once a case
|
|
916
|
+
// reached investigating or confirmed (everAdvanced — immune to demotion
|
|
917
|
+
// round-trips), a keyword in free text is NOT enough: the kill must be
|
|
918
|
+
// backed by a real refutation evidence item (EvidenceAdd role=refutation —
|
|
919
|
+
// the disprove attempt that ended the lead).
|
|
828
920
|
const items = current ? listEvidenceItems(current.id) : [];
|
|
829
921
|
if (!items.some((e) => e.role === "refutation" && e.sha256)) {
|
|
830
|
-
const advanced = current?.
|
|
831
|
-
const text = [
|
|
922
|
+
const advanced = current?.everAdvanced === true;
|
|
923
|
+
const text = [
|
|
924
|
+
update.nextStep,
|
|
925
|
+
(update.assumptions ?? []).join(" "),
|
|
926
|
+
(update.blockers ?? []).join(" "),
|
|
927
|
+
update.evidence,
|
|
928
|
+
]
|
|
832
929
|
.filter(Boolean)
|
|
833
930
|
.join(" ");
|
|
834
931
|
if (advanced || !KILL_REASON_PATTERN.test(text)) {
|
|
835
932
|
throw new Error(
|
|
836
933
|
advanced
|
|
837
|
-
? "Cannot kill an investigating/confirmed
|
|
934
|
+
? "Cannot kill an advanced case (ever reached investigating/confirmed) without ARTIFACT-BACKED refutation evidence: add " +
|
|
838
935
|
"EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
|
|
839
936
|
"that ended this lead) before killing."
|
|
840
937
|
: "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
|
|
841
|
-
"artifact_path recommended) or state a kill reason in assumptions/nextStep " +
|
|
938
|
+
"artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
|
|
842
939
|
"(intended_behavior, duplicate, framework_protection, out_of_scope, " +
|
|
843
940
|
"skeptic-disproven, no_attack_path, ...)",
|
|
844
941
|
);
|
|
@@ -929,6 +1026,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
929
1026
|
id,
|
|
930
1027
|
title,
|
|
931
1028
|
status: input.status ?? existing?.status ?? "hypothesis",
|
|
1029
|
+
// Once a case has been investigating/confirmed it never forgets — the kill
|
|
1030
|
+
// gate must not be defeatable by demoting first.
|
|
1031
|
+
everAdvanced:
|
|
1032
|
+
existing?.everAdvanced === true ||
|
|
1033
|
+
input.status === "investigating" ||
|
|
1034
|
+
input.status === "confirmed",
|
|
932
1035
|
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
933
1036
|
severity: input.severity ?? existing?.severity,
|
|
934
1037
|
priority: input.priority ?? existing?.priority,
|
|
@@ -954,6 +1057,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
954
1057
|
: existing?.disconfirmation,
|
|
955
1058
|
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
956
1059
|
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
1060
|
+
pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
|
|
1061
|
+
confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
|
|
957
1062
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
958
1063
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
959
1064
|
evidenceItems: existing?.evidenceItems ?? [],
|
|
@@ -1213,8 +1318,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
|
|
|
1213
1318
|
const n = titles.length;
|
|
1214
1319
|
const weights = new Map<string, number>();
|
|
1215
1320
|
for (const [token, docs] of df) {
|
|
1216
|
-
// +1
|
|
1217
|
-
|
|
1321
|
+
// ln((n+1)/(docs+1)) — NO +1 baseline. A token in every title scores ~0
|
|
1322
|
+
// (ln 1), a token in one title scores ln((n+1)/2) > 1 for n >= 3. The old
|
|
1323
|
+
// `1 + ln(...)` made every weight >= 1, so the weighted half of the hybrid
|
|
1324
|
+
// gate was vacuous (weightedSum >= sharedCount always) and generic
|
|
1325
|
+
// vocabulary could never be down-weighted.
|
|
1326
|
+
weights.set(token, Math.log((n + 1) / (docs + 1)));
|
|
1218
1327
|
}
|
|
1219
1328
|
return weights;
|
|
1220
1329
|
}
|
|
@@ -1243,26 +1352,45 @@ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
|
|
|
1243
1352
|
|
|
1244
1353
|
// ── SQLite Mutation Actions ───────────────────────────────────────────
|
|
1245
1354
|
|
|
1355
|
+
function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
1356
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1357
|
+
try {
|
|
1358
|
+
const value = fn();
|
|
1359
|
+
db.exec("COMMIT");
|
|
1360
|
+
return value;
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
try {
|
|
1363
|
+
db.exec("ROLLBACK");
|
|
1364
|
+
} catch {
|
|
1365
|
+
// ignore rollback errors
|
|
1366
|
+
}
|
|
1367
|
+
throw err;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1246
1371
|
function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
1247
1372
|
// Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
|
|
1248
1373
|
// wipe case_links when updating an existing primary key.
|
|
1249
1374
|
const stmt = db.prepare(`
|
|
1250
1375
|
INSERT INTO cases (
|
|
1251
|
-
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
1376
|
+
id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
|
|
1252
1377
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
1253
1378
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
1254
1379
|
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
1380
|
+
pending_confirmation_json, confirmer_verdict_json,
|
|
1255
1381
|
reported_at, report_path, created_at, updated_at
|
|
1256
1382
|
) VALUES (
|
|
1257
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1383
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1258
1384
|
?, ?, ?, ?, ?, ?,
|
|
1259
1385
|
?, ?, ?, ?, ?,
|
|
1260
1386
|
?, ?, ?, ?,
|
|
1387
|
+
?, ?,
|
|
1261
1388
|
?, ?, ?, ?
|
|
1262
1389
|
)
|
|
1263
1390
|
ON CONFLICT(id) DO UPDATE SET
|
|
1264
1391
|
title = excluded.title,
|
|
1265
1392
|
status = excluded.status,
|
|
1393
|
+
ever_advanced = excluded.ever_advanced,
|
|
1266
1394
|
confidence = excluded.confidence,
|
|
1267
1395
|
severity = excluded.severity,
|
|
1268
1396
|
priority = excluded.priority,
|
|
@@ -1284,6 +1412,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1284
1412
|
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
1285
1413
|
disprove_if_json = excluded.disprove_if_json,
|
|
1286
1414
|
control_verified_json = excluded.control_verified_json,
|
|
1415
|
+
pending_confirmation_json = excluded.pending_confirmation_json,
|
|
1416
|
+
confirmer_verdict_json = excluded.confirmer_verdict_json,
|
|
1287
1417
|
reported_at = excluded.reported_at,
|
|
1288
1418
|
report_path = excluded.report_path,
|
|
1289
1419
|
created_at = excluded.created_at,
|
|
@@ -1294,6 +1424,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1294
1424
|
record.id,
|
|
1295
1425
|
record.title,
|
|
1296
1426
|
record.status,
|
|
1427
|
+
record.everAdvanced ? 1 : 0,
|
|
1297
1428
|
record.confidence,
|
|
1298
1429
|
record.severity || null,
|
|
1299
1430
|
record.priority || null,
|
|
@@ -1315,6 +1446,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1315
1446
|
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
1316
1447
|
JSON.stringify(record.disproveIf),
|
|
1317
1448
|
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
1449
|
+
record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
|
|
1450
|
+
record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
|
|
1318
1451
|
record.reportedAt || null,
|
|
1319
1452
|
record.reportPath || null,
|
|
1320
1453
|
record.createdAt,
|
|
@@ -1554,153 +1687,164 @@ export function coverageSummary(caseId: string): CoverageSummary {
|
|
|
1554
1687
|
export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
1555
1688
|
const db = getDb();
|
|
1556
1689
|
validateNewCaseInput(input);
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1690
|
+
return withImmediateTransaction(db, () => {
|
|
1691
|
+
const record = buildRecord(input, undefined);
|
|
1692
|
+
validateCase(record);
|
|
1693
|
+
|
|
1694
|
+
const duplicate = findDuplicateCaseInDb(db, record);
|
|
1695
|
+
if (duplicate) {
|
|
1696
|
+
return {
|
|
1697
|
+
record: duplicate.record,
|
|
1698
|
+
created: false,
|
|
1699
|
+
nearDuplicate: duplicate.near,
|
|
1700
|
+
reason: duplicate.near
|
|
1701
|
+
? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
|
|
1702
|
+
`Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
|
|
1703
|
+
`Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
|
|
1704
|
+
: `Duplicate case exists: ${duplicate.record.id}`,
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1574
1707
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1708
|
+
upsertCase(db, record);
|
|
1709
|
+
return { record, created: true };
|
|
1710
|
+
});
|
|
1577
1711
|
}
|
|
1578
1712
|
|
|
1579
1713
|
export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
|
|
1580
1714
|
const db = getDb();
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1715
|
+
return withImmediateTransaction(db, () => {
|
|
1716
|
+
const current = getCaseById(id);
|
|
1717
|
+
if (!current) {
|
|
1718
|
+
throw new Error(`Case not found: ${id}`);
|
|
1719
|
+
}
|
|
1585
1720
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1721
|
+
// Terminal states: block all mutations (status and field edits). The transition
|
|
1722
|
+
// gate only runs on status changes, so without this reported/killed cases could
|
|
1723
|
+
// still be rewritten via field-only updates.
|
|
1724
|
+
if (current.status === "killed") {
|
|
1725
|
+
throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
|
|
1726
|
+
}
|
|
1727
|
+
if (current.status === "reported") {
|
|
1728
|
+
throw new Error("Cannot mutate a reported case; file a follow-up case instead");
|
|
1729
|
+
}
|
|
1595
1730
|
|
|
1596
|
-
|
|
1597
|
-
// construction is the update itself.
|
|
1598
|
-
let next = buildRecord(update, current);
|
|
1731
|
+
let next = buildRecord(update, current);
|
|
1599
1732
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1733
|
+
if (update.status && update.status !== current.status) {
|
|
1734
|
+
validateTransition(current.status, next.status, update, current);
|
|
1735
|
+
}
|
|
1603
1736
|
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
// Reported cases are immutable, so current.status cannot be reported here.
|
|
1608
|
-
if (next.status === "reported") {
|
|
1609
|
-
next = { ...next, reportedAt: new Date().toISOString() };
|
|
1610
|
-
}
|
|
1737
|
+
if (next.status === "reported") {
|
|
1738
|
+
next = { ...next, reportedAt: new Date().toISOString() };
|
|
1739
|
+
}
|
|
1611
1740
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1741
|
+
if (current.status === "confirmed" && next.status === "investigating") {
|
|
1742
|
+
next = {
|
|
1743
|
+
...next,
|
|
1744
|
+
pocVerified: undefined,
|
|
1745
|
+
disconfirmationVerified: undefined,
|
|
1746
|
+
controlVerified: undefined,
|
|
1747
|
+
confirmerVerdict: undefined,
|
|
1748
|
+
pendingConfirmation: undefined,
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1623
1751
|
|
|
1624
|
-
|
|
1752
|
+
if (current.status === "confirmed" && next.status === "confirmed") {
|
|
1753
|
+
const proofFields = ["target", "poc", "impact", "severity"] as const;
|
|
1754
|
+
const changed = proofFields.filter((field) => current[field] !== next[field]);
|
|
1755
|
+
if (changed.length > 0) {
|
|
1756
|
+
throw new Error(
|
|
1757
|
+
`Confirmed proof-bound field(s) changed: ${changed.join(", ")}. ` +
|
|
1758
|
+
`Demote the case with status: "investigating" in the same update; that clears stale verification records and requires re-promotion.`,
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1625
1762
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
acc
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
? `Case is already ${current.status}; no material fields changed.`
|
|
1655
|
-
: "No material fields changed.";
|
|
1656
|
-
return { record: current, changed: false, reason };
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
const duplicate = findDuplicateCaseInDb(db, next, id);
|
|
1660
|
-
if (duplicate) {
|
|
1661
|
-
return {
|
|
1662
|
-
record: current,
|
|
1663
|
-
changed: false,
|
|
1664
|
-
reason: duplicate.near
|
|
1665
|
-
? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
|
|
1666
|
-
`(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
|
|
1667
|
-
`clearly distinct title if these are genuinely separate findings.`
|
|
1668
|
-
: `Update would create a duplicate of case ${duplicate.record.id}`,
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1763
|
+
validateCase(next);
|
|
1764
|
+
|
|
1765
|
+
const norm = (r: CaseRecord) =>
|
|
1766
|
+
JSON.stringify(
|
|
1767
|
+
Object.keys(r)
|
|
1768
|
+
.sort()
|
|
1769
|
+
.reduce<Record<string, unknown>>((acc, k) => {
|
|
1770
|
+
if (
|
|
1771
|
+
k === "updatedAt" ||
|
|
1772
|
+
k === "createdAt" ||
|
|
1773
|
+
k === "linkedCases" ||
|
|
1774
|
+
k === "evidenceItems" ||
|
|
1775
|
+
k === "coverageItems"
|
|
1776
|
+
) {
|
|
1777
|
+
acc[k] = "";
|
|
1778
|
+
} else {
|
|
1779
|
+
acc[k] = (r as Record<string, unknown>)[k];
|
|
1780
|
+
}
|
|
1781
|
+
return acc;
|
|
1782
|
+
}, {}),
|
|
1783
|
+
);
|
|
1784
|
+
if (norm(current) === norm(next)) {
|
|
1785
|
+
const reason =
|
|
1786
|
+
update.status && update.status === current.status
|
|
1787
|
+
? `Case is already ${current.status}; no material fields changed.`
|
|
1788
|
+
: "No material fields changed.";
|
|
1789
|
+
return { record: current, changed: false, reason };
|
|
1790
|
+
}
|
|
1671
1791
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1792
|
+
const duplicate = findDuplicateCaseInDb(db, next, id);
|
|
1793
|
+
if (duplicate) {
|
|
1794
|
+
return {
|
|
1795
|
+
record: current,
|
|
1796
|
+
changed: false,
|
|
1797
|
+
reason: duplicate.near
|
|
1798
|
+
? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
|
|
1799
|
+
`(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
|
|
1800
|
+
`clearly distinct title if these are genuinely separate findings.`
|
|
1801
|
+
: `Update would create a duplicate of case ${duplicate.record.id}`,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
upsertCase(db, next);
|
|
1806
|
+
return { record: next, changed: true };
|
|
1807
|
+
});
|
|
1674
1808
|
}
|
|
1675
1809
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
}
|
|
1810
|
+
function validateRunEvidence(run: PocEvidenceRun, label: string): void {
|
|
1811
|
+
if (!run.completed) {
|
|
1812
|
+
throw new Error(`${label} did not complete; a crash is not evidence`);
|
|
1813
|
+
}
|
|
1814
|
+
if (!run.outputComplete) {
|
|
1815
|
+
throw new Error(`${label} output capture was incomplete; evidence checks are unsafe`);
|
|
1816
|
+
}
|
|
1817
|
+
if (!run.evidence || !run.evidenceSha256) {
|
|
1818
|
+
throw new Error(
|
|
1819
|
+
`${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
if (!evidenceNonceMatches(run.evidence, run.nonce)) {
|
|
1823
|
+
throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1692
1826
|
|
|
1693
|
-
/**
|
|
1694
|
-
function
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1827
|
+
/** Determinism + differential on normalized evidence (nonce/observations stripped). */
|
|
1828
|
+
function assertEvidenceDifferential(bundle: PendingConfirmation): void {
|
|
1829
|
+
const [r1, r2] = bundle.targetRuns;
|
|
1830
|
+
if (normalizeEvidence(r1.evidence) !== normalizeEvidence(r2.evidence)) {
|
|
1831
|
+
throw new Error(
|
|
1832
|
+
"Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
|
|
1836
|
+
throw new Error(
|
|
1837
|
+
"Control run produced identical evidence to the target — the claimed impact is not target-dependent",
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1697
1840
|
}
|
|
1698
1841
|
|
|
1699
1842
|
/**
|
|
1700
|
-
* Gate for
|
|
1701
|
-
* poc/evidence/impact/severity.
|
|
1702
|
-
*
|
|
1703
|
-
*
|
|
1843
|
+
* Gate for phase 1 of promotion: case must exist, be investigating, and have
|
|
1844
|
+
* poc/evidence/impact/severity/target. The disconfirmation is provided by the
|
|
1845
|
+
* confirmer at confirm time, so it is NOT a precondition here. Returns the
|
|
1846
|
+
* record when promotable, throws otherwise. Exported so PromoteFinding can
|
|
1847
|
+
* validate BEFORE paying for (potentially slow) sandboxed PoC runs.
|
|
1704
1848
|
*/
|
|
1705
1849
|
export function assertPromotable(id: string): CaseRecord {
|
|
1706
1850
|
const current = getCaseById(id);
|
|
@@ -1708,7 +1852,7 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1708
1852
|
throw new Error(`Case not found: ${id}`);
|
|
1709
1853
|
}
|
|
1710
1854
|
if (current.status !== "investigating") {
|
|
1711
|
-
throw new Error(`
|
|
1855
|
+
throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
|
|
1712
1856
|
}
|
|
1713
1857
|
if (!current.poc) {
|
|
1714
1858
|
throw new Error("CONFIRMED requires poc; set poc on the case first");
|
|
@@ -1727,15 +1871,10 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1727
1871
|
"CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
|
|
1728
1872
|
);
|
|
1729
1873
|
}
|
|
1730
|
-
if (!current.disconfirmation) {
|
|
1731
|
-
throw new Error(
|
|
1732
|
-
"CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
|
|
1733
|
-
);
|
|
1734
|
-
}
|
|
1735
1874
|
// Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
|
|
1736
1875
|
// summary-only observation is agent prose about itself — promotion requires
|
|
1737
1876
|
// a real file with its SHA-256 as the initial signal. (The reproduction item
|
|
1738
|
-
// is always artifact-backed: the
|
|
1877
|
+
// is always artifact-backed: the gate writes it from the evidence hash.)
|
|
1739
1878
|
if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
|
|
1740
1879
|
throw new Error(
|
|
1741
1880
|
"Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
|
|
@@ -1746,203 +1885,242 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1746
1885
|
return current;
|
|
1747
1886
|
}
|
|
1748
1887
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
):
|
|
1888
|
+
/**
|
|
1889
|
+
* Phase 1: record the harness-observed evidence bundle on the case. The whole
|
|
1890
|
+
* contract is validated here — same-file control, nonce binding, run
|
|
1891
|
+
* completion, determinism across the two target runs, and the target/control
|
|
1892
|
+
* differential — so a bundle that cannot promote is rejected before the
|
|
1893
|
+
* confirmer is ever dispatched.
|
|
1894
|
+
*/
|
|
1895
|
+
export function storePendingConfirmation(id: string, bundle: PendingConfirmation): CaseRecord {
|
|
1757
1896
|
const db = getDb();
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
throw new Error(
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
pocHash
|
|
1804
|
-
controlHash
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
`PoC that COMPLETED (completed: true) and whose output does not contain the marker "${verificationMarker}"` +
|
|
1826
|
-
` and whose output DOES contain the control liveness marker "${liveness}"` +
|
|
1827
|
-
" (the control must actually reach its target — a failed/early control is not a clean verdict)" +
|
|
1828
|
-
". PromoteFinding requires control_path + control_liveness_marker.",
|
|
1829
|
-
);
|
|
1830
|
-
}
|
|
1897
|
+
return withImmediateTransaction(db, () => {
|
|
1898
|
+
const current = getCaseById(id);
|
|
1899
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
1900
|
+
if (current.status !== "investigating") {
|
|
1901
|
+
throw new Error(
|
|
1902
|
+
`Pending confirmation requires an investigating case (current: ${current.status})`,
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
|
|
1906
|
+
if (bundle.targetRuns.length !== 2 || !bundle.controlRun) {
|
|
1907
|
+
throw new Error("Pending confirmation requires two target runs and one control run");
|
|
1908
|
+
}
|
|
1909
|
+
if (!bundle.pocPath || !bundle.controlPath || !bundle.controlTarget) {
|
|
1910
|
+
throw new Error("Pending confirmation requires pocPath, controlPath, and controlTarget");
|
|
1911
|
+
}
|
|
1912
|
+
// Control-target binding (machine-verified here, not just in the tool
|
|
1913
|
+
// layer): the control run must actually have targeted the declared
|
|
1914
|
+
// control_target, that target must differ from the target runs' target,
|
|
1915
|
+
// and the control target must differ from the case's target — otherwise
|
|
1916
|
+
// "the control demonstrated nothing on the vulnerable target" passes.
|
|
1917
|
+
const targetRunTarget = bundle.targetRuns[0]?.target;
|
|
1918
|
+
if (!targetRunTarget || bundle.targetRuns.some((r) => r.target !== targetRunTarget)) {
|
|
1919
|
+
throw new Error(
|
|
1920
|
+
"Pending confirmation requires both target runs against the same case target",
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
if (!bundle.controlRun.target || bundle.controlRun.target !== bundle.controlTarget) {
|
|
1924
|
+
throw new Error(
|
|
1925
|
+
"CONTROL BINDING FAILED: controlRun.target must equal control_target — a control run " +
|
|
1926
|
+
"against a different host than the one declared proves nothing.",
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
if (bundle.controlRun.target === targetRunTarget) {
|
|
1930
|
+
throw new Error(
|
|
1931
|
+
"CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
|
|
1932
|
+
"the claimed impact is not target-dependent.",
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
if (bundle.controlTarget === current.target) {
|
|
1936
|
+
throw new Error(
|
|
1937
|
+
"CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
|
|
1938
|
+
"against the vulnerable target proves nothing.",
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
// Same-file contract re-checked at store time (the tool already checked).
|
|
1942
|
+
let pocHash: string | undefined;
|
|
1943
|
+
let controlHash: string | undefined;
|
|
1944
|
+
try {
|
|
1945
|
+
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
1946
|
+
controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
|
|
1947
|
+
} catch {
|
|
1948
|
+
pocHash = undefined;
|
|
1949
|
+
controlHash = undefined;
|
|
1950
|
+
}
|
|
1951
|
+
if (!pocHash || !controlHash || pocHash !== controlHash) {
|
|
1952
|
+
throw new Error(
|
|
1953
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
|
|
1954
|
+
"(sha256 mismatch). A separately written control file proves nothing.",
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
if (bundle.pocSha256 && bundle.pocSha256 !== pocHash) {
|
|
1958
|
+
throw new Error("pocSha256 does not match the PoC file on disk");
|
|
1959
|
+
}
|
|
1960
|
+
for (const run of [...bundle.targetRuns, bundle.controlRun]) {
|
|
1961
|
+
validateRunEvidence(run, `${run.mode} run`);
|
|
1962
|
+
}
|
|
1963
|
+
assertEvidenceDifferential(bundle);
|
|
1831
1964
|
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
`PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
|
|
1839
|
-
"exit 0 alone cannot promote to confirmed",
|
|
1840
|
-
);
|
|
1841
|
-
}
|
|
1965
|
+
const next = buildRecord({ pendingConfirmation: bundle }, current);
|
|
1966
|
+
validateCase(next);
|
|
1967
|
+
upsertCase(db, next);
|
|
1968
|
+
return next;
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1842
1971
|
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1972
|
+
/**
|
|
1973
|
+
* Phase 2: commit (or refuse) the promotion on a confirmer verdict.
|
|
1974
|
+
*
|
|
1975
|
+
* CONFIRMED requires the full bundle to still hold (completion, nonce,
|
|
1976
|
+
* determinism, differential), the PoC script to be unchanged since the runs
|
|
1977
|
+
* (pocSha256 — otherwise the confirmer reviewed different bytes), and a
|
|
1978
|
+
* verdict that re-executed the verify request with a target-only differential
|
|
1979
|
+
* and a disconfirmation attempt. NOT_CONFIRMED records the verdict and keeps
|
|
1980
|
+
* the case investigating — no tie-breaker.
|
|
1981
|
+
*/
|
|
1982
|
+
export function applyConfirmationResult(
|
|
1983
|
+
id: string,
|
|
1984
|
+
verdictInput: ConfirmerVerdict,
|
|
1985
|
+
): CaseUpdateResult {
|
|
1986
|
+
const db = getDb();
|
|
1987
|
+
return withImmediateTransaction(db, () => {
|
|
1988
|
+
const current = getCaseById(id);
|
|
1989
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
1990
|
+
if (current.status !== "investigating") {
|
|
1991
|
+
throw new Error(`ConfirmFinding requires an investigating case (current: ${current.status})`);
|
|
1992
|
+
}
|
|
1993
|
+
const bundle = current.pendingConfirmation;
|
|
1994
|
+
if (!bundle) {
|
|
1995
|
+
throw new Error("No pending confirmation on this case — run PromoteFinding first");
|
|
1996
|
+
}
|
|
1997
|
+
// Fail closed on an unparseable ranAt: Date.parse(garbage) is NaN, and
|
|
1998
|
+
// NaN > TTL is false — a malformed timestamp must NOT make the bundle
|
|
1999
|
+
// immortal. Treat it as expired (re-run PromoteFinding for a fresh one).
|
|
2000
|
+
const ranAtMs = Date.parse(bundle.ranAt);
|
|
2001
|
+
if (!Number.isFinite(ranAtMs) || Date.now() - ranAtMs > PENDING_CONFIRM_TTL_MS) {
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
"Pending confirmation expired or has an invalid timestamp (1h TTL) — re-run PromoteFinding for a fresh bundle",
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
const parsed = validateConfirmerVerdict(verdictInput);
|
|
2007
|
+
if (!parsed.ok) throw new Error(`Invalid confirmer verdict: ${parsed.error}`);
|
|
2008
|
+
const verdict = parsed.verdict;
|
|
2009
|
+
const recorded: ConfirmerVerdictRecord = { ...verdict, at: new Date().toISOString() };
|
|
2010
|
+
|
|
2011
|
+
if (verdict.verdict === "NOT_CONFIRMED") {
|
|
2012
|
+
const note = `confirmer NOT_CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}: ${verdict.reasoning}`;
|
|
2013
|
+
const next = buildRecord(
|
|
2014
|
+
{ confirmerVerdict: recorded, assumptions: [...(current.assumptions ?? []), note] },
|
|
2015
|
+
current,
|
|
2016
|
+
);
|
|
2017
|
+
validateCase(next);
|
|
2018
|
+
upsertCase(db, next);
|
|
2019
|
+
return { record: next, changed: true };
|
|
2020
|
+
}
|
|
1857
2021
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
// observation recorded after the fact is not the initial signal). The
|
|
1872
|
-
// irreducible residual — a model writing a fake capture file seconds before
|
|
1873
|
-
// EvidenceAdd — is documented, not machine-checkable.
|
|
1874
|
-
const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
|
|
1875
|
-
if (observation) {
|
|
1876
|
-
if (pocSha256 && observation.sha256 === pocSha256) {
|
|
2022
|
+
// CONFIRMED — re-validate the whole bundle (defense in depth; the case may
|
|
2023
|
+
// have been touched between phase 1 and the verdict).
|
|
2024
|
+
for (const run of [...bundle.targetRuns, bundle.controlRun]) {
|
|
2025
|
+
validateRunEvidence(run, `${run.mode} run`);
|
|
2026
|
+
}
|
|
2027
|
+
assertEvidenceDifferential(bundle);
|
|
2028
|
+
let pocHash: string | undefined;
|
|
2029
|
+
try {
|
|
2030
|
+
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
2031
|
+
} catch {
|
|
2032
|
+
pocHash = undefined;
|
|
2033
|
+
}
|
|
2034
|
+
if (!pocHash || pocHash !== bundle.pocSha256) {
|
|
1877
2035
|
throw new Error(
|
|
1878
|
-
"
|
|
1879
|
-
"(identical sha256). The initial signal must be a separate captured artifact.",
|
|
2036
|
+
"PoC script changed since the runs — re-run PromoteFinding (the confirmer must review the exact bytes that ran)",
|
|
1880
2037
|
);
|
|
1881
2038
|
}
|
|
1882
|
-
|
|
2039
|
+
// The case target must still be the host the PoC ran against, and still
|
|
2040
|
+
// differ from the control target. The evidence proves nothing about a
|
|
2041
|
+
// target the case adopted after the runs.
|
|
2042
|
+
const targetRun = bundle.targetRuns[0];
|
|
2043
|
+
if (!current.target || current.target !== targetRun.target) {
|
|
1883
2044
|
throw new Error(
|
|
1884
|
-
"
|
|
1885
|
-
|
|
2045
|
+
"Case target changed since the PoC runs — re-run PromoteFinding against the current target " +
|
|
2046
|
+
`(bundle target: ${targetRun.target}, case target: ${current.target ?? "(none)"}).`,
|
|
1886
2047
|
);
|
|
1887
2048
|
}
|
|
1888
|
-
if (
|
|
2049
|
+
if (current.target === bundle.controlTarget) {
|
|
2050
|
+
throw new Error(
|
|
2051
|
+
"Case target now equals the control target — the claimed impact is not target-dependent; " +
|
|
2052
|
+
"re-run PromoteFinding with a distinct control_target.",
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// The observation must predate the repro (provenance guard).
|
|
2057
|
+
const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
|
|
2058
|
+
if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
|
|
1889
2059
|
throw new Error(
|
|
1890
2060
|
"Evidence chain invalid: the observation item was recorded after the PoC ran " +
|
|
1891
|
-
`(${observation.createdAt} > ${
|
|
2061
|
+
`(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
|
|
1892
2062
|
);
|
|
1893
2063
|
}
|
|
1894
|
-
}
|
|
1895
|
-
const reproductionItem: EvidenceItem = {
|
|
1896
|
-
id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
|
|
1897
|
-
caseId: id,
|
|
1898
|
-
role: "reproduction",
|
|
1899
|
-
artifactPath: basename(verification.path),
|
|
1900
|
-
sha256: pocSha256,
|
|
1901
|
-
summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
|
|
1902
|
-
createdAt: verification.ranAt,
|
|
1903
|
-
};
|
|
1904
2064
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
if (disconfirmationVerification) {
|
|
1918
|
-
update.disconfirmationVerified = stripRaw(disconfirmationVerification);
|
|
1919
|
-
}
|
|
1920
|
-
if (controlVerification) {
|
|
1921
|
-
update.controlVerified = stripRaw(controlVerification);
|
|
1922
|
-
}
|
|
2065
|
+
const reproductionItem: EvidenceItem = {
|
|
2066
|
+
id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
|
|
2067
|
+
caseId: id,
|
|
2068
|
+
role: "reproduction",
|
|
2069
|
+
// The runner preserves each run's evidence.json in a durable dir
|
|
2070
|
+
// (.pi/poc-evidence/) — the artifact the hash was computed over still
|
|
2071
|
+
// exists, so the item stays artifact-backed and re-verifiable.
|
|
2072
|
+
artifactPath: targetRun.evidencePath ? basename(targetRun.evidencePath) : "evidence.json",
|
|
2073
|
+
sha256: targetRun.evidenceSha256,
|
|
2074
|
+
summary: `PoC evidence verified (2 target runs + control) — confirmer CONFIRMED${verdict.model ? ` (${verdict.model})` : ""}`,
|
|
2075
|
+
createdAt: targetRun.ranAt,
|
|
2076
|
+
};
|
|
1923
2077
|
|
|
1924
|
-
|
|
1925
|
-
|
|
2078
|
+
const newEvidence =
|
|
2079
|
+
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
2080
|
+
`### PoC Execution Capture (${targetRun.ranAt})\n` +
|
|
2081
|
+
`- **Evidence sha256:** ${targetRun.evidenceSha256}\n` +
|
|
2082
|
+
`- **Target:** ${targetRun.target}\n` +
|
|
2083
|
+
`- **Confirmer:** ${verdict.model ?? "unknown model"} — CONFIRMED\n` +
|
|
2084
|
+
`#### Target Run Output\n\`\`\`\n${targetRun.output ?? ""}\n\`\`\``;
|
|
2085
|
+
|
|
2086
|
+
const update: NormalizedCaseInput = {
|
|
2087
|
+
status: "confirmed",
|
|
2088
|
+
pocVerified: {
|
|
2089
|
+
path: bundle.pocPath,
|
|
2090
|
+
exitCode: targetRun.exitCode,
|
|
2091
|
+
ranAt: targetRun.ranAt,
|
|
2092
|
+
output: targetRun.output,
|
|
2093
|
+
sandbox: targetRun.sandbox,
|
|
2094
|
+
completed: true,
|
|
2095
|
+
outputComplete: true,
|
|
2096
|
+
mode: "poc",
|
|
2097
|
+
target: targetRun.target,
|
|
2098
|
+
},
|
|
2099
|
+
controlVerified: {
|
|
2100
|
+
path: bundle.controlPath,
|
|
2101
|
+
exitCode: bundle.controlRun.exitCode,
|
|
2102
|
+
ranAt: bundle.controlRun.ranAt,
|
|
2103
|
+
output: bundle.controlRun.output,
|
|
2104
|
+
sandbox: bundle.controlRun.sandbox,
|
|
2105
|
+
completed: true,
|
|
2106
|
+
outputComplete: true,
|
|
2107
|
+
mode: "control",
|
|
2108
|
+
target: bundle.controlRun.target,
|
|
2109
|
+
},
|
|
2110
|
+
disconfirmation: verdict.disconfirmation_attempt,
|
|
2111
|
+
confirmerVerdict: recorded,
|
|
2112
|
+
pendingConfirmation: undefined,
|
|
2113
|
+
evidence: newEvidence,
|
|
2114
|
+
};
|
|
1926
2115
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
db.exec("BEGIN");
|
|
1931
|
-
try {
|
|
2116
|
+
const next = buildRecord(update, current);
|
|
2117
|
+
next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
|
|
2118
|
+
validateCase(next);
|
|
1932
2119
|
insertEvidenceItem(db, reproductionItem);
|
|
1933
2120
|
upsertCase(db, next);
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
db.exec("ROLLBACK");
|
|
1938
|
-
} catch {
|
|
1939
|
-
// ignore
|
|
1940
|
-
}
|
|
1941
|
-
throw err;
|
|
1942
|
-
}
|
|
1943
|
-
// Attach to the record being returned (current was fetched pre-insert).
|
|
1944
|
-
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
1945
|
-
return { record: next, changed: true };
|
|
2121
|
+
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
2122
|
+
return { record: next, changed: true };
|
|
2123
|
+
});
|
|
1946
2124
|
}
|
|
1947
2125
|
|
|
1948
2126
|
// ── Chain suggestions ───────────────────────────────────────────────
|
|
@@ -2345,12 +2523,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
2345
2523
|
|
|
2346
2524
|
const query = options.query?.trim().toLowerCase();
|
|
2347
2525
|
if (query) {
|
|
2348
|
-
|
|
2526
|
+
// Escape LIKE wildcards so a query containing % or _ matches literally
|
|
2527
|
+
// instead of acting as a pattern ("100%" must not match "1000"). The
|
|
2528
|
+
// backslash is the escape char, so it is escaped first.
|
|
2529
|
+
const escaped = query.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
2530
|
+
const likeParam = `%${escaped}%`;
|
|
2349
2531
|
if (options.field) {
|
|
2350
|
-
where.push(`lower(${options.field}) LIKE
|
|
2532
|
+
where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
|
|
2351
2533
|
params.push(likeParam);
|
|
2352
2534
|
} else {
|
|
2353
|
-
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE
|
|
2535
|
+
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ? ESCAPE '\\'`).join(" OR ");
|
|
2354
2536
|
where.push(`(${ors})`);
|
|
2355
2537
|
for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
|
|
2356
2538
|
}
|
|
@@ -2390,8 +2572,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
|
|
|
2390
2572
|
total: number;
|
|
2391
2573
|
} {
|
|
2392
2574
|
const db = getDb();
|
|
2393
|
-
|
|
2394
|
-
|
|
2575
|
+
// NaN is not clamped by Math.min/max (it passes through) and SQLite binds it
|
|
2576
|
+
// as NULL, which disables LIMIT — fall back to the default instead.
|
|
2577
|
+
const rawLimit = Number.isFinite(options.limit) ? options.limit : undefined;
|
|
2578
|
+
const rawOffset = Number.isFinite(options.offset) ? options.offset : undefined;
|
|
2579
|
+
const limit = Math.max(1, Math.min(rawLimit ?? 50, 200));
|
|
2580
|
+
const offset = Math.max(0, rawOffset ?? 0);
|
|
2395
2581
|
|
|
2396
2582
|
const { whereSql, orderSql, params } = buildCaseWhere(options);
|
|
2397
2583
|
|
|
@@ -2481,7 +2667,10 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
2481
2667
|
} else if (Array.isArray(val)) {
|
|
2482
2668
|
display = val.join(", ");
|
|
2483
2669
|
} else if (typeof val === "object") {
|
|
2484
|
-
|
|
2670
|
+
// Path-leak guard (consistent with buildCompleteRecord): verification
|
|
2671
|
+
// objects and the pending bundle carry local script/evidence paths —
|
|
2672
|
+
// show basenames only.
|
|
2673
|
+
display = JSON.stringify(redactPaths(val));
|
|
2485
2674
|
} else {
|
|
2486
2675
|
display = String(val);
|
|
2487
2676
|
}
|
|
@@ -2513,25 +2702,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
|
|
|
2513
2702
|
* must not balloon the report context into megabytes. */
|
|
2514
2703
|
const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
|
|
2515
2704
|
|
|
2705
|
+
/**
|
|
2706
|
+
* Recursively redact local filesystem paths to basenames in a serialized
|
|
2707
|
+
* object. Covers the verification records (path), the pending confirmation
|
|
2708
|
+
* bundle (pocPath/controlPath) and preserved evidence copies (evidencePath) —
|
|
2709
|
+
* the context bundle must never leak the researcher's local paths.
|
|
2710
|
+
*/
|
|
2711
|
+
function redactPaths(value: unknown, seen = new Set<object>()): unknown {
|
|
2712
|
+
if (Array.isArray(value)) return value.map((v) => redactPaths(v, seen));
|
|
2713
|
+
if (typeof value !== "object" || value === null) return value;
|
|
2714
|
+
if (seen.has(value)) return value;
|
|
2715
|
+
seen.add(value);
|
|
2716
|
+
const out: Record<string, unknown> = {};
|
|
2717
|
+
for (const [k, v] of Object.entries(value)) {
|
|
2718
|
+
if (
|
|
2719
|
+
typeof v === "string" &&
|
|
2720
|
+
(k === "path" || k === "pocPath" || k === "controlPath" || k === "evidencePath")
|
|
2721
|
+
) {
|
|
2722
|
+
out[k] = basename(v) || v;
|
|
2723
|
+
} else {
|
|
2724
|
+
out[k] = redactPaths(v, seen);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return out;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2516
2730
|
function buildCompleteRecord(current: CaseRecord): string {
|
|
2517
2731
|
const rows: string[] = [];
|
|
2518
2732
|
for (const [k, v] of Object.entries(current)) {
|
|
2519
2733
|
if (v === undefined || v === null || v === "") continue;
|
|
2520
|
-
|
|
2521
|
-
//
|
|
2522
|
-
//
|
|
2523
|
-
|
|
2524
|
-
if (
|
|
2525
|
-
(k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
|
|
2526
|
-
v &&
|
|
2527
|
-
typeof v === "object"
|
|
2528
|
-
) {
|
|
2529
|
-
const redacted = {
|
|
2530
|
-
...(v as Record<string, unknown>),
|
|
2531
|
-
path: basename((v as { path?: string }).path ?? ""),
|
|
2532
|
-
};
|
|
2533
|
-
display = JSON.stringify(redacted, null, 2);
|
|
2534
|
-
}
|
|
2734
|
+
// Path-leak guard: verification objects + the pending bundle carry the
|
|
2735
|
+
// researcher's local PoC/disconfirmation/control/evidence paths — show
|
|
2736
|
+
// basenames only (the dedicated log sections do the same).
|
|
2737
|
+
const display = typeof v === "object" ? JSON.stringify(redactPaths(v), null, 2) : String(v);
|
|
2535
2738
|
rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
|
|
2536
2739
|
}
|
|
2537
2740
|
return rows.join("\n");
|
|
@@ -2566,19 +2769,11 @@ function buildCaseLinks(db: DatabaseSync, id: string): string {
|
|
|
2566
2769
|
function buildScratchpadSection(caseId: string): string {
|
|
2567
2770
|
const root = getScratchpadRoot();
|
|
2568
2771
|
if (!existsSync(root)) return "No scratchpad found (no pipeline run artifacts recorded).";
|
|
2569
|
-
let entries: Dirent[] = [];
|
|
2570
|
-
try {
|
|
2571
|
-
entries = readdirSync(root, { withFileTypes: true });
|
|
2572
|
-
} catch {
|
|
2573
|
-
return "Scratchpad root unreadable.";
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
2772
|
const sections: string[] = [];
|
|
2577
2773
|
let totalChars = 0;
|
|
2578
2774
|
let totalCapped = false;
|
|
2579
|
-
outer: for (const
|
|
2580
|
-
|
|
2581
|
-
const resume = scratchpad_resume(entry.name);
|
|
2775
|
+
outer: for (const runId of scratchpad_runs()) {
|
|
2776
|
+
const resume = scratchpad_resume(runId);
|
|
2582
2777
|
if (!resume) continue;
|
|
2583
2778
|
const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
|
|
2584
2779
|
// Gate on the case id appearing in phase_ids OR in any artifact filename —
|
|
@@ -2589,7 +2784,7 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2589
2784
|
.some((n) => n.includes(caseId));
|
|
2590
2785
|
if (!allIds.includes(caseId) && !namedInArtifact) continue;
|
|
2591
2786
|
|
|
2592
|
-
sections.push(`### Run: ${
|
|
2787
|
+
sections.push(`### Run: ${runId} (project root: ${resume.checkpoint.project_root})`);
|
|
2593
2788
|
for (const phase of PHASE_ORDER) {
|
|
2594
2789
|
const names = resume.artifacts[phase];
|
|
2595
2790
|
if (!names?.length) continue;
|
|
@@ -2599,7 +2794,7 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2599
2794
|
totalCapped = true;
|
|
2600
2795
|
break outer;
|
|
2601
2796
|
}
|
|
2602
|
-
const content = scratchpad_read(
|
|
2797
|
+
const content = scratchpad_read(runId, phase, name) ?? "(unreadable)";
|
|
2603
2798
|
const clipped =
|
|
2604
2799
|
content.length > MAX_ARTIFACT_CHARS
|
|
2605
2800
|
? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
|
|
@@ -2695,13 +2890,13 @@ export function writeCaseContext(id: string): {
|
|
|
2695
2890
|
current.pocVerified
|
|
2696
2891
|
? mdSection(
|
|
2697
2892
|
"PoC Verification Log",
|
|
2698
|
-
`### 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\`\`\``,
|
|
2893
|
+
`### 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\`\`\``,
|
|
2699
2894
|
)
|
|
2700
2895
|
: undefined,
|
|
2701
2896
|
current.controlVerified
|
|
2702
2897
|
? mdSection(
|
|
2703
2898
|
"Control-Target Check (anti-cheat)",
|
|
2704
|
-
`### 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- **
|
|
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 codes and output markers are diagnostics, not gates; the machine floor is the evidence differential + the confirmer's re-execution.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
|
|
2705
2900
|
)
|
|
2706
2901
|
: undefined,
|
|
2707
2902
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|