@xaccefy/pi-casefile 0.8.3 → 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 +1 -1
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +274 -215
- package/src/ledger.ts +393 -205
- package/src/pipeline-submit.ts +68 -13
- package/src/poc-runner.ts +167 -63
- package/src/scratchpad.ts +28 -4
- package/src/workflow.ts +79 -16
package/src/ledger.ts
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
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";
|
|
16
23
|
import {
|
|
17
24
|
findWorkspaceRoot,
|
|
18
25
|
getScratchpadRoot,
|
|
@@ -157,6 +164,13 @@ export type CaseRecord = {
|
|
|
157
164
|
id: string;
|
|
158
165
|
title: string;
|
|
159
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;
|
|
160
174
|
confidence: CaseConfidence;
|
|
161
175
|
severity?: CaseSeverity;
|
|
162
176
|
priority?: CasePriority;
|
|
@@ -182,8 +196,12 @@ export type CaseRecord = {
|
|
|
182
196
|
pocVerified?: PocVerificationRecord;
|
|
183
197
|
/** Verification of a disconfirmation run (set only by promoteFindingResult). */
|
|
184
198
|
disconfirmationVerified?: PocVerificationRecord;
|
|
185
|
-
/** Verification of a control-target run (set only by
|
|
199
|
+
/** Verification of a control-target run (set only by the confirmation gate). */
|
|
186
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;
|
|
187
205
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
188
206
|
reportedAt?: string;
|
|
189
207
|
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
@@ -210,6 +228,49 @@ export type PocVerificationRecord = {
|
|
|
210
228
|
target?: string;
|
|
211
229
|
};
|
|
212
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
|
+
|
|
213
274
|
export type CaseInput = {
|
|
214
275
|
title: string;
|
|
215
276
|
status?: CaseStatus;
|
|
@@ -239,6 +300,8 @@ type NormalizedCaseInput = Partial<CaseInput> & {
|
|
|
239
300
|
pocVerified?: CaseRecord["pocVerified"];
|
|
240
301
|
disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
|
|
241
302
|
controlVerified?: CaseRecord["controlVerified"];
|
|
303
|
+
pendingConfirmation?: CaseRecord["pendingConfirmation"];
|
|
304
|
+
confirmerVerdict?: CaseRecord["confirmerVerdict"];
|
|
242
305
|
reportedAt?: string;
|
|
243
306
|
reportPath?: string;
|
|
244
307
|
};
|
|
@@ -371,6 +434,7 @@ function getDb(): DatabaseSync {
|
|
|
371
434
|
id TEXT PRIMARY KEY,
|
|
372
435
|
title TEXT NOT NULL,
|
|
373
436
|
status TEXT NOT NULL,
|
|
437
|
+
ever_advanced INTEGER NOT NULL DEFAULT 0,
|
|
374
438
|
confidence TEXT NOT NULL,
|
|
375
439
|
severity TEXT,
|
|
376
440
|
priority TEXT,
|
|
@@ -390,6 +454,8 @@ function getDb(): DatabaseSync {
|
|
|
390
454
|
poc_verified_json TEXT, -- JSON object
|
|
391
455
|
disconfirmation TEXT,
|
|
392
456
|
disconfirmation_verified_json TEXT, -- JSON object
|
|
457
|
+
pending_confirmation_json TEXT, -- JSON object
|
|
458
|
+
confirmer_verdict_json TEXT, -- JSON object
|
|
393
459
|
reported_at TEXT,
|
|
394
460
|
report_path TEXT,
|
|
395
461
|
created_at TEXT NOT NULL,
|
|
@@ -428,6 +494,21 @@ function getDb(): DatabaseSync {
|
|
|
428
494
|
if (!caseCols.some((c) => c.name === "control_verified_json")) {
|
|
429
495
|
db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
|
|
430
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
|
+
}
|
|
431
512
|
|
|
432
513
|
// Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
|
|
433
514
|
db.exec(`
|
|
@@ -507,6 +588,7 @@ function mapRow(
|
|
|
507
588
|
id: row.id,
|
|
508
589
|
title: row.title,
|
|
509
590
|
status: row.status as CaseStatus,
|
|
591
|
+
everAdvanced: row.ever_advanced === 1,
|
|
510
592
|
confidence: row.confidence as CaseConfidence,
|
|
511
593
|
severity: row.severity as CaseSeverity | undefined,
|
|
512
594
|
priority: row.priority as CasePriority | undefined,
|
|
@@ -528,6 +610,8 @@ function mapRow(
|
|
|
528
610
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
529
611
|
disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
|
|
530
612
|
controlVerified: safeParseObject(row.control_verified_json),
|
|
613
|
+
pendingConfirmation: safeParseObject(row.pending_confirmation_json),
|
|
614
|
+
confirmerVerdict: safeParseObject(row.confirmer_verdict_json),
|
|
531
615
|
reportedAt: row.reported_at || undefined,
|
|
532
616
|
reportPath: row.report_path || undefined,
|
|
533
617
|
evidenceItems,
|
|
@@ -673,16 +757,20 @@ function validateCase(record: CaseRecord): void {
|
|
|
673
757
|
);
|
|
674
758
|
}
|
|
675
759
|
// Keep this gate in lockstep with promoteFindingResult: a case may only be
|
|
676
|
-
// 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).
|
|
677
762
|
if (
|
|
678
763
|
record.status === "confirmed" &&
|
|
679
764
|
(!record.evidence ||
|
|
680
765
|
!record.poc ||
|
|
681
766
|
!record.impact ||
|
|
682
767
|
!record.severity ||
|
|
768
|
+
!record.target ||
|
|
683
769
|
!record.disconfirmation)
|
|
684
770
|
) {
|
|
685
|
-
throw new Error(
|
|
771
|
+
throw new Error(
|
|
772
|
+
"Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
|
|
773
|
+
);
|
|
686
774
|
}
|
|
687
775
|
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
688
776
|
throw new Error("Blocked cases require at least one blocker");
|
|
@@ -788,7 +876,16 @@ export const KILL_REASON_VALUES = [
|
|
|
788
876
|
] as const;
|
|
789
877
|
export type KillReason = (typeof KILL_REASON_VALUES)[number];
|
|
790
878
|
|
|
791
|
-
|
|
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
|
+
);
|
|
792
889
|
|
|
793
890
|
function validateTransition(
|
|
794
891
|
from: CaseStatus,
|
|
@@ -813,26 +910,32 @@ function validateTransition(
|
|
|
813
910
|
|
|
814
911
|
if (to === "killed") {
|
|
815
912
|
// Black-cat rule: a kill must be justified. Valid iff (a) a refutation
|
|
816
|
-
// evidence item exists for this case, or (b) — only for
|
|
817
|
-
//
|
|
818
|
-
// vocabulary (matches workflow.ts). Once a case
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
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).
|
|
822
920
|
const items = current ? listEvidenceItems(current.id) : [];
|
|
823
921
|
if (!items.some((e) => e.role === "refutation" && e.sha256)) {
|
|
824
|
-
const advanced = current?.
|
|
825
|
-
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
|
+
]
|
|
826
929
|
.filter(Boolean)
|
|
827
930
|
.join(" ");
|
|
828
931
|
if (advanced || !KILL_REASON_PATTERN.test(text)) {
|
|
829
932
|
throw new Error(
|
|
830
933
|
advanced
|
|
831
|
-
? "Cannot kill an investigating/confirmed
|
|
934
|
+
? "Cannot kill an advanced case (ever reached investigating/confirmed) without ARTIFACT-BACKED refutation evidence: add " +
|
|
832
935
|
"EvidenceAdd role=refutation with artifact_path (sha256 required — the disprove attempt " +
|
|
833
936
|
"that ended this lead) before killing."
|
|
834
937
|
: "Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation, " +
|
|
835
|
-
"artifact_path recommended) or state a kill reason in assumptions/nextStep " +
|
|
938
|
+
"artifact_path recommended) or state a kill reason in assumptions/nextStep/blockers " +
|
|
836
939
|
"(intended_behavior, duplicate, framework_protection, out_of_scope, " +
|
|
837
940
|
"skeptic-disproven, no_attack_path, ...)",
|
|
838
941
|
);
|
|
@@ -923,6 +1026,12 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
923
1026
|
id,
|
|
924
1027
|
title,
|
|
925
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",
|
|
926
1035
|
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
927
1036
|
severity: input.severity ?? existing?.severity,
|
|
928
1037
|
priority: input.priority ?? existing?.priority,
|
|
@@ -948,6 +1057,8 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
948
1057
|
: existing?.disconfirmation,
|
|
949
1058
|
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
950
1059
|
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
1060
|
+
pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
|
|
1061
|
+
confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
|
|
951
1062
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
952
1063
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
953
1064
|
evidenceItems: existing?.evidenceItems ?? [],
|
|
@@ -1207,8 +1318,12 @@ function titleTokenRarityWeights(titles: string[]): Map<string, number> {
|
|
|
1207
1318
|
const n = titles.length;
|
|
1208
1319
|
const weights = new Map<string, number>();
|
|
1209
1320
|
for (const [token, docs] of df) {
|
|
1210
|
-
// +1
|
|
1211
|
-
|
|
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)));
|
|
1212
1327
|
}
|
|
1213
1328
|
return weights;
|
|
1214
1329
|
}
|
|
@@ -1258,21 +1373,24 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1258
1373
|
// wipe case_links when updating an existing primary key.
|
|
1259
1374
|
const stmt = db.prepare(`
|
|
1260
1375
|
INSERT INTO cases (
|
|
1261
|
-
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
1376
|
+
id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
|
|
1262
1377
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
1263
1378
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
1264
1379
|
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
1380
|
+
pending_confirmation_json, confirmer_verdict_json,
|
|
1265
1381
|
reported_at, report_path, created_at, updated_at
|
|
1266
1382
|
) VALUES (
|
|
1267
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1383
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1268
1384
|
?, ?, ?, ?, ?, ?,
|
|
1269
1385
|
?, ?, ?, ?, ?,
|
|
1270
1386
|
?, ?, ?, ?,
|
|
1387
|
+
?, ?,
|
|
1271
1388
|
?, ?, ?, ?
|
|
1272
1389
|
)
|
|
1273
1390
|
ON CONFLICT(id) DO UPDATE SET
|
|
1274
1391
|
title = excluded.title,
|
|
1275
1392
|
status = excluded.status,
|
|
1393
|
+
ever_advanced = excluded.ever_advanced,
|
|
1276
1394
|
confidence = excluded.confidence,
|
|
1277
1395
|
severity = excluded.severity,
|
|
1278
1396
|
priority = excluded.priority,
|
|
@@ -1294,6 +1412,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1294
1412
|
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
1295
1413
|
disprove_if_json = excluded.disprove_if_json,
|
|
1296
1414
|
control_verified_json = excluded.control_verified_json,
|
|
1415
|
+
pending_confirmation_json = excluded.pending_confirmation_json,
|
|
1416
|
+
confirmer_verdict_json = excluded.confirmer_verdict_json,
|
|
1297
1417
|
reported_at = excluded.reported_at,
|
|
1298
1418
|
report_path = excluded.report_path,
|
|
1299
1419
|
created_at = excluded.created_at,
|
|
@@ -1304,6 +1424,7 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1304
1424
|
record.id,
|
|
1305
1425
|
record.title,
|
|
1306
1426
|
record.status,
|
|
1427
|
+
record.everAdvanced ? 1 : 0,
|
|
1307
1428
|
record.confidence,
|
|
1308
1429
|
record.severity || null,
|
|
1309
1430
|
record.priority || null,
|
|
@@ -1325,6 +1446,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
1325
1446
|
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
1326
1447
|
JSON.stringify(record.disproveIf),
|
|
1327
1448
|
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
1449
|
+
record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
|
|
1450
|
+
record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
|
|
1328
1451
|
record.reportedAt || null,
|
|
1329
1452
|
record.reportPath || null,
|
|
1330
1453
|
record.createdAt,
|
|
@@ -1621,6 +1744,8 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
1621
1744
|
pocVerified: undefined,
|
|
1622
1745
|
disconfirmationVerified: undefined,
|
|
1623
1746
|
controlVerified: undefined,
|
|
1747
|
+
confirmerVerdict: undefined,
|
|
1748
|
+
pendingConfirmation: undefined,
|
|
1624
1749
|
};
|
|
1625
1750
|
}
|
|
1626
1751
|
|
|
@@ -1682,67 +1807,44 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
1682
1807
|
});
|
|
1683
1808
|
}
|
|
1684
1809
|
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
* Sanitized but UNTRUNCATED output, used for marker presence/absence
|
|
1694
|
-
* checks. Never persisted to the ledger (stripRaw drops it) — a cheating
|
|
1695
|
-
* script must not hide its marker in the slice, and the DB must not grow
|
|
1696
|
-
* with megabytes of run output.
|
|
1697
|
-
*/
|
|
1698
|
-
rawOutput?: string;
|
|
1699
|
-
};
|
|
1700
|
-
|
|
1701
|
-
/** Drop the transient rawOutput before persisting a verification record. */
|
|
1702
|
-
function stripRaw(v: PocVerification): PocVerificationRecord {
|
|
1703
|
-
const { rawOutput: _raw, ...rest } = v;
|
|
1704
|
-
return rest;
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
function assertVerificationRecord(
|
|
1708
|
-
label: string,
|
|
1709
|
-
v: PocVerification | undefined,
|
|
1710
|
-
expectedMode: string,
|
|
1711
|
-
expectedTarget: string,
|
|
1712
|
-
): asserts v is PocVerification {
|
|
1713
|
-
if (!v) throw new Error(`${label} verification is required`);
|
|
1714
|
-
if (!v.path || typeof v.path !== "string") throw new Error(`${label} verification path missing`);
|
|
1715
|
-
if (!Number.isInteger(v.exitCode)) throw new Error(`${label} verification exitCode invalid`);
|
|
1716
|
-
if (!v.ranAt || Number.isNaN(Date.parse(v.ranAt))) {
|
|
1717
|
-
throw new Error(`${label} verification ranAt must be an ISO timestamp`);
|
|
1718
|
-
}
|
|
1719
|
-
if (typeof v.sandbox !== "boolean") throw new Error(`${label} verification sandbox flag missing`);
|
|
1720
|
-
if (v.completed !== true) throw new Error(`${label} verification did not complete`);
|
|
1721
|
-
if (v.outputComplete !== true) {
|
|
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) {
|
|
1722
1818
|
throw new Error(
|
|
1723
|
-
`${label}
|
|
1819
|
+
`${label} has no evidence.json — the PoC must write evidence to $PI_POC_EVIDENCE_DIR`,
|
|
1724
1820
|
);
|
|
1725
1821
|
}
|
|
1726
|
-
if (
|
|
1727
|
-
throw new Error(`${label}
|
|
1822
|
+
if (!evidenceNonceMatches(run.evidence, run.nonce)) {
|
|
1823
|
+
throw new Error(`${label} evidence nonce mismatch — evidence not bound to this run`);
|
|
1728
1824
|
}
|
|
1729
|
-
|
|
1825
|
+
}
|
|
1826
|
+
|
|
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)) {
|
|
1730
1831
|
throw new Error(
|
|
1731
|
-
|
|
1832
|
+
"Target runs produced inconsistent evidence — the exploit did not reproduce deterministically",
|
|
1732
1833
|
);
|
|
1733
1834
|
}
|
|
1734
|
-
if (
|
|
1835
|
+
if (normalizeEvidence(r1.evidence) === normalizeEvidence(bundle.controlRun.evidence)) {
|
|
1735
1836
|
throw new Error(
|
|
1736
|
-
|
|
1837
|
+
"Control run produced identical evidence to the target — the claimed impact is not target-dependent",
|
|
1737
1838
|
);
|
|
1738
1839
|
}
|
|
1739
1840
|
}
|
|
1740
1841
|
|
|
1741
1842
|
/**
|
|
1742
|
-
* Gate for
|
|
1743
|
-
* poc/evidence/impact/severity.
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
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.
|
|
1746
1848
|
*/
|
|
1747
1849
|
export function assertPromotable(id: string): CaseRecord {
|
|
1748
1850
|
const current = getCaseById(id);
|
|
@@ -1750,7 +1852,7 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1750
1852
|
throw new Error(`Case not found: ${id}`);
|
|
1751
1853
|
}
|
|
1752
1854
|
if (current.status !== "investigating") {
|
|
1753
|
-
throw new Error(`
|
|
1855
|
+
throw new Error(`PromoteFinding requires an investigating case (current: ${current.status})`);
|
|
1754
1856
|
}
|
|
1755
1857
|
if (!current.poc) {
|
|
1756
1858
|
throw new Error("CONFIRMED requires poc; set poc on the case first");
|
|
@@ -1769,15 +1871,10 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1769
1871
|
"CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
|
|
1770
1872
|
);
|
|
1771
1873
|
}
|
|
1772
|
-
if (!current.disconfirmation) {
|
|
1773
|
-
throw new Error(
|
|
1774
|
-
"CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
|
|
1775
|
-
);
|
|
1776
|
-
}
|
|
1777
1874
|
// Evidence-chain closure: the observation item must be ARTIFACT-BACKED. A
|
|
1778
1875
|
// summary-only observation is agent prose about itself — promotion requires
|
|
1779
1876
|
// a real file with its SHA-256 as the initial signal. (The reproduction item
|
|
1780
|
-
// is always artifact-backed: the
|
|
1877
|
+
// is always artifact-backed: the gate writes it from the evidence hash.)
|
|
1781
1878
|
if (!current.evidenceItems.some((e) => e.role === "observation" && e.sha256)) {
|
|
1782
1879
|
throw new Error(
|
|
1783
1880
|
"Evidence chain incomplete: CONFIRMED requires an artifact-backed observation evidence item " +
|
|
@@ -1788,171 +1885,237 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
1788
1885
|
return current;
|
|
1789
1886
|
}
|
|
1790
1887
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
):
|
|
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 {
|
|
1799
1896
|
const db = getDb();
|
|
1800
1897
|
return withImmediateTransaction(db, () => {
|
|
1801
|
-
const current =
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
// Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
|
|
1805
|
-
// promotion — sandboxed and live alike. The control run must be the same
|
|
1806
|
-
// script against a DISTINCT baseline target, with complete captured output.
|
|
1807
|
-
const liveness = controlLivenessMarker?.trim();
|
|
1808
|
-
if (!liveness) {
|
|
1898
|
+
const current = getCaseById(id);
|
|
1899
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
1900
|
+
if (current.status !== "investigating") {
|
|
1809
1901
|
throw new Error(
|
|
1810
|
-
|
|
1811
|
-
"after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
|
|
1902
|
+
`Pending confirmation requires an investigating case (current: ${current.status})`,
|
|
1812
1903
|
);
|
|
1813
1904
|
}
|
|
1814
|
-
|
|
1815
|
-
if (!
|
|
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)) {
|
|
1816
1919
|
throw new Error(
|
|
1817
|
-
"
|
|
1818
|
-
"promoteFindingResult refuses to promote on exit 0 alone.",
|
|
1920
|
+
"Pending confirmation requires both target runs against the same case target",
|
|
1819
1921
|
);
|
|
1820
1922
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
+
);
|
|
1825
1928
|
}
|
|
1826
|
-
if (
|
|
1929
|
+
if (bundle.controlRun.target === targetRunTarget) {
|
|
1827
1930
|
throw new Error(
|
|
1828
|
-
"
|
|
1931
|
+
"CONTROL BINDING FAILED: the control run targeted the same host as the target runs — " +
|
|
1932
|
+
"the claimed impact is not target-dependent.",
|
|
1829
1933
|
);
|
|
1830
1934
|
}
|
|
1831
|
-
|
|
1832
|
-
assertVerificationRecord(
|
|
1833
|
-
"Disconfirmation",
|
|
1834
|
-
disconfirmationVerification,
|
|
1835
|
-
"disconfirmation",
|
|
1836
|
-
caseTarget,
|
|
1837
|
-
);
|
|
1838
|
-
|
|
1839
|
-
if (verification.exitCode !== 0) {
|
|
1935
|
+
if (bundle.controlTarget === current.target) {
|
|
1840
1936
|
throw new Error(
|
|
1841
|
-
|
|
1937
|
+
"CONTROL BINDING FAILED: control_target must differ from the case target; a control run " +
|
|
1938
|
+
"against the vulnerable target proves nothing.",
|
|
1842
1939
|
);
|
|
1843
1940
|
}
|
|
1844
|
-
|
|
1845
|
-
// Same-file contract: the control must be the SAME script as the PoC
|
|
1846
|
-
// (differing only via PI_POC_MODE / PI_POC_TARGET). The tool enforces this
|
|
1847
|
-
// before running; the ledger re-checks so a direct caller cannot bypass it.
|
|
1941
|
+
// Same-file contract re-checked at store time (the tool already checked).
|
|
1848
1942
|
let pocHash: string | undefined;
|
|
1849
1943
|
let controlHash: string | undefined;
|
|
1850
1944
|
try {
|
|
1851
|
-
pocHash = createHash("sha256").update(readFileSync(
|
|
1852
|
-
controlHash = createHash("sha256")
|
|
1853
|
-
.update(readFileSync(controlVerification.path))
|
|
1854
|
-
.digest("hex");
|
|
1945
|
+
pocHash = createHash("sha256").update(readFileSync(bundle.pocPath)).digest("hex");
|
|
1946
|
+
controlHash = createHash("sha256").update(readFileSync(bundle.controlPath)).digest("hex");
|
|
1855
1947
|
} catch {
|
|
1856
1948
|
pocHash = undefined;
|
|
1857
1949
|
controlHash = undefined;
|
|
1858
1950
|
}
|
|
1859
1951
|
if (!pocHash || !controlHash || pocHash !== controlHash) {
|
|
1860
1952
|
throw new Error(
|
|
1861
|
-
"
|
|
1862
|
-
"(sha256
|
|
1863
|
-
"A separately written control file proves nothing.",
|
|
1953
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
|
|
1954
|
+
"(sha256 mismatch). A separately written control file proves nothing.",
|
|
1864
1955
|
);
|
|
1865
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);
|
|
1866
1964
|
|
|
1867
|
-
const
|
|
1868
|
-
|
|
1965
|
+
const next = buildRecord({ pendingConfirmation: bundle }, current);
|
|
1966
|
+
validateCase(next);
|
|
1967
|
+
upsertCase(db, next);
|
|
1968
|
+
return next;
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
|
|
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) {
|
|
1869
2002
|
throw new Error(
|
|
1870
|
-
"
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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,
|
|
1875
2016
|
);
|
|
2017
|
+
validateCase(next);
|
|
2018
|
+
upsertCase(db, next);
|
|
2019
|
+
return { record: next, changed: true };
|
|
1876
2020
|
}
|
|
1877
2021
|
|
|
1878
|
-
|
|
1879
|
-
|
|
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) {
|
|
1880
2035
|
throw new Error(
|
|
1881
|
-
|
|
1882
|
-
"exit 0 alone cannot promote to confirmed",
|
|
2036
|
+
"PoC script changed since the runs — re-run PromoteFinding (the confirmer must review the exact bytes that ran)",
|
|
1883
2037
|
);
|
|
1884
2038
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
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) {
|
|
1887
2044
|
throw new Error(
|
|
1888
|
-
"
|
|
1889
|
-
|
|
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)"}).`,
|
|
1890
2047
|
);
|
|
1891
2048
|
}
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
try {
|
|
1898
|
-
pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1899
|
-
} catch {
|
|
1900
|
-
pocSha256 = undefined;
|
|
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
|
+
);
|
|
1901
2054
|
}
|
|
1902
2055
|
|
|
1903
|
-
//
|
|
1904
|
-
// file than the PoC (same hash = the model re-used its PoC as "the initial
|
|
1905
|
-
// signal"), a different basename, and it must predate the PoC run.
|
|
2056
|
+
// The observation must predate the repro (provenance guard).
|
|
1906
2057
|
const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
|
|
1907
|
-
if (observation) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
);
|
|
1913
|
-
}
|
|
1914
|
-
if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
|
|
1915
|
-
throw new Error(
|
|
1916
|
-
"Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
|
|
1917
|
-
"The initial signal must be a separate captured artifact.",
|
|
1918
|
-
);
|
|
1919
|
-
}
|
|
1920
|
-
if (observation.createdAt > verification.ranAt) {
|
|
1921
|
-
throw new Error(
|
|
1922
|
-
"Evidence chain invalid: the observation item was recorded after the PoC ran " +
|
|
1923
|
-
`(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
2058
|
+
if (observation && observation.createdAt > bundle.targetRuns[0].ranAt) {
|
|
2059
|
+
throw new Error(
|
|
2060
|
+
"Evidence chain invalid: the observation item was recorded after the PoC ran " +
|
|
2061
|
+
`(${observation.createdAt} > ${bundle.targetRuns[0].ranAt}). The observation must predate the repro.`,
|
|
2062
|
+
);
|
|
1926
2063
|
}
|
|
2064
|
+
|
|
1927
2065
|
const reproductionItem: EvidenceItem = {
|
|
1928
|
-
id: `ev_${stableShortId(`${id}\nreproduction\n${
|
|
2066
|
+
id: `ev_${stableShortId(`${id}\nreproduction\n${targetRun.ranAt}`)}`,
|
|
1929
2067
|
caseId: id,
|
|
1930
2068
|
role: "reproduction",
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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,
|
|
1935
2076
|
};
|
|
1936
2077
|
|
|
1937
2078
|
const newEvidence =
|
|
1938
2079
|
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
1939
|
-
`### PoC Execution Capture (${
|
|
1940
|
-
`- **
|
|
1941
|
-
`- **
|
|
1942
|
-
`- **
|
|
1943
|
-
`####
|
|
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\`\`\``;
|
|
1944
2085
|
|
|
1945
2086
|
const update: NormalizedCaseInput = {
|
|
1946
2087
|
status: "confirmed",
|
|
1947
|
-
pocVerified:
|
|
1948
|
-
|
|
1949
|
-
|
|
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,
|
|
1950
2113
|
evidence: newEvidence,
|
|
1951
2114
|
};
|
|
1952
2115
|
|
|
1953
2116
|
const next = buildRecord(update, current);
|
|
2117
|
+
next.pendingConfirmation = undefined; // buildRecord's ?? existing keeps it; clear explicitly
|
|
1954
2118
|
validateCase(next);
|
|
1955
|
-
|
|
1956
2119
|
insertEvidenceItem(db, reproductionItem);
|
|
1957
2120
|
upsertCase(db, next);
|
|
1958
2121
|
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
@@ -2360,12 +2523,16 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
2360
2523
|
|
|
2361
2524
|
const query = options.query?.trim().toLowerCase();
|
|
2362
2525
|
if (query) {
|
|
2363
|
-
|
|
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}%`;
|
|
2364
2531
|
if (options.field) {
|
|
2365
|
-
where.push(`lower(${options.field}) LIKE
|
|
2532
|
+
where.push(`lower(${options.field}) LIKE ? ESCAPE '\\'`);
|
|
2366
2533
|
params.push(likeParam);
|
|
2367
2534
|
} else {
|
|
2368
|
-
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE
|
|
2535
|
+
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ? ESCAPE '\\'`).join(" OR ");
|
|
2369
2536
|
where.push(`(${ors})`);
|
|
2370
2537
|
for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
|
|
2371
2538
|
}
|
|
@@ -2405,8 +2572,12 @@ export function searchCases(options: CaseSearchOptions = {}): {
|
|
|
2405
2572
|
total: number;
|
|
2406
2573
|
} {
|
|
2407
2574
|
const db = getDb();
|
|
2408
|
-
|
|
2409
|
-
|
|
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);
|
|
2410
2581
|
|
|
2411
2582
|
const { whereSql, orderSql, params } = buildCaseWhere(options);
|
|
2412
2583
|
|
|
@@ -2496,7 +2667,10 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
2496
2667
|
} else if (Array.isArray(val)) {
|
|
2497
2668
|
display = val.join(", ");
|
|
2498
2669
|
} else if (typeof val === "object") {
|
|
2499
|
-
|
|
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));
|
|
2500
2674
|
} else {
|
|
2501
2675
|
display = String(val);
|
|
2502
2676
|
}
|
|
@@ -2528,25 +2702,39 @@ const MAX_ARTIFACT_CHARS = 100_000;
|
|
|
2528
2702
|
* must not balloon the report context into megabytes. */
|
|
2529
2703
|
const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
|
|
2530
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
|
+
|
|
2531
2730
|
function buildCompleteRecord(current: CaseRecord): string {
|
|
2532
2731
|
const rows: string[] = [];
|
|
2533
2732
|
for (const [k, v] of Object.entries(current)) {
|
|
2534
2733
|
if (v === undefined || v === null || v === "") continue;
|
|
2535
|
-
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
if (
|
|
2540
|
-
(k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
|
|
2541
|
-
v &&
|
|
2542
|
-
typeof v === "object"
|
|
2543
|
-
) {
|
|
2544
|
-
const redacted = {
|
|
2545
|
-
...(v as Record<string, unknown>),
|
|
2546
|
-
path: basename((v as { path?: string }).path ?? ""),
|
|
2547
|
-
};
|
|
2548
|
-
display = JSON.stringify(redacted, null, 2);
|
|
2549
|
-
}
|
|
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);
|
|
2550
2738
|
rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
|
|
2551
2739
|
}
|
|
2552
2740
|
return rows.join("\n");
|
|
@@ -2702,13 +2890,13 @@ export function writeCaseContext(id: string): {
|
|
|
2702
2890
|
current.pocVerified
|
|
2703
2891
|
? mdSection(
|
|
2704
2892
|
"PoC Verification Log",
|
|
2705
|
-
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
|
|
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\`\`\``,
|
|
2706
2894
|
)
|
|
2707
2895
|
: undefined,
|
|
2708
2896
|
current.controlVerified
|
|
2709
2897
|
? mdSection(
|
|
2710
2898
|
"Control-Target Check (anti-cheat)",
|
|
2711
|
-
`### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **
|
|
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\`\`\``,
|
|
2712
2900
|
)
|
|
2713
2901
|
: undefined,
|
|
2714
2902
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|