@maintainabilityai/research-runner 0.1.36 → 0.1.42
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/dist/runner/skills.js +323 -46
- package/package.json +1 -1
package/dist/runner/skills.js
CHANGED
|
@@ -1337,8 +1337,19 @@ const ProviderResultSchema = zod_1.z.object({
|
|
|
1337
1337
|
publishedDate: zod_1.z.string().optional(),
|
|
1338
1338
|
authors: zod_1.z.array(zod_1.z.string()).optional(),
|
|
1339
1339
|
});
|
|
1340
|
+
// Cert-run-2 bug D fix (Task #56) — schema now accepts BOTH the
|
|
1341
|
+
// canonical grouped shape (`ProviderResult[][]`, one inner array per
|
|
1342
|
+
// provider) AND the agent's intuitive flat shape (`ProviderResult[]`).
|
|
1343
|
+
// Cert run #2 chain showed the agent attempting flat-input dedupe-and-
|
|
1344
|
+
// rank TWICE in a row (events 10+11) before figuring out grouped on
|
|
1345
|
+
// attempt 3 — burning Zod-error feedback to converge. Lenient schema
|
|
1346
|
+
// removes the trial-and-error: either shape works, handler normalizes
|
|
1347
|
+
// internally before calling the pure dedupeAndRank function.
|
|
1340
1348
|
const DedupeAndRankInput = zod_1.z.object({
|
|
1341
|
-
results: zod_1.z.
|
|
1349
|
+
results: zod_1.z.union([
|
|
1350
|
+
zod_1.z.array(zod_1.z.array(ProviderResultSchema)), // canonical: grouped by provider
|
|
1351
|
+
zod_1.z.array(ProviderResultSchema), // lenient: flat list across providers
|
|
1352
|
+
]),
|
|
1342
1353
|
topN: zod_1.z.number().int().positive().optional(),
|
|
1343
1354
|
});
|
|
1344
1355
|
const handleDedupeAndRank = async (input) => {
|
|
@@ -1346,11 +1357,17 @@ const handleDedupeAndRank = async (input) => {
|
|
|
1346
1357
|
if (!parsed.success) {
|
|
1347
1358
|
return { ok: false, reason: `bad-input: ${parsed.error.message}` };
|
|
1348
1359
|
}
|
|
1349
|
-
|
|
1360
|
+
// Discriminate via first element: if it's an array → grouped (flatten);
|
|
1361
|
+
// else flat → use directly. Empty array → treat as empty flat (no-op).
|
|
1362
|
+
const r = parsed.data.results;
|
|
1363
|
+
const grouped = r.length > 0 && Array.isArray(r[0]);
|
|
1364
|
+
const flat = grouped
|
|
1365
|
+
? parsed.data.results.flat()
|
|
1366
|
+
: parsed.data.results;
|
|
1350
1367
|
const ranked = (0, dedupe_and_rank_1.dedupeAndRank)({ results: flat, topN: parsed.data.topN ?? 50 });
|
|
1351
1368
|
const providerCounts = {};
|
|
1352
|
-
for (const
|
|
1353
|
-
providerCounts[
|
|
1369
|
+
for (const result of ranked) {
|
|
1370
|
+
providerCounts[result.provider] = (providerCounts[result.provider] ?? 0) + 1;
|
|
1354
1371
|
}
|
|
1355
1372
|
return { ok: true, rankedSources: ranked, providerCounts };
|
|
1356
1373
|
};
|
|
@@ -1430,7 +1447,19 @@ const handleFormatResearchIssueUpdate = async (input) => {
|
|
|
1430
1447
|
const AuditEmitInput = zod_1.z.object({
|
|
1431
1448
|
okrId: zod_1.z.string().min(1),
|
|
1432
1449
|
runId: zod_1.z.string().min(1),
|
|
1433
|
-
|
|
1450
|
+
// Bug K (cert-run-5) — added `self_review` and `self_review_exhausted`
|
|
1451
|
+
// to the enum. The HOW + WHAT workflows' review-emit step calls
|
|
1452
|
+
// `audit-emit-event` with `eventKind: 'self_review'` to write per-
|
|
1453
|
+
// persona-per-round events into the chain (parsed from PR-body /
|
|
1454
|
+
// artifact-md / artifact-frontmatter sources). Previously the enum
|
|
1455
|
+
// rejected the kind with bad-input; the workflow logged a warning
|
|
1456
|
+
// and moved on; the chain never got the synthetic event; the UI
|
|
1457
|
+
// had to rely on its artifact-fallback. Now the emit succeeds.
|
|
1458
|
+
eventKind: zod_1.z.enum([
|
|
1459
|
+
'skill_call', 'llm_call', 'artifact_written', 'review_received',
|
|
1460
|
+
'self_review', 'self_review_exhausted',
|
|
1461
|
+
'state_transition', 'human_gate',
|
|
1462
|
+
]),
|
|
1434
1463
|
payload: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
1435
1464
|
phase: zod_1.z.enum(['why', 'how', 'what']),
|
|
1436
1465
|
intentThreadUuid: zod_1.z.string().min(1),
|
|
@@ -1493,29 +1522,125 @@ async function sleep(ms) {
|
|
|
1493
1522
|
// `sealed: false, sealVerified: false` but still passes if hashes are
|
|
1494
1523
|
// intact. A chain with PARTIAL signatures is treated as tampering.
|
|
1495
1524
|
// ─────────────────────────────────────────────────────────────────────
|
|
1496
|
-
|
|
1525
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1526
|
+
// Bug O (Task #72) — Per-epoch Ed25519 signing.
|
|
1527
|
+
//
|
|
1528
|
+
// Each agent session = one "signer epoch":
|
|
1529
|
+
// - Original agent invocation → epoch 1
|
|
1530
|
+
// - First revise-agent invocation (different runner machine) → epoch 2
|
|
1531
|
+
// - Second revise → epoch 3
|
|
1532
|
+
// - ... etc
|
|
1533
|
+
//
|
|
1534
|
+
// Each epoch persists its OWN keypair:
|
|
1535
|
+
// <runId>.epoch-N.pub.pem (mesh, committed)
|
|
1536
|
+
// <runId>--<runId>.epoch-N.priv.pem (tmpdir, ephemeral)
|
|
1537
|
+
//
|
|
1538
|
+
// Events carry `signer_epoch: N` so chain-verify can look up the right
|
|
1539
|
+
// pub key per event. Workflow-emitted events (`emitted_by: 'workflow'`)
|
|
1540
|
+
// stay unsigned-by-design — those are CI infrastructure, not an agent.
|
|
1541
|
+
//
|
|
1542
|
+
// Backward compat:
|
|
1543
|
+
// - Legacy chains used <runId>.pub.pem with no epoch suffix. Verifiers
|
|
1544
|
+
// treat that as the epoch-1 pub key if no epoch-suffixed files exist.
|
|
1545
|
+
// - Events without `signer_epoch` field default to epoch 1.
|
|
1546
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1547
|
+
function knightSealLegacyPubKeyPath(okrId, runId) {
|
|
1497
1548
|
return path.join(meshPath(), 'okrs', okrId, 'audit', 'keys', `${runId}.pub.pem`);
|
|
1498
1549
|
}
|
|
1499
|
-
function
|
|
1550
|
+
function knightSealEpochPubKeyPath(okrId, runId, epoch) {
|
|
1551
|
+
return path.join(meshPath(), 'okrs', okrId, 'audit', 'keys', `${runId}.epoch-${epoch}.pub.pem`);
|
|
1552
|
+
}
|
|
1553
|
+
function knightSealEpochPrivKeyPath(okrId, runId, epoch) {
|
|
1500
1554
|
// Tmpdir-scoped to avoid any chance of `git add`-ing a private key.
|
|
1501
|
-
|
|
1502
|
-
|
|
1555
|
+
return path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.epoch-${epoch}.priv.pem`);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Find the active signer epoch for this run.
|
|
1559
|
+
*
|
|
1560
|
+
* Returns the epoch number AND whether the caller should generate a
|
|
1561
|
+
* fresh keypair (isNewSession=true) or load the existing one (false).
|
|
1562
|
+
*
|
|
1563
|
+
* Logic:
|
|
1564
|
+
* 1. Scan `audit/keys/<runId>.epoch-N.pub.pem` files → find max N.
|
|
1565
|
+
* 2. Legacy compat: if no epoch files but `<runId>.pub.pem` exists,
|
|
1566
|
+
* treat it as the epoch-1 pub (max=1).
|
|
1567
|
+
* 3. If max=0 (no keys at all) → genesis, return { epoch: 1, isNew: true }.
|
|
1568
|
+
* 4. If max>0 and `<okrId>--<runId>.epoch-N.priv.pem` exists in tmp →
|
|
1569
|
+
* same session, return { epoch: max, isNew: false }.
|
|
1570
|
+
* 5. If max>0 and priv missing → new session (revise pass / different
|
|
1571
|
+
* runner machine), return { epoch: max+1, isNew: true }.
|
|
1572
|
+
*/
|
|
1573
|
+
function findActiveEpoch(okrId, runId) {
|
|
1574
|
+
const keysDir = path.join(meshPath(), 'okrs', okrId, 'audit', 'keys');
|
|
1575
|
+
let maxEpoch = 0;
|
|
1576
|
+
if (fs.existsSync(keysDir)) {
|
|
1577
|
+
const escaped = runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1578
|
+
const epochRe = new RegExp(`^${escaped}\\.epoch-(\\d+)\\.pub\\.pem$`);
|
|
1579
|
+
for (const f of fs.readdirSync(keysDir)) {
|
|
1580
|
+
const m = f.match(epochRe);
|
|
1581
|
+
if (m) {
|
|
1582
|
+
maxEpoch = Math.max(maxEpoch, parseInt(m[1], 10));
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
// Legacy fallback: bare `<runId>.pub.pem` counts as epoch 1.
|
|
1586
|
+
if (maxEpoch === 0 && fs.existsSync(knightSealLegacyPubKeyPath(okrId, runId))) {
|
|
1587
|
+
maxEpoch = 1;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if (maxEpoch === 0) {
|
|
1591
|
+
return { epoch: 1, isNewSession: true };
|
|
1592
|
+
}
|
|
1593
|
+
// Check if the max-epoch's private key still exists in tmp.
|
|
1594
|
+
const privPath = knightSealEpochPrivKeyPath(okrId, runId, maxEpoch);
|
|
1595
|
+
// For legacy compat: epoch 1 priv key might be at the legacy path.
|
|
1596
|
+
const legacyPrivPath = path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.priv.pem`);
|
|
1597
|
+
if (fs.existsSync(privPath) || (maxEpoch === 1 && fs.existsSync(legacyPrivPath))) {
|
|
1598
|
+
return { epoch: maxEpoch, isNewSession: false };
|
|
1599
|
+
}
|
|
1600
|
+
// Priv gone → new session, advance to the next epoch.
|
|
1601
|
+
return { epoch: maxEpoch + 1, isNewSession: true };
|
|
1503
1602
|
}
|
|
1504
1603
|
/**
|
|
1505
|
-
* Load the
|
|
1506
|
-
*
|
|
1604
|
+
* Load OR create the keypair for a specific (run, epoch). When
|
|
1605
|
+
* isNewSession is true, generates a fresh Ed25519 keypair and persists
|
|
1606
|
+
* BOTH the pub key (mesh) and priv key (tmpdir, mode 0600). When
|
|
1607
|
+
* isNewSession is false, loads the existing pair from disk.
|
|
1608
|
+
*
|
|
1609
|
+
* Bug O (Task #72) — replaces the old single-key `loadOrCreateRunKeypair`.
|
|
1610
|
+
* Per-epoch model means every agent session signs with its own identity,
|
|
1611
|
+
* closing the cryptographic gap that revise-agent events previously had.
|
|
1612
|
+
*
|
|
1613
|
+
* Backward compat: for epoch 1 only, if no `<runId>.epoch-1.pub.pem`
|
|
1614
|
+
* exists but the legacy `<runId>.pub.pem` does, load from the legacy
|
|
1615
|
+
* path (existing chains keep verifying without renaming files).
|
|
1507
1616
|
*/
|
|
1508
|
-
function
|
|
1509
|
-
const privPath =
|
|
1510
|
-
const pubPath =
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1617
|
+
function loadOrCreateEpochKeypair(okrId, runId, epoch, isNewSession) {
|
|
1618
|
+
const privPath = knightSealEpochPrivKeyPath(okrId, runId, epoch);
|
|
1619
|
+
const pubPath = knightSealEpochPubKeyPath(okrId, runId, epoch);
|
|
1620
|
+
const legacyPubPath = knightSealLegacyPubKeyPath(okrId, runId);
|
|
1621
|
+
const legacyPrivPath = path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.priv.pem`);
|
|
1622
|
+
if (!isNewSession) {
|
|
1623
|
+
// Load existing keypair. For epoch 1, try the epoch-suffixed path
|
|
1624
|
+
// first; fall back to legacy paths if those are what's on disk.
|
|
1625
|
+
let privPem;
|
|
1626
|
+
let pubPem;
|
|
1627
|
+
if (fs.existsSync(privPath) && fs.existsSync(pubPath)) {
|
|
1628
|
+
privPem = fs.readFileSync(privPath, 'utf8');
|
|
1629
|
+
pubPem = fs.readFileSync(pubPath, 'utf8');
|
|
1630
|
+
}
|
|
1631
|
+
else if (epoch === 1 && fs.existsSync(legacyPrivPath) && fs.existsSync(legacyPubPath)) {
|
|
1632
|
+
privPem = fs.readFileSync(legacyPrivPath, 'utf8');
|
|
1633
|
+
pubPem = fs.readFileSync(legacyPubPath, 'utf8');
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
throw new Error(`epoch-keypair-load-failed: epoch=${epoch} privPath=${privPath} pubPath=${pubPath}`);
|
|
1637
|
+
}
|
|
1514
1638
|
return {
|
|
1515
1639
|
privKey: (0, node_crypto_1.createPrivateKey)({ key: privPem, format: 'pem' }),
|
|
1516
1640
|
pubKey: (0, node_crypto_1.createPublicKey)({ key: pubPem, format: 'pem' }),
|
|
1517
1641
|
};
|
|
1518
1642
|
}
|
|
1643
|
+
// Generate + persist fresh keypair for this epoch.
|
|
1519
1644
|
const { privateKey, publicKey } = (0, node_crypto_1.generateKeyPairSync)('ed25519');
|
|
1520
1645
|
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
1521
1646
|
const pubPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
@@ -1525,19 +1650,47 @@ function loadOrCreateRunKeypair(okrId, runId) {
|
|
|
1525
1650
|
fs.writeFileSync(pubPath, pubPem, 'utf8');
|
|
1526
1651
|
return { privKey: privateKey, pubKey: publicKey };
|
|
1527
1652
|
}
|
|
1528
|
-
/**
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1653
|
+
/**
|
|
1654
|
+
* Load every epoch's public key for this run into a Map<epoch, KeyObject>.
|
|
1655
|
+
* Used by audit-verify-chain. Includes the legacy `<runId>.pub.pem` as
|
|
1656
|
+
* epoch 1 when present (so old chains verify without renaming).
|
|
1657
|
+
*/
|
|
1658
|
+
function loadAllEpochPubKeys(okrId, runId) {
|
|
1659
|
+
const keysDir = path.join(meshPath(), 'okrs', okrId, 'audit', 'keys');
|
|
1660
|
+
const result = new Map();
|
|
1661
|
+
if (!fs.existsSync(keysDir)) {
|
|
1662
|
+
return result;
|
|
1533
1663
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1664
|
+
const escaped = runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1665
|
+
const epochRe = new RegExp(`^${escaped}\\.epoch-(\\d+)\\.pub\\.pem$`);
|
|
1666
|
+
for (const f of fs.readdirSync(keysDir)) {
|
|
1667
|
+
const m = f.match(epochRe);
|
|
1668
|
+
if (!m) {
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const epoch = parseInt(m[1], 10);
|
|
1672
|
+
try {
|
|
1673
|
+
const pem = fs.readFileSync(path.join(keysDir, f), 'utf8');
|
|
1674
|
+
result.set(epoch, (0, node_crypto_1.createPublicKey)({ key: pem, format: 'pem' }));
|
|
1675
|
+
}
|
|
1676
|
+
catch { /* skip unreadable */ }
|
|
1536
1677
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1678
|
+
// Legacy fallback: bare `<runId>.pub.pem` populates epoch 1 if not
|
|
1679
|
+
// already set by an epoch-suffixed file.
|
|
1680
|
+
if (!result.has(1)) {
|
|
1681
|
+
const legacyPath = knightSealLegacyPubKeyPath(okrId, runId);
|
|
1682
|
+
if (fs.existsSync(legacyPath)) {
|
|
1683
|
+
try {
|
|
1684
|
+
result.set(1, (0, node_crypto_1.createPublicKey)({ key: fs.readFileSync(legacyPath, 'utf8'), format: 'pem' }));
|
|
1685
|
+
}
|
|
1686
|
+
catch { /* skip */ }
|
|
1687
|
+
}
|
|
1539
1688
|
}
|
|
1689
|
+
return result;
|
|
1540
1690
|
}
|
|
1691
|
+
// tryLoadRunPublicKey removed in Bug O (Task #72) — the per-epoch
|
|
1692
|
+
// model uses loadAllEpochPubKeys() to load every signer's key.
|
|
1693
|
+
// Legacy callers should switch to the multi-key flow.
|
|
1541
1694
|
function signEventHash(privKey, eventHashHex) {
|
|
1542
1695
|
// Ed25519 signs raw bytes — we sign the UTF-8 bytes of the hex digest,
|
|
1543
1696
|
// which is the canonical chain anchor. Output: 64-byte signature, hex.
|
|
@@ -1600,8 +1753,48 @@ const handleAuditEmitEvent = async (input) => {
|
|
|
1600
1753
|
nextEventId = last.event_id + 1;
|
|
1601
1754
|
}
|
|
1602
1755
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1756
|
+
// Bug O (Task #72) — per-epoch signing.
|
|
1757
|
+
//
|
|
1758
|
+
// Workflow-emitted events stay unsigned-by-design (CI infrastructure,
|
|
1759
|
+
// not an agent). Everything else (original agent + revise agent)
|
|
1760
|
+
// gets a per-epoch signature; each agent session = one signer epoch.
|
|
1761
|
+
//
|
|
1762
|
+
// Backward compat: legacy chains with emitted_by:'revise-agent' +
|
|
1763
|
+
// unsigned events still verify (chain-verify accepts the legacy
|
|
1764
|
+
// attribution). New chains sign all agent events.
|
|
1765
|
+
const emittedBy = payload?.emitted_by;
|
|
1766
|
+
const isWorkflowEmit = emittedBy === 'workflow';
|
|
1767
|
+
let privKey = null;
|
|
1768
|
+
let publicKeyPem = null;
|
|
1769
|
+
let signerEpoch = null;
|
|
1770
|
+
let isFirstEventOfEpoch = false;
|
|
1771
|
+
if (!isWorkflowEmit) {
|
|
1772
|
+
// Agent context (original OR revise). Determine the current
|
|
1773
|
+
// epoch + load-or-generate its keypair.
|
|
1774
|
+
const { epoch, isNewSession } = findActiveEpoch(okrId, runId);
|
|
1775
|
+
signerEpoch = epoch;
|
|
1776
|
+
const keypair = loadOrCreateEpochKeypair(okrId, runId, epoch, isNewSession);
|
|
1777
|
+
privKey = keypair.privKey;
|
|
1778
|
+
publicKeyPem = keypair.pubKey.export({ type: 'spki', format: 'pem' });
|
|
1779
|
+
// First event of each epoch embeds the pub key inline so a
|
|
1780
|
+
// single-line audit excerpt names its signer.
|
|
1781
|
+
if (fs.existsSync(filePath)) {
|
|
1782
|
+
const existing = fs.readFileSync(filePath, 'utf8').split('\n').filter(l => l.trim().length > 0);
|
|
1783
|
+
isFirstEventOfEpoch = !existing.some(line => {
|
|
1784
|
+
try {
|
|
1785
|
+
const e = JSON.parse(line);
|
|
1786
|
+
const eEpoch = typeof e.signer_epoch === 'number' ? e.signer_epoch : 1;
|
|
1787
|
+
return eEpoch === epoch;
|
|
1788
|
+
}
|
|
1789
|
+
catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
isFirstEventOfEpoch = true;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1605
1798
|
const draft = {
|
|
1606
1799
|
event_id: nextEventId,
|
|
1607
1800
|
ts: new Date().toISOString(),
|
|
@@ -1612,19 +1805,23 @@ const handleAuditEmitEvent = async (input) => {
|
|
|
1612
1805
|
event_kind: eventKind,
|
|
1613
1806
|
payload,
|
|
1614
1807
|
prev_event_hash: prevHash,
|
|
1615
|
-
// Embed
|
|
1616
|
-
//
|
|
1617
|
-
|
|
1618
|
-
// the JSONL with no integrity gain.
|
|
1619
|
-
public_key: nextEventId === 1 ? publicKeyPem : null,
|
|
1808
|
+
// Embed pub key on first event of each epoch (agent only).
|
|
1809
|
+
// Workflow events carry no public_key — they're system-trusted.
|
|
1810
|
+
public_key: isFirstEventOfEpoch ? publicKeyPem : null,
|
|
1620
1811
|
event_hash: '',
|
|
1621
1812
|
signature: '',
|
|
1622
1813
|
};
|
|
1814
|
+
// signer_epoch present on all agent-signed events; absent on
|
|
1815
|
+
// workflow events. Older chains without this field default to
|
|
1816
|
+
// epoch 1 in chain-verify (backward compat).
|
|
1817
|
+
if (signerEpoch !== null) {
|
|
1818
|
+
draft.signer_epoch = signerEpoch;
|
|
1819
|
+
}
|
|
1623
1820
|
const hash = sha256(canonicalStringify(draft));
|
|
1624
|
-
const signature = signEventHash(privKey, hash);
|
|
1821
|
+
const signature = privKey ? signEventHash(privKey, hash) : '';
|
|
1625
1822
|
const finalEvent = { ...draft, event_hash: hash, signature };
|
|
1626
1823
|
fs.appendFileSync(filePath, JSON.stringify(finalEvent) + '\n', 'utf8');
|
|
1627
|
-
return { ok: true, chainHead: hash, eventId: nextEventId, sealed:
|
|
1824
|
+
return { ok: true, chainHead: hash, eventId: nextEventId, sealed: signature !== '' };
|
|
1628
1825
|
}
|
|
1629
1826
|
finally {
|
|
1630
1827
|
if (lockFd !== null) {
|
|
@@ -1677,11 +1874,19 @@ const handleAuditVerifyChain = async (input) => {
|
|
|
1677
1874
|
catch (err) {
|
|
1678
1875
|
return { ok: false, reason: `read-failed: ${err.message}` };
|
|
1679
1876
|
}
|
|
1680
|
-
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
|
|
1877
|
+
// Bug O (Task #72) — load ALL epoch pub keys (epoch-1, epoch-2, ...).
|
|
1878
|
+
// Each agent session signs with its own epoch key. Includes legacy
|
|
1879
|
+
// <runId>.pub.pem as epoch-1 if no epoch-suffixed files exist.
|
|
1880
|
+
const epochPubKeys = loadAllEpochPubKeys(okrId, runId);
|
|
1881
|
+
// Track signature state across the whole chain.
|
|
1684
1882
|
let signedCount = 0;
|
|
1883
|
+
let workflowUnsignedCount = 0; // post-agent workflow-emitted events, unsigned by-design
|
|
1884
|
+
// P9 (Bug-P / Codex audit): revise-agent unsigned events get their own
|
|
1885
|
+
// bucket so we can decide legitimacy chain-by-chain — legacy chains
|
|
1886
|
+
// (no per-epoch signing anywhere) keep the old allowance; per-epoch
|
|
1887
|
+
// chains (any event with `signer_epoch`) require revise-agent to sign.
|
|
1888
|
+
let reviseAgentUnsignedCount = 0;
|
|
1889
|
+
let chainUsesPerEpochSigning = false;
|
|
1685
1890
|
let prev = null;
|
|
1686
1891
|
for (let i = 0; i < lines.length; i++) {
|
|
1687
1892
|
let event;
|
|
@@ -1712,25 +1917,88 @@ const handleAuditVerifyChain = async (input) => {
|
|
|
1712
1917
|
if (recordedHash !== recomputed) {
|
|
1713
1918
|
return { ok: false, reason: `forged-hash-line-${i + 1}: recorded=${recordedHash.slice(0, 16)}… recomputed=${recomputed.slice(0, 16)}…` };
|
|
1714
1919
|
}
|
|
1715
|
-
|
|
1920
|
+
// Bug K + N (cert-run-5): post-agent events emitted by the workflow
|
|
1921
|
+
// (e.g. the synthetic self_review backfill that runs AFTER the agent
|
|
1922
|
+
// session ended) genuinely cannot sign — the ephemeral private key
|
|
1923
|
+
// is gone by then. `payload.emitted_by: 'workflow'` is the legitimate
|
|
1924
|
+
// unsigned attribution.
|
|
1925
|
+
//
|
|
1926
|
+
// P9 (Bug-P / Codex audit) — `revise-agent` used to share the same
|
|
1927
|
+
// legitimate-unsigned bucket because Bug N landed BEFORE Bug O. With
|
|
1928
|
+
// per-epoch signing (Bug O), a revise-agent session DOES have an
|
|
1929
|
+
// ephemeral key and DOES sign its events. So an unsigned revise-agent
|
|
1930
|
+
// event is now only legitimate on LEGACY chains — chains where no
|
|
1931
|
+
// event carries `signer_epoch`. Tracking that requires a chain-level
|
|
1932
|
+
// verdict, so we count unsigned revise-agent events into a separate
|
|
1933
|
+
// bucket and decide legitimacy after the loop sees whether the chain
|
|
1934
|
+
// uses per-epoch signing at all.
|
|
1935
|
+
const eventPayload = event.payload;
|
|
1936
|
+
const emittedBy = eventPayload?.emitted_by;
|
|
1937
|
+
const isWorkflowUnsigned = emittedBy === 'workflow';
|
|
1938
|
+
const isReviseAgentUnsigned = emittedBy === 'revise-agent';
|
|
1939
|
+
if (typeof event.signer_epoch === 'number') {
|
|
1940
|
+
chainUsesPerEpochSigning = true;
|
|
1941
|
+
}
|
|
1942
|
+
if (recordedSignature !== null && recordedSignature !== '') {
|
|
1716
1943
|
signedCount++;
|
|
1717
1944
|
}
|
|
1945
|
+
else if (isWorkflowUnsigned) {
|
|
1946
|
+
workflowUnsignedCount++;
|
|
1947
|
+
}
|
|
1948
|
+
else if (isReviseAgentUnsigned) {
|
|
1949
|
+
reviseAgentUnsignedCount++;
|
|
1950
|
+
}
|
|
1718
1951
|
prev = recordedHash;
|
|
1719
1952
|
}
|
|
1720
|
-
//
|
|
1953
|
+
// P9: legacy chains (pre-Bug-O — no signer_epoch on any event) keep
|
|
1954
|
+
// the broad allowance. New chains (any event carries signer_epoch)
|
|
1955
|
+
// require revise-agent events to be signed; an unsigned revise-agent
|
|
1956
|
+
// event on a per-epoch chain is now a real chain-integrity failure.
|
|
1957
|
+
if (chainUsesPerEpochSigning && reviseAgentUnsignedCount > 0) {
|
|
1958
|
+
return {
|
|
1959
|
+
ok: false,
|
|
1960
|
+
reason: `revise-agent-unsigned-on-per-epoch-chain: ${reviseAgentUnsignedCount} revise-agent events without signatures; per-epoch chains require revise-agent to sign with its own epoch key (Bug O contract)`,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
// Legacy chains: roll revise-agent unsigned into the workflow-unsigned
|
|
1964
|
+
// bucket so the downstream "agent_event_count" math still excludes them.
|
|
1965
|
+
workflowUnsignedCount += reviseAgentUnsignedCount;
|
|
1966
|
+
// Knight's Seal verification: every AGENT-emitted event must be signed
|
|
1967
|
+
// by its declared signer_epoch's pub key. Workflow-unsigned events are
|
|
1968
|
+
// excluded from the denominator (their emitted_by: 'workflow' marker
|
|
1969
|
+
// proves they're legitimate-unsigned).
|
|
1721
1970
|
const sealed = signedCount > 0;
|
|
1971
|
+
const agentEventCount = lines.length - workflowUnsignedCount;
|
|
1722
1972
|
let sealVerified = false;
|
|
1723
1973
|
if (sealed) {
|
|
1724
|
-
if (signedCount !==
|
|
1725
|
-
return { ok: false, reason: `partial-signatures: ${signedCount}/${
|
|
1974
|
+
if (signedCount !== agentEventCount) {
|
|
1975
|
+
return { ok: false, reason: `partial-signatures: ${signedCount}/${agentEventCount} agent-emitted events signed (chain tampered; ${workflowUnsignedCount} workflow-emitted unsigned by-design)` };
|
|
1726
1976
|
}
|
|
1727
|
-
if (
|
|
1728
|
-
return { ok: false, reason: `public-key-missing: events are signed but no <runId>.pub.pem found in audit/keys/` };
|
|
1977
|
+
if (epochPubKeys.size === 0) {
|
|
1978
|
+
return { ok: false, reason: `public-key-missing: events are signed but no <runId>.epoch-*.pub.pem (or legacy <runId>.pub.pem) found in audit/keys/` };
|
|
1729
1979
|
}
|
|
1730
1980
|
for (let i = 0; i < lines.length; i++) {
|
|
1731
1981
|
const event = JSON.parse(lines[i]);
|
|
1982
|
+
const emittedBy = event.payload?.emitted_by;
|
|
1983
|
+
// P9: workflow-emitted unsigned events are always legitimate
|
|
1984
|
+
// (the post-agent context genuinely has no private key). For
|
|
1985
|
+
// revise-agent unsigned events, the loop above already returned
|
|
1986
|
+
// an error if we're on a per-epoch chain, so reaching this point
|
|
1987
|
+
// means we're on a legacy chain where the looser bucket applies.
|
|
1988
|
+
const isLegitimateUnsigned = (emittedBy === 'workflow' || emittedBy === 'revise-agent')
|
|
1989
|
+
&& (!event.signature || event.signature === '');
|
|
1990
|
+
if (isLegitimateUnsigned) {
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
// Bug O (Task #72) — per-epoch verification. Events default to
|
|
1994
|
+
// epoch 1 if signer_epoch absent (legacy chains).
|
|
1995
|
+
const epoch = typeof event.signer_epoch === 'number' ? event.signer_epoch : 1;
|
|
1996
|
+
const pubKey = epochPubKeys.get(epoch);
|
|
1997
|
+
if (!pubKey) {
|
|
1998
|
+
return { ok: false, reason: `pub-key-missing-for-epoch-${epoch}-line-${i + 1}: chain references epoch ${epoch} but no <runId>.epoch-${epoch}.pub.pem on disk` };
|
|
1999
|
+
}
|
|
1732
2000
|
if (!verifyEventSignature(pubKey, event.event_hash, event.signature)) {
|
|
1733
|
-
return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed` };
|
|
2001
|
+
return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed against epoch-${epoch} pub key` };
|
|
1734
2002
|
}
|
|
1735
2003
|
}
|
|
1736
2004
|
sealVerified = true;
|
|
@@ -1810,6 +2078,15 @@ async function runSkill(name, input) {
|
|
|
1810
2078
|
if (!result.ok) {
|
|
1811
2079
|
payload.reason = result.reason;
|
|
1812
2080
|
}
|
|
2081
|
+
// Bug O (Task #72) — per-epoch signing now handles revise-agent
|
|
2082
|
+
// context natively. handleAuditEmitEvent's findActiveEpoch()
|
|
2083
|
+
// detects revise-context from filesystem state and advances to
|
|
2084
|
+
// a fresh epoch with its own keypair. No payload tagging needed
|
|
2085
|
+
// here — every agent-emitted event gets a real signature
|
|
2086
|
+
// attributable to a specific signer_epoch. The legacy
|
|
2087
|
+
// emitted_by:'revise-agent' attribution (Bug N) is still
|
|
2088
|
+
// accepted by chain-verify for backward compat with chains
|
|
2089
|
+
// created before this commit, but new code doesn't emit it.
|
|
1813
2090
|
// Best-effort: an audit-write failure must not shadow the real
|
|
1814
2091
|
// skill result. But we MUST surface the failure to stderr — pre-
|
|
1815
2092
|
// B28a.v1.1 these were silently swallowed and PR #108 dropped 3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maintainabilityai/research-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "Research + PRD agent runner — orchestrates the Archeologist and PRD pipelines for the MaintainabilityAI governance mesh",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MaintainabilityAI",
|