@xaccefy/pi-casefile 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -5
- package/src/index.ts +50 -40
- package/src/ledger.ts +331 -324
- package/src/pipeline-submit.ts +212 -14
- package/src/poc-runner.ts +21 -4
- package/src/scratchpad.ts +78 -12
- package/src/workflow.ts +8 -8
package/src/ledger.ts
CHANGED
|
@@ -11,15 +11,7 @@
|
|
|
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";
|
|
24
16
|
import {
|
|
25
17
|
findWorkspaceRoot,
|
|
@@ -27,6 +19,7 @@ import {
|
|
|
27
19
|
PHASE_ORDER,
|
|
28
20
|
scratchpad_read,
|
|
29
21
|
scratchpad_resume,
|
|
22
|
+
scratchpad_runs,
|
|
30
23
|
} from "./scratchpad.ts";
|
|
31
24
|
import { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
32
25
|
|
|
@@ -186,29 +179,11 @@ export type CaseRecord = {
|
|
|
186
179
|
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
187
180
|
disconfirmation?: string;
|
|
188
181
|
/** 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
|
-
};
|
|
182
|
+
pocVerified?: PocVerificationRecord;
|
|
196
183
|
/** Verification of a disconfirmation run (set only by promoteFindingResult). */
|
|
197
|
-
disconfirmationVerified?:
|
|
198
|
-
path: string;
|
|
199
|
-
exitCode: number;
|
|
200
|
-
ranAt: string;
|
|
201
|
-
output?: string;
|
|
202
|
-
sandbox: boolean;
|
|
203
|
-
};
|
|
184
|
+
disconfirmationVerified?: PocVerificationRecord;
|
|
204
185
|
/** 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
|
-
};
|
|
186
|
+
controlVerified?: PocVerificationRecord;
|
|
212
187
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
213
188
|
reportedAt?: string;
|
|
214
189
|
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
@@ -223,6 +198,18 @@ export type CaseRecord = {
|
|
|
223
198
|
updatedAt: string;
|
|
224
199
|
};
|
|
225
200
|
|
|
201
|
+
export type PocVerificationRecord = {
|
|
202
|
+
path: string;
|
|
203
|
+
exitCode: number;
|
|
204
|
+
ranAt: string;
|
|
205
|
+
output?: string;
|
|
206
|
+
sandbox: boolean;
|
|
207
|
+
completed?: boolean;
|
|
208
|
+
outputComplete?: boolean;
|
|
209
|
+
mode?: string;
|
|
210
|
+
target?: string;
|
|
211
|
+
};
|
|
212
|
+
|
|
226
213
|
export type CaseInput = {
|
|
227
214
|
title: string;
|
|
228
215
|
status?: CaseStatus;
|
|
@@ -367,6 +354,13 @@ function getDb(): DatabaseSync {
|
|
|
367
354
|
}
|
|
368
355
|
|
|
369
356
|
const db = new DatabaseSync(dbPath);
|
|
357
|
+
// Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
|
|
358
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
359
|
+
try {
|
|
360
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
361
|
+
} catch {
|
|
362
|
+
// Some filesystems/backends reject WAL; rollback journal still works.
|
|
363
|
+
}
|
|
370
364
|
// Enable foreign-key enforcement so ON DELETE CASCADE actually fires
|
|
371
365
|
// (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
|
|
372
366
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -1243,6 +1237,22 @@ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
|
|
|
1243
1237
|
|
|
1244
1238
|
// ── SQLite Mutation Actions ───────────────────────────────────────────
|
|
1245
1239
|
|
|
1240
|
+
function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
1241
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1242
|
+
try {
|
|
1243
|
+
const value = fn();
|
|
1244
|
+
db.exec("COMMIT");
|
|
1245
|
+
return value;
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
try {
|
|
1248
|
+
db.exec("ROLLBACK");
|
|
1249
|
+
} catch {
|
|
1250
|
+
// ignore rollback errors
|
|
1251
|
+
}
|
|
1252
|
+
throw err;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1246
1256
|
function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
1247
1257
|
// Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
|
|
1248
1258
|
// wipe case_links when updating an existing primary key.
|
|
@@ -1554,133 +1564,131 @@ export function coverageSummary(caseId: string): CoverageSummary {
|
|
|
1554
1564
|
export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
1555
1565
|
const db = getDb();
|
|
1556
1566
|
validateNewCaseInput(input);
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1567
|
+
return withImmediateTransaction(db, () => {
|
|
1568
|
+
const record = buildRecord(input, undefined);
|
|
1569
|
+
validateCase(record);
|
|
1570
|
+
|
|
1571
|
+
const duplicate = findDuplicateCaseInDb(db, record);
|
|
1572
|
+
if (duplicate) {
|
|
1573
|
+
return {
|
|
1574
|
+
record: duplicate.record,
|
|
1575
|
+
created: false,
|
|
1576
|
+
nearDuplicate: duplicate.near,
|
|
1577
|
+
reason: duplicate.near
|
|
1578
|
+
? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
|
|
1579
|
+
`Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
|
|
1580
|
+
`Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
|
|
1581
|
+
: `Duplicate case exists: ${duplicate.record.id}`,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1574
1584
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1585
|
+
upsertCase(db, record);
|
|
1586
|
+
return { record, created: true };
|
|
1587
|
+
});
|
|
1577
1588
|
}
|
|
1578
1589
|
|
|
1579
1590
|
export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
|
|
1580
1591
|
const db = getDb();
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1592
|
+
return withImmediateTransaction(db, () => {
|
|
1593
|
+
const current = getCaseById(id);
|
|
1594
|
+
if (!current) {
|
|
1595
|
+
throw new Error(`Case not found: ${id}`);
|
|
1596
|
+
}
|
|
1585
1597
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1598
|
+
// Terminal states: block all mutations (status and field edits). The transition
|
|
1599
|
+
// gate only runs on status changes, so without this reported/killed cases could
|
|
1600
|
+
// still be rewritten via field-only updates.
|
|
1601
|
+
if (current.status === "killed") {
|
|
1602
|
+
throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
|
|
1603
|
+
}
|
|
1604
|
+
if (current.status === "reported") {
|
|
1605
|
+
throw new Error("Cannot mutate a reported case; file a follow-up case instead");
|
|
1606
|
+
}
|
|
1595
1607
|
|
|
1596
|
-
|
|
1597
|
-
// construction is the update itself.
|
|
1598
|
-
let next = buildRecord(update, current);
|
|
1608
|
+
let next = buildRecord(update, current);
|
|
1599
1609
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1610
|
+
if (update.status && update.status !== current.status) {
|
|
1611
|
+
validateTransition(current.status, next.status, update, current);
|
|
1612
|
+
}
|
|
1603
1613
|
|
|
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
|
-
}
|
|
1614
|
+
if (next.status === "reported") {
|
|
1615
|
+
next = { ...next, reportedAt: new Date().toISOString() };
|
|
1616
|
+
}
|
|
1611
1617
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
controlVerified: undefined,
|
|
1621
|
-
};
|
|
1622
|
-
}
|
|
1618
|
+
if (current.status === "confirmed" && next.status === "investigating") {
|
|
1619
|
+
next = {
|
|
1620
|
+
...next,
|
|
1621
|
+
pocVerified: undefined,
|
|
1622
|
+
disconfirmationVerified: undefined,
|
|
1623
|
+
controlVerified: undefined,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1623
1626
|
|
|
1624
|
-
|
|
1627
|
+
if (current.status === "confirmed" && next.status === "confirmed") {
|
|
1628
|
+
const proofFields = ["target", "poc", "impact", "severity"] as const;
|
|
1629
|
+
const changed = proofFields.filter((field) => current[field] !== next[field]);
|
|
1630
|
+
if (changed.length > 0) {
|
|
1631
|
+
throw new Error(
|
|
1632
|
+
`Confirmed proof-bound field(s) changed: ${changed.join(", ")}. ` +
|
|
1633
|
+
`Demote the case with status: "investigating" in the same update; that clears stale verification records and requires re-promotion.`,
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1625
1637
|
|
|
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
|
-
}
|
|
1638
|
+
validateCase(next);
|
|
1639
|
+
|
|
1640
|
+
const norm = (r: CaseRecord) =>
|
|
1641
|
+
JSON.stringify(
|
|
1642
|
+
Object.keys(r)
|
|
1643
|
+
.sort()
|
|
1644
|
+
.reduce<Record<string, unknown>>((acc, k) => {
|
|
1645
|
+
if (
|
|
1646
|
+
k === "updatedAt" ||
|
|
1647
|
+
k === "createdAt" ||
|
|
1648
|
+
k === "linkedCases" ||
|
|
1649
|
+
k === "evidenceItems" ||
|
|
1650
|
+
k === "coverageItems"
|
|
1651
|
+
) {
|
|
1652
|
+
acc[k] = "";
|
|
1653
|
+
} else {
|
|
1654
|
+
acc[k] = (r as Record<string, unknown>)[k];
|
|
1655
|
+
}
|
|
1656
|
+
return acc;
|
|
1657
|
+
}, {}),
|
|
1658
|
+
);
|
|
1659
|
+
if (norm(current) === norm(next)) {
|
|
1660
|
+
const reason =
|
|
1661
|
+
update.status && update.status === current.status
|
|
1662
|
+
? `Case is already ${current.status}; no material fields changed.`
|
|
1663
|
+
: "No material fields changed.";
|
|
1664
|
+
return { record: current, changed: false, reason };
|
|
1665
|
+
}
|
|
1671
1666
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1667
|
+
const duplicate = findDuplicateCaseInDb(db, next, id);
|
|
1668
|
+
if (duplicate) {
|
|
1669
|
+
return {
|
|
1670
|
+
record: current,
|
|
1671
|
+
changed: false,
|
|
1672
|
+
reason: duplicate.near
|
|
1673
|
+
? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
|
|
1674
|
+
`(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
|
|
1675
|
+
`clearly distinct title if these are genuinely separate findings.`
|
|
1676
|
+
: `Update would create a duplicate of case ${duplicate.record.id}`,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
upsertCase(db, next);
|
|
1681
|
+
return { record: next, changed: true };
|
|
1682
|
+
});
|
|
1674
1683
|
}
|
|
1675
1684
|
|
|
1676
|
-
export type PocVerification = {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
completed?: boolean;
|
|
1685
|
+
export type PocVerification = PocVerificationRecord & {
|
|
1686
|
+
/** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
|
|
1687
|
+
outputComplete?: boolean;
|
|
1688
|
+
/** Harness mode used for the run: poc, control, or disconfirmation. */
|
|
1689
|
+
mode?: string;
|
|
1690
|
+
/** Target passed to the PoC through PI_POC_TARGET. */
|
|
1691
|
+
target?: string;
|
|
1684
1692
|
/**
|
|
1685
1693
|
* Sanitized but UNTRUNCATED output, used for marker presence/absence
|
|
1686
1694
|
* checks. Never persisted to the ledger (stripRaw drops it) — a cheating
|
|
@@ -1691,11 +1699,45 @@ export type PocVerification = {
|
|
|
1691
1699
|
};
|
|
1692
1700
|
|
|
1693
1701
|
/** Drop the transient rawOutput before persisting a verification record. */
|
|
1694
|
-
function stripRaw(v: PocVerification):
|
|
1702
|
+
function stripRaw(v: PocVerification): PocVerificationRecord {
|
|
1695
1703
|
const { rawOutput: _raw, ...rest } = v;
|
|
1696
1704
|
return rest;
|
|
1697
1705
|
}
|
|
1698
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) {
|
|
1722
|
+
throw new Error(
|
|
1723
|
+
`${label} verification output capture was incomplete; marker checks are unsafe`,
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof v.rawOutput !== "string") {
|
|
1727
|
+
throw new Error(`${label} verification rawOutput is required for full-output marker checks`);
|
|
1728
|
+
}
|
|
1729
|
+
if (v.mode !== expectedMode) {
|
|
1730
|
+
throw new Error(
|
|
1731
|
+
`${label} verification mode mismatch: expected ${expectedMode}, got ${v.mode ?? "unset"}`,
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
if (v.target !== expectedTarget) {
|
|
1735
|
+
throw new Error(
|
|
1736
|
+
`${label} verification target mismatch: expected ${expectedTarget}, got ${v.target ?? "unset"}`,
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1699
1741
|
/**
|
|
1700
1742
|
* Gate for promotion to confirmed: case must exist, be investigating, and have
|
|
1701
1743
|
* poc/evidence/impact/severity. Returns the record when promotable, throws
|
|
@@ -1755,194 +1797,167 @@ export function promoteFindingResult(
|
|
|
1755
1797
|
controlLivenessMarker?: string,
|
|
1756
1798
|
): CaseUpdateResult {
|
|
1757
1799
|
const db = getDb();
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
)
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
}
|
|
1780
|
-
// Verification marker is MANDATORY at the ledger too — no `!marker` branch.
|
|
1781
|
-
// The tool always passes it; a future direct caller that omits it must fail
|
|
1782
|
-
// closed, not get a weakened control gate.
|
|
1783
|
-
const verificationMarker = marker?.trim();
|
|
1784
|
-
if (!verificationMarker) {
|
|
1785
|
-
throw new Error(
|
|
1786
|
-
"Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
|
|
1787
|
-
"promoteFindingResult refuses to promote on exit 0 alone.",
|
|
1788
|
-
);
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
// Same-file contract: the control must be the SAME script as the PoC
|
|
1792
|
-
// (differing only via PI_POC_MODE). The tool enforces this before running;
|
|
1793
|
-
// the ledger re-checks so a direct caller cannot bypass it. A separately
|
|
1794
|
-
// written control file is meaningless — the same actor writes both.
|
|
1795
|
-
let pocHash: string | undefined;
|
|
1796
|
-
let controlHash: string | undefined;
|
|
1797
|
-
try {
|
|
1798
|
-
pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1799
|
-
controlHash = createHash("sha256")
|
|
1800
|
-
.update(readFileSync(controlVerification?.path ?? ""))
|
|
1801
|
-
.digest("hex");
|
|
1802
|
-
} catch {
|
|
1803
|
-
pocHash = undefined;
|
|
1804
|
-
controlHash = undefined;
|
|
1805
|
-
}
|
|
1806
|
-
if (!pocHash || !controlHash || pocHash !== controlHash) {
|
|
1807
|
-
throw new Error(
|
|
1808
|
-
"Every promotion requires controlVerification from the SAME script as the PoC " +
|
|
1809
|
-
"(sha256 of controlVerification.path must equal sha256 of verification.path). " +
|
|
1810
|
-
"A separately written control file proves nothing.",
|
|
1811
|
-
);
|
|
1812
|
-
}
|
|
1800
|
+
return withImmediateTransaction(db, () => {
|
|
1801
|
+
const current = assertPromotable(id);
|
|
1802
|
+
const caseTarget = current.target ?? "";
|
|
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) {
|
|
1809
|
+
throw new Error(
|
|
1810
|
+
"Every promotion requires controlLivenessMarker: a non-empty string the control run must print " +
|
|
1811
|
+
"after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
const verificationMarker = marker?.trim();
|
|
1815
|
+
if (!verificationMarker) {
|
|
1816
|
+
throw new Error(
|
|
1817
|
+
"Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
|
|
1818
|
+
"promoteFindingResult refuses to promote on exit 0 alone.",
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1813
1821
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1822
|
+
assertVerificationRecord("PoC", verification, "poc", caseTarget);
|
|
1823
|
+
if (!controlVerification?.target?.trim()) {
|
|
1824
|
+
throw new Error("Every promotion requires a controlVerification target");
|
|
1825
|
+
}
|
|
1826
|
+
if (controlVerification.target === caseTarget) {
|
|
1827
|
+
throw new Error(
|
|
1828
|
+
"Every promotion requires a distinct control target; the control run cannot use the case target",
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
assertVerificationRecord("Control", controlVerification, "control", controlVerification.target);
|
|
1832
|
+
assertVerificationRecord(
|
|
1833
|
+
"Disconfirmation",
|
|
1834
|
+
disconfirmationVerification,
|
|
1835
|
+
"disconfirmation",
|
|
1836
|
+
caseTarget,
|
|
1829
1837
|
);
|
|
1830
|
-
}
|
|
1831
1838
|
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
throw new Error(
|
|
1838
|
-
`PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
|
|
1839
|
-
"exit 0 alone cannot promote to confirmed",
|
|
1840
|
-
);
|
|
1841
|
-
}
|
|
1839
|
+
if (verification.exitCode !== 0) {
|
|
1840
|
+
throw new Error(
|
|
1841
|
+
`PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1842
1844
|
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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.
|
|
1848
|
+
let pocHash: string | undefined;
|
|
1849
|
+
let controlHash: string | undefined;
|
|
1850
|
+
try {
|
|
1851
|
+
pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1852
|
+
controlHash = createHash("sha256")
|
|
1853
|
+
.update(readFileSync(controlVerification.path))
|
|
1854
|
+
.digest("hex");
|
|
1855
|
+
} catch {
|
|
1856
|
+
pocHash = undefined;
|
|
1857
|
+
controlHash = undefined;
|
|
1858
|
+
}
|
|
1859
|
+
if (!pocHash || !controlHash || pocHash !== controlHash) {
|
|
1860
|
+
throw new Error(
|
|
1861
|
+
"Every promotion requires controlVerification from the SAME script as the PoC " +
|
|
1862
|
+
"(sha256 of controlVerification.path must equal sha256 of verification.path). " +
|
|
1863
|
+
"A separately written control file proves nothing.",
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1857
1866
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
// with its SHA-256, not to agent prose in the evidence field.
|
|
1861
|
-
let pocSha256: string | undefined;
|
|
1862
|
-
try {
|
|
1863
|
-
pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1864
|
-
} catch {
|
|
1865
|
-
pocSha256 = undefined;
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
// Cheap provenance guards on the observation item: it must be a DIFFERENT
|
|
1869
|
-
// file than the PoC (same hash = the model re-used its PoC as "the initial
|
|
1870
|
-
// signal"), a different basename, and it must predate the PoC run (an
|
|
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) {
|
|
1867
|
+
const controlOutput = controlVerification.rawOutput ?? "";
|
|
1868
|
+
if (controlOutput.includes(verificationMarker) || !controlOutput.includes(liveness)) {
|
|
1877
1869
|
throw new Error(
|
|
1878
|
-
"
|
|
1879
|
-
|
|
1870
|
+
"Every promotion requires a valid controlVerification: a control-target run of the same " +
|
|
1871
|
+
`PoC whose output does not contain the marker "${verificationMarker}"` +
|
|
1872
|
+
` and whose output DOES contain the control liveness marker "${liveness}"` +
|
|
1873
|
+
" (the control must actually reach its target — a failed/early control is not a clean verdict)" +
|
|
1874
|
+
". PromoteFinding requires control_path + control_liveness_marker.",
|
|
1880
1875
|
);
|
|
1881
1876
|
}
|
|
1882
|
-
|
|
1877
|
+
|
|
1878
|
+
const pocOutput = verification.rawOutput ?? "";
|
|
1879
|
+
if (!pocOutput.includes(verificationMarker)) {
|
|
1883
1880
|
throw new Error(
|
|
1884
|
-
|
|
1885
|
-
"
|
|
1881
|
+
`PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
|
|
1882
|
+
"exit 0 alone cannot promote to confirmed",
|
|
1886
1883
|
);
|
|
1887
1884
|
}
|
|
1888
|
-
|
|
1885
|
+
|
|
1886
|
+
if (disconfirmationVerification.exitCode === 0) {
|
|
1889
1887
|
throw new Error(
|
|
1890
|
-
"
|
|
1891
|
-
|
|
1888
|
+
"Every promotion requires an executed disconfirmation run that completed and exited non-zero " +
|
|
1889
|
+
"(the finding survived the attempt to disprove it). PromoteFinding requires disconfirmation_path for every promotion.",
|
|
1892
1890
|
);
|
|
1893
1891
|
}
|
|
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
1892
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
pocVerified: stripRaw(verification),
|
|
1915
|
-
evidence: newEvidence,
|
|
1916
|
-
};
|
|
1917
|
-
if (disconfirmationVerification) {
|
|
1918
|
-
update.disconfirmationVerified = stripRaw(disconfirmationVerification);
|
|
1919
|
-
}
|
|
1920
|
-
if (controlVerification) {
|
|
1921
|
-
update.controlVerified = stripRaw(controlVerification);
|
|
1922
|
-
}
|
|
1893
|
+
// Machine-recorded reproduction evidence: the PoC gate itself writes the
|
|
1894
|
+
// artifact-backed evidence item — confirmation is anchored to a real file
|
|
1895
|
+
// with its SHA-256, not to agent prose in the evidence field.
|
|
1896
|
+
let pocSha256: string | undefined;
|
|
1897
|
+
try {
|
|
1898
|
+
pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1899
|
+
} catch {
|
|
1900
|
+
pocSha256 = undefined;
|
|
1901
|
+
}
|
|
1923
1902
|
|
|
1924
|
-
|
|
1925
|
-
|
|
1903
|
+
// Cheap provenance guards on the observation item: it must be a DIFFERENT
|
|
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.
|
|
1906
|
+
const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
|
|
1907
|
+
if (observation) {
|
|
1908
|
+
if (pocSha256 && observation.sha256 === pocSha256) {
|
|
1909
|
+
throw new Error(
|
|
1910
|
+
"Evidence chain invalid: the observation artifact is the same file as the PoC " +
|
|
1911
|
+
"(identical sha256). The initial signal must be a separate captured artifact.",
|
|
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
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
const reproductionItem: EvidenceItem = {
|
|
1928
|
+
id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
|
|
1929
|
+
caseId: id,
|
|
1930
|
+
role: "reproduction",
|
|
1931
|
+
artifactPath: basename(verification.path),
|
|
1932
|
+
sha256: pocSha256,
|
|
1933
|
+
summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
|
|
1934
|
+
createdAt: verification.ranAt,
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
const newEvidence =
|
|
1938
|
+
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
1939
|
+
`### PoC Execution Capture (${verification.ranAt})\n` +
|
|
1940
|
+
`- **Exit Code:** ${verification.exitCode}\n` +
|
|
1941
|
+
`- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
|
|
1942
|
+
`- **Target:** ${verification.target}\n` +
|
|
1943
|
+
`#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
|
|
1944
|
+
|
|
1945
|
+
const update: NormalizedCaseInput = {
|
|
1946
|
+
status: "confirmed",
|
|
1947
|
+
pocVerified: stripRaw(verification),
|
|
1948
|
+
disconfirmationVerified: stripRaw(disconfirmationVerification),
|
|
1949
|
+
controlVerified: stripRaw(controlVerification),
|
|
1950
|
+
evidence: newEvidence,
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
const next = buildRecord(update, current);
|
|
1954
|
+
validateCase(next);
|
|
1926
1955
|
|
|
1927
|
-
// Evidence insert + case upsert are one atomic step: a failure between them
|
|
1928
|
-
// would orphan a reproduction item on an investigating case (a promotion
|
|
1929
|
-
// that never happened must leave no trace).
|
|
1930
|
-
db.exec("BEGIN");
|
|
1931
|
-
try {
|
|
1932
1956
|
insertEvidenceItem(db, reproductionItem);
|
|
1933
1957
|
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 };
|
|
1958
|
+
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
1959
|
+
return { record: next, changed: true };
|
|
1960
|
+
});
|
|
1946
1961
|
}
|
|
1947
1962
|
|
|
1948
1963
|
// ── Chain suggestions ───────────────────────────────────────────────
|
|
@@ -2566,19 +2581,11 @@ function buildCaseLinks(db: DatabaseSync, id: string): string {
|
|
|
2566
2581
|
function buildScratchpadSection(caseId: string): string {
|
|
2567
2582
|
const root = getScratchpadRoot();
|
|
2568
2583
|
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
2584
|
const sections: string[] = [];
|
|
2577
2585
|
let totalChars = 0;
|
|
2578
2586
|
let totalCapped = false;
|
|
2579
|
-
outer: for (const
|
|
2580
|
-
|
|
2581
|
-
const resume = scratchpad_resume(entry.name);
|
|
2587
|
+
outer: for (const runId of scratchpad_runs()) {
|
|
2588
|
+
const resume = scratchpad_resume(runId);
|
|
2582
2589
|
if (!resume) continue;
|
|
2583
2590
|
const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
|
|
2584
2591
|
// Gate on the case id appearing in phase_ids OR in any artifact filename —
|
|
@@ -2589,7 +2596,7 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2589
2596
|
.some((n) => n.includes(caseId));
|
|
2590
2597
|
if (!allIds.includes(caseId) && !namedInArtifact) continue;
|
|
2591
2598
|
|
|
2592
|
-
sections.push(`### Run: ${
|
|
2599
|
+
sections.push(`### Run: ${runId} (project root: ${resume.checkpoint.project_root})`);
|
|
2593
2600
|
for (const phase of PHASE_ORDER) {
|
|
2594
2601
|
const names = resume.artifacts[phase];
|
|
2595
2602
|
if (!names?.length) continue;
|
|
@@ -2599,7 +2606,7 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
2599
2606
|
totalCapped = true;
|
|
2600
2607
|
break outer;
|
|
2601
2608
|
}
|
|
2602
|
-
const content = scratchpad_read(
|
|
2609
|
+
const content = scratchpad_read(runId, phase, name) ?? "(unreadable)";
|
|
2603
2610
|
const clipped =
|
|
2604
2611
|
content.length > MAX_ARTIFACT_CHARS
|
|
2605
2612
|
? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
|