@lifeaitools/rdc-skills 0.24.3 → 0.24.5
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/git-sha.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const os = require('os');
|
|
13
13
|
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
14
15
|
const { execFileSync } = require('child_process');
|
|
15
16
|
const hookLog = require('./hook-logger');
|
|
16
17
|
|
|
@@ -26,6 +27,21 @@ const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
|
|
|
26
27
|
// regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
|
|
27
28
|
const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
|
|
28
29
|
|
|
30
|
+
// Truth Gate 3.0 — Layer 3 (validator re-run receipt).
|
|
31
|
+
// Ratified contract (c): "no done without a validator re-run receipt with a
|
|
32
|
+
// matching live nonce + running git_sha." This layer is FLAG-GATED and
|
|
33
|
+
// default-OFF so it does not break in-flight build closures; it activates at
|
|
34
|
+
// deploy by flipping the flag ON. When ON it is FAIL-CLOSED.
|
|
35
|
+
// Running-brain health endpoint — the source of the actually-running git_sha
|
|
36
|
+
// to pin against (mirrors .claude/hooks/truth-gate.mjs ~line 112).
|
|
37
|
+
const BRAIN_HEALTH_URL = process.env.RDC_BRAIN_HEALTH_URL || 'http://127.0.0.1:3109/health';
|
|
38
|
+
// Canonical signed field order for a VALIDATOR re-run receipt. MUST match
|
|
39
|
+
// .claude/hooks/lib/receipt.mjs VALIDATOR_SIGNED_FIELDS exactly (the gate has
|
|
40
|
+
// no access to that ESM lib, so the contract is mirrored here, byte-for-byte).
|
|
41
|
+
const VALIDATOR_SIGNED_FIELDS = [
|
|
42
|
+
'claim', 'witness', 'git_sha', 'nonce', 'command', 'result', 'nonce_in_output', 'ts',
|
|
43
|
+
];
|
|
44
|
+
|
|
29
45
|
function readStdin() {
|
|
30
46
|
return new Promise((resolve) => {
|
|
31
47
|
let input = '';
|
|
@@ -389,6 +405,256 @@ async function loadEvidenceLib() {
|
|
|
389
405
|
return _evidenceLib;
|
|
390
406
|
}
|
|
391
407
|
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
// Truth Gate 3.0 — Layer 3 (validator re-run receipt). FLAG-GATED, default-OFF.
|
|
410
|
+
//
|
|
411
|
+
// Ratified contract (c): a `done` close requires a chain-stored, HMAC-valid,
|
|
412
|
+
// fresh-nonce, running-sha-pinned validator receipt. Nothing consumed the
|
|
413
|
+
// receipt layer before this. To avoid breaking in-flight closures, the check is
|
|
414
|
+
// behind a flag (default OFF → behaves exactly as today). When the flag is ON it
|
|
415
|
+
// is FAIL-CLOSED: a missing / forged / stale / replayed / wrong-sha receipt, or
|
|
416
|
+
// an inability to evaluate, DENIES the close.
|
|
417
|
+
// ===========================================================================
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Is the Layer-3 validator-receipt requirement enabled?
|
|
421
|
+
* Default OFF. Turned on at deploy via either:
|
|
422
|
+
* - env RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT in {1,true,on,yes}, OR
|
|
423
|
+
* - a DB row in public.truthgate_flags(flag,enabled) with flag =
|
|
424
|
+
* 'require_validator_receipt' and enabled = true (best-effort; a lookup
|
|
425
|
+
* failure does NOT enable the flag — absence is OFF).
|
|
426
|
+
* The env is the authoritative, test-stable switch; the DB lookup is an optional
|
|
427
|
+
* deploy-time toggle. Either being true enables it.
|
|
428
|
+
*/
|
|
429
|
+
function truthGateFlagEnabled(flag) {
|
|
430
|
+
const envName = 'RDC_TRUTHGATE_' + String(flag || '').toUpperCase();
|
|
431
|
+
const v = String(process.env[envName] || '').trim().toLowerCase();
|
|
432
|
+
return v === '1' || v === 'true' || v === 'on' || v === 'yes';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Best-effort DB toggle for the flag. Absence / error → false (stays OFF). */
|
|
436
|
+
async function truthGateFlagEnabledDb(flag) {
|
|
437
|
+
try {
|
|
438
|
+
const rows = await supabaseGet(
|
|
439
|
+
`truthgate_flags?flag=eq.${encodeURIComponent(flag)}&select=enabled&limit=1`,
|
|
440
|
+
);
|
|
441
|
+
return Array.isArray(rows) && rows[0] && rows[0].enabled === true;
|
|
442
|
+
} catch (_) {
|
|
443
|
+
return false; // a missing table or a lookup failure must NOT enable the gate
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Canonical JSON the HMAC is computed over — mirrors receipt.mjs validatorCanonical(). */
|
|
448
|
+
function validatorCanonical(receipt) {
|
|
449
|
+
const picked = {};
|
|
450
|
+
for (const k of VALIDATOR_SIGNED_FIELDS) picked[k] = receipt[k] ?? null;
|
|
451
|
+
return JSON.stringify(picked);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Verify the validator receipt HMAC with the truth-gate secret (constant-time). */
|
|
455
|
+
function verifyValidatorSig(receipt, secret) {
|
|
456
|
+
if (!secret || !receipt || !receipt.hmac) return false;
|
|
457
|
+
let expected;
|
|
458
|
+
try {
|
|
459
|
+
expected = crypto.createHmac('sha256', secret).update(validatorCanonical(receipt)).digest('hex');
|
|
460
|
+
} catch (_) { return false; }
|
|
461
|
+
let a, b;
|
|
462
|
+
try {
|
|
463
|
+
a = Buffer.from(expected, 'hex');
|
|
464
|
+
b = Buffer.from(String(receipt.hmac), 'hex');
|
|
465
|
+
} catch (_) { return false; }
|
|
466
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Does a re-run result object read as a PASS? Mirrors receipt.mjs resultIsPass(). */
|
|
470
|
+
function resultIsPass(result) {
|
|
471
|
+
if (!result || typeof result !== 'object') return false;
|
|
472
|
+
if (typeof result.exit_code === 'number') return result.exit_code === 0;
|
|
473
|
+
if (typeof result.exitCode === 'number') return result.exitCode === 0;
|
|
474
|
+
if (typeof result.passed === 'number') {
|
|
475
|
+
if (typeof result.failed === 'number') return result.failed === 0;
|
|
476
|
+
if (typeof result.total === 'number') return result.passed === result.total;
|
|
477
|
+
}
|
|
478
|
+
if (typeof result.tsc_errors === 'number') return result.tsc_errors === 0;
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Validate a VALIDATOR re-run receipt for a `done` close. Pure + unit-testable;
|
|
484
|
+
* throws GateDenied on any failure so the caller fail-closes. ALL must hold:
|
|
485
|
+
* - a secret is available (else INFRA → DENY, never a silent pass)
|
|
486
|
+
* - HMAC valid (signed by the validator path, not hand-written)
|
|
487
|
+
* - witness ∈ allow-list (never 'agent' / self-witnessed)
|
|
488
|
+
* - git_sha === actualRunningSha (re-run pinned to the running brain HEAD)
|
|
489
|
+
* - nonce_in_output === true (fresh nonce surfaced in captured output)
|
|
490
|
+
* - result reads as a pass
|
|
491
|
+
* - nonce NOT in seenNonces (durable replay set, loaded from the chain store)
|
|
492
|
+
* - ts within maxAgeMin (fresh)
|
|
493
|
+
*
|
|
494
|
+
* @param {object} receipt
|
|
495
|
+
* @param {object} opts
|
|
496
|
+
* @param {string} opts.secret HMAC secret (truth-gate-secret)
|
|
497
|
+
* @param {string} opts.actualRunningSha live brain HEAD to pin against (required)
|
|
498
|
+
* @param {string[]} [opts.seenNonces] durable nonces already consumed
|
|
499
|
+
* @param {number} [opts.maxAgeMin=30]
|
|
500
|
+
* @param {number} [opts.nowMs]
|
|
501
|
+
*/
|
|
502
|
+
function assertValidatorReceipt(receipt, opts = {}) {
|
|
503
|
+
const { secret, actualRunningSha, seenNonces = [], maxAgeMin = 30, nowMs } = opts;
|
|
504
|
+
if (!secret) {
|
|
505
|
+
deny(
|
|
506
|
+
'L3: done rejected — INFRA: no truth-gate HMAC secret available to verify the validator receipt. ' +
|
|
507
|
+
'Cannot evaluate the Layer-3 receipt; fail-closed (INFRA is a BLOCK, not an allow).',
|
|
508
|
+
{ layer: 3 },
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (!receipt || typeof receipt !== 'object') {
|
|
512
|
+
deny('L3: done rejected — no validator re-run receipt found for this work item (flag is ON).', { layer: 3 });
|
|
513
|
+
}
|
|
514
|
+
if (!verifyValidatorSig(receipt, secret)) {
|
|
515
|
+
deny('L3: done rejected — validator receipt HMAC invalid/absent (hand-written or tampered receipt).', { layer: 3 });
|
|
516
|
+
}
|
|
517
|
+
if (!WITNESS_ALLOWLIST.has(String(receipt.witness || ''))) {
|
|
518
|
+
deny(
|
|
519
|
+
'L3: done rejected — validator receipt witness "' + (receipt.witness || '<missing>') +
|
|
520
|
+
'" is not in {validator-rerun, ci, human-review} (the doer cannot self-witness).',
|
|
521
|
+
{ layer: 3, witness: receipt.witness },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (!receipt.git_sha) {
|
|
525
|
+
deny('L3: done rejected — validator receipt git_sha missing.', { layer: 3 });
|
|
526
|
+
}
|
|
527
|
+
if (actualRunningSha && String(receipt.git_sha) !== String(actualRunningSha)) {
|
|
528
|
+
deny(
|
|
529
|
+
'L3: done rejected — validator receipt git_sha ' + receipt.git_sha +
|
|
530
|
+
' != the actually-running brain HEAD ' + actualRunningSha + ' (receipt not pinned to the live runtime).',
|
|
531
|
+
{ layer: 3, receiptSha: receipt.git_sha, runningSha: actualRunningSha },
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (receipt.nonce_in_output !== true) {
|
|
535
|
+
deny('L3: done rejected — validator receipt nonce_in_output != true (fresh nonce absent from captured output: cached/replayed artifact).', { layer: 3 });
|
|
536
|
+
}
|
|
537
|
+
if (!resultIsPass(receipt.result)) {
|
|
538
|
+
deny('L3: done rejected — validator receipt result did not read as a pass (re-run failed or unparseable result).', { layer: 3 });
|
|
539
|
+
}
|
|
540
|
+
if (receipt.nonce && Array.isArray(seenNonces) && seenNonces.includes(receipt.nonce)) {
|
|
541
|
+
deny('L3: done rejected — validator receipt nonce ' + receipt.nonce + ' already consumed in the chain (replayed) — stale.', { layer: 3, nonce: receipt.nonce });
|
|
542
|
+
}
|
|
543
|
+
const t = Date.parse(receipt.ts);
|
|
544
|
+
if (Number.isNaN(t)) deny('L3: done rejected — validator receipt ts is unparseable.', { layer: 3 });
|
|
545
|
+
const now = typeof nowMs === 'number' ? nowMs : Date.now();
|
|
546
|
+
if (now - t > maxAgeMin * 60_000) {
|
|
547
|
+
deny('L3: done rejected — validator receipt is stale (> ' + maxAgeMin + 'm old).', { layer: 3 });
|
|
548
|
+
}
|
|
549
|
+
// No denial → the Layer-3 receipt is valid, fresh, pinned, and unreplayed.
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Read the truth-gate HMAC secret from clauth/env. null when unavailable. */
|
|
553
|
+
async function getTruthGateSecret() {
|
|
554
|
+
if (process.env.TRUTH_GATE_SECRET) return process.env.TRUTH_GATE_SECRET;
|
|
555
|
+
try {
|
|
556
|
+
const res = await fetch('http://127.0.0.1:52437/v/truth-gate-secret', { signal: AbortSignal.timeout(2500) });
|
|
557
|
+
if (res.ok) {
|
|
558
|
+
const text = (await res.text()).trim();
|
|
559
|
+
if (text && !text.startsWith('{')) return text;
|
|
560
|
+
}
|
|
561
|
+
} catch (_) {}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Pin against the running brain's /health git_sha (mirrors truth-gate.mjs). null on failure. */
|
|
566
|
+
async function getRunningBrainSha() {
|
|
567
|
+
try {
|
|
568
|
+
const res = await fetch(BRAIN_HEALTH_URL, { signal: AbortSignal.timeout(3000) });
|
|
569
|
+
if (!res.ok) return null;
|
|
570
|
+
const j = await res.json();
|
|
571
|
+
const sha = String(j && j.git_sha ? j.git_sha : '').trim();
|
|
572
|
+
return sha || null;
|
|
573
|
+
} catch (_) { return null; }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Newest validator receipt stored for this work item, or null. Loaded from the chain store. */
|
|
577
|
+
function pickNewestValidatorReceipt(receiptRows) {
|
|
578
|
+
if (!Array.isArray(receiptRows) || receiptRows.length === 0) return null;
|
|
579
|
+
let best = null;
|
|
580
|
+
let bestT = -Infinity;
|
|
581
|
+
for (const row of receiptRows) {
|
|
582
|
+
const payload = row && row.payload;
|
|
583
|
+
if (!payload || typeof payload !== 'object') continue;
|
|
584
|
+
const t = Date.parse(payload.ts);
|
|
585
|
+
const key = Number.isNaN(t) ? -Infinity : t;
|
|
586
|
+
if (key >= bestT) { bestT = key; best = payload; }
|
|
587
|
+
}
|
|
588
|
+
return best;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Layer-3 verification of a `done` close: require a chain-stored, HMAC-valid,
|
|
593
|
+
* fresh-nonce, running-sha-pinned validator receipt for THIS work item. Only
|
|
594
|
+
* runs when the flag is ON; otherwise it is a no-op (today's behaviour). When ON
|
|
595
|
+
* it is FAIL-CLOSED — any inability to load/evaluate the receipt is a DENY.
|
|
596
|
+
*
|
|
597
|
+
* Dependencies are injectable for unit tests via `deps`:
|
|
598
|
+
* deps.flagEnabled() → boolean
|
|
599
|
+
* deps.getSecret() → Promise<string|null>
|
|
600
|
+
* deps.getRunningSha() → Promise<string|null>
|
|
601
|
+
* deps.loadReceiptRows(id) → Promise<rows for this work item>
|
|
602
|
+
* deps.loadSeenNonces() → Promise<string[]> (durable consumed nonces)
|
|
603
|
+
*/
|
|
604
|
+
async function verifyLayer3(statusCall, item, deps = {}, nowMs) {
|
|
605
|
+
const flagEnabled = deps.flagEnabled ? await deps.flagEnabled() : false;
|
|
606
|
+
if (!flagEnabled) return; // default-OFF: behaves exactly as today
|
|
607
|
+
|
|
608
|
+
const secret = deps.getSecret ? await deps.getSecret() : await getTruthGateSecret();
|
|
609
|
+
const runningSha = deps.getRunningSha ? await deps.getRunningSha() : await getRunningBrainSha();
|
|
610
|
+
if (!runningSha) {
|
|
611
|
+
deny(
|
|
612
|
+
'L3: done rejected — INFRA: could not read the running brain git_sha from ' + BRAIN_HEALTH_URL +
|
|
613
|
+
'. Cannot pin the validator receipt to the live runtime; fail-closed (INFRA is a BLOCK).',
|
|
614
|
+
{ layer: 3 },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let receiptRows;
|
|
619
|
+
let seenNonces;
|
|
620
|
+
try {
|
|
621
|
+
receiptRows = deps.loadReceiptRows
|
|
622
|
+
? await deps.loadReceiptRows(statusCall.id)
|
|
623
|
+
: await supabaseGet(`truth_gate_receipts?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=payload&order=id.desc&limit=50`);
|
|
624
|
+
seenNonces = deps.loadSeenNonces
|
|
625
|
+
? await deps.loadSeenNonces(statusCall.id)
|
|
626
|
+
: await loadSeenNonces(statusCall.id, pickNewestValidatorReceipt(receiptRows));
|
|
627
|
+
} catch (e) {
|
|
628
|
+
deny(
|
|
629
|
+
'L3: done rejected — INFRA: could not load validator receipts/seen-nonces (' +
|
|
630
|
+
(e && e.message ? e.message : String(e)) + '); fail-closed.',
|
|
631
|
+
{ layer: 3 },
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const receipt = pickNewestValidatorReceipt(receiptRows);
|
|
636
|
+
assertValidatorReceipt(receipt, { secret, actualRunningSha: runningSha, seenNonces, nowMs });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Durable replay set: every validator-receipt nonce already consumed in the
|
|
641
|
+
* chain, EXCLUDING the candidate receipt's own nonce (so the candidate is not
|
|
642
|
+
* spuriously flagged as a replay of itself). Replay rejection is therefore
|
|
643
|
+
* durable (chain-backed), not in-memory-only.
|
|
644
|
+
*/
|
|
645
|
+
async function loadSeenNonces(workItemId, candidateReceipt) {
|
|
646
|
+
const ownNonce = candidateReceipt && candidateReceipt.nonce ? String(candidateReceipt.nonce) : null;
|
|
647
|
+
const rows = await supabaseGet(
|
|
648
|
+
`truth_gate_receipts?select=payload&payload->>nonce=not.is.null&limit=1000`,
|
|
649
|
+
);
|
|
650
|
+
const seen = [];
|
|
651
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
652
|
+
const n = r && r.payload && r.payload.nonce ? String(r.payload.nonce) : null;
|
|
653
|
+
if (n && n !== ownNonce) seen.push(n);
|
|
654
|
+
}
|
|
655
|
+
return seen;
|
|
656
|
+
}
|
|
657
|
+
|
|
392
658
|
/**
|
|
393
659
|
* Layer-2 verification of a `done` close against the real repo. Throws (caught
|
|
394
660
|
* by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
|
|
@@ -578,6 +844,24 @@ async function verifyDone(statusCall, blob) {
|
|
|
578
844
|
// Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
|
|
579
845
|
block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
|
|
580
846
|
}
|
|
847
|
+
|
|
848
|
+
// --- Truth Gate 3.0 Layer 3 — validator re-run receipt (FLAG-GATED) ---------
|
|
849
|
+
// Default-OFF: a no-op until the flag is flipped at deploy, so in-flight build
|
|
850
|
+
// closures are unaffected. When ON it is FAIL-CLOSED: requires a chain-stored,
|
|
851
|
+
// HMAC-valid, fresh-nonce, running-sha-pinned validator receipt.
|
|
852
|
+
try {
|
|
853
|
+
await verifyLayer3(statusCall, item, {
|
|
854
|
+
flagEnabled: async () =>
|
|
855
|
+
truthGateFlagEnabled('require_validator_receipt') ||
|
|
856
|
+
(await truthGateFlagEnabledDb('require_validator_receipt')),
|
|
857
|
+
});
|
|
858
|
+
} catch (e) {
|
|
859
|
+
if (e instanceof GateDenied) {
|
|
860
|
+
block(e.reason, e.details); // translate the L3 denial into a hard block
|
|
861
|
+
}
|
|
862
|
+
// Any OTHER error during L3 (flag ON) is an inability to verify => FAIL-CLOSED.
|
|
863
|
+
block('L3: done rejected — Layer-3 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
|
|
864
|
+
}
|
|
581
865
|
}
|
|
582
866
|
|
|
583
867
|
function validateTick(tick, rawTool) {
|
|
@@ -647,5 +931,14 @@ if (require.main === module) {
|
|
|
647
931
|
assertGitRepoAvailable,
|
|
648
932
|
buildCapturedShaSet,
|
|
649
933
|
WITNESS_ALLOWLIST,
|
|
934
|
+
// Truth Gate 3.0 Layer 3 (validator re-run receipt) — exported for tests.
|
|
935
|
+
verifyLayer3,
|
|
936
|
+
assertValidatorReceipt,
|
|
937
|
+
truthGateFlagEnabled,
|
|
938
|
+
validatorCanonical,
|
|
939
|
+
verifyValidatorSig,
|
|
940
|
+
resultIsPass,
|
|
941
|
+
pickNewestValidatorReceipt,
|
|
942
|
+
VALIDATOR_SIGNED_FIELDS,
|
|
650
943
|
};
|
|
651
944
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/rdc-skills",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.5",
|
|
4
4
|
"description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"validate": "node tests/validate-skills.js",
|
|
35
35
|
"rdc-design": "node scripts/rdc-design-cli.mjs",
|
|
36
36
|
"test:hooks": "node scripts/test-rdc-hooks.mjs",
|
|
37
|
-
"test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/require-work-item-on-commit.test.mjs",
|
|
37
|
+
"test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/work-item-exit-gate-l3.test.mjs && node tests/require-work-item-on-commit.test.mjs",
|
|
38
38
|
"test:mcp": "node tests/mcp.test.mjs",
|
|
39
39
|
"test:mcp:remote": "node tests/mcp.test.mjs --remote",
|
|
40
40
|
"mcp": "node bin/rdc-skills-mcp.mjs",
|
|
@@ -126,22 +126,31 @@ function runHook(payload, extraEnv = {}) {
|
|
|
126
126
|
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
128
|
// 4. PreToolUse legacy behavior preserved (warn-only, never blocks)
|
|
129
|
+
// Uses a fresh temp dir as HOME/USERPROFILE so no fixit.marker is visible
|
|
130
|
+
// regardless of real machine state — makes the warn-path assertion deterministic.
|
|
129
131
|
// ---------------------------------------------------------------------------
|
|
130
132
|
{
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
tool_input: { command: 'git commit -m "no convention and no uuid"' },
|
|
135
|
-
});
|
|
136
|
-
assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
|
|
137
|
-
assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
|
|
133
|
+
const fakeHome = mkdtempSync(join(tmpdir(), 'wic-home-'));
|
|
134
|
+
try {
|
|
135
|
+
const preEnv = { HOME: fakeHome, USERPROFILE: fakeHome };
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
const res = runHook({
|
|
138
|
+
hook_event_name: 'PreToolUse',
|
|
139
|
+
tool_name: 'Bash',
|
|
140
|
+
tool_input: { command: 'git commit -m "no convention and no uuid"' },
|
|
141
|
+
}, preEnv);
|
|
142
|
+
assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
|
|
143
|
+
assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
|
|
144
|
+
|
|
145
|
+
const ok = runHook({
|
|
146
|
+
hook_event_name: 'PreToolUse',
|
|
147
|
+
tool_name: 'Bash',
|
|
148
|
+
tool_input: { command: 'git commit -m "feat(x): conventional"' },
|
|
149
|
+
}, preEnv);
|
|
150
|
+
assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
153
|
+
}
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Truth Gate 3.0 — Layer 3 (validator re-run receipt) tests for the closure gate.
|
|
4
|
+
*
|
|
5
|
+
* Exercises the FLAG-GATED Layer-3 check wired into work-item-exit-gate.js. The
|
|
6
|
+
* receipt store, running-sha probe, and secret are injected via verifyLayer3's
|
|
7
|
+
* `deps`, so the test runs fully offline (no DB, no brain, no clauth). The HMAC
|
|
8
|
+
* is computed with a fixed secret using the SAME canonical field order the gate
|
|
9
|
+
* mirrors from receipt.mjs.
|
|
10
|
+
*
|
|
11
|
+
* Branches proven:
|
|
12
|
+
* A. flag OFF → ALLOW as today (no-op, no receipt needed)
|
|
13
|
+
* B. flag ON + valid receipt (fresh nonce, pinned sha, unreplayed) → ALLOW
|
|
14
|
+
* C. flag ON + NO receipt → DENY
|
|
15
|
+
* D. flag ON + forged/tampered HMAC → DENY
|
|
16
|
+
* E. flag ON + stale-by-age receipt → DENY
|
|
17
|
+
* F. flag ON + replayed nonce (in durable seen-set) → DENY
|
|
18
|
+
* G. flag ON + wrong git_sha (not the running brain) → DENY
|
|
19
|
+
* H. flag ON + nonce_in_output:false (cached artifact) → DENY
|
|
20
|
+
* I. flag ON + witness:"agent" (self-witness) → DENY
|
|
21
|
+
* J. flag ON + INFRA: no secret → DENY ; no running sha → DENY
|
|
22
|
+
* K. truthGateFlagEnabled reads the env switch
|
|
23
|
+
*
|
|
24
|
+
* Run: node tests/work-item-exit-gate-l3.test.mjs
|
|
25
|
+
*/
|
|
26
|
+
import { resolve, dirname, join } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import crypto from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
33
|
+
const HOOK = join(REPO_ROOT, 'hooks', 'work-item-exit-gate.js');
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const gate = require(HOOK);
|
|
37
|
+
|
|
38
|
+
const failures = [];
|
|
39
|
+
function assert(name, condition, detail = '') {
|
|
40
|
+
if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
|
|
41
|
+
else process.stdout.write(` ok ${name}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SECRET = 'test-secret-not-real';
|
|
45
|
+
const SHA = 'c3189c9d58a37d648e9de4a6bcd7d46772053eea';
|
|
46
|
+
const NOW = Date.parse('2026-06-25T12:00:00Z');
|
|
47
|
+
const SIGNED_FIELDS = gate.VALIDATOR_SIGNED_FIELDS;
|
|
48
|
+
|
|
49
|
+
function signValidator(receipt, secret = SECRET) {
|
|
50
|
+
const picked = {};
|
|
51
|
+
for (const k of SIGNED_FIELDS) picked[k] = receipt[k] ?? null;
|
|
52
|
+
return crypto.createHmac('sha256', secret).update(JSON.stringify(picked)).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A signed, otherwise-valid validator receipt; override fields to break it. */
|
|
56
|
+
function freshReceipt(over = {}) {
|
|
57
|
+
const r = {
|
|
58
|
+
claim: 'WP-4 validator re-run passes',
|
|
59
|
+
witness: 'validator-rerun',
|
|
60
|
+
git_sha: SHA,
|
|
61
|
+
nonce: 'vrr-fresh-abc123',
|
|
62
|
+
command: 'node scripts/needle-verify.mjs',
|
|
63
|
+
result: { exit_code: 0 },
|
|
64
|
+
nonce_in_output: true,
|
|
65
|
+
ts: new Date(NOW - 60_000).toISOString(), // 1 min old
|
|
66
|
+
...over,
|
|
67
|
+
};
|
|
68
|
+
r.hmac = signValidator(r);
|
|
69
|
+
return r;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const statusCall = { id: '11111111-2222-3333-4444-555555555555', actorSessionId: 'validator-x', actorRole: 'validator' };
|
|
73
|
+
const item = { id: statusCall.id, item_type: 'task', status: 'review', session_id: 'sess-origin' };
|
|
74
|
+
|
|
75
|
+
/** Build a deps object that drives verifyLayer3 fully offline. */
|
|
76
|
+
function deps({ flag = true, secret = SECRET, runningSha = SHA, receipts = [], seenNonces = [] } = {}) {
|
|
77
|
+
return {
|
|
78
|
+
flagEnabled: async () => flag,
|
|
79
|
+
getSecret: async () => secret,
|
|
80
|
+
getRunningSha: async () => runningSha,
|
|
81
|
+
loadReceiptRows: async () => receipts.map((p) => ({ payload: p })),
|
|
82
|
+
loadSeenNonces: async () => seenNonces,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run verifyLayer3 and return the GateDenied reason, or null if it allowed. */
|
|
87
|
+
async function runL3(d) {
|
|
88
|
+
try {
|
|
89
|
+
await gate.verifyLayer3(statusCall, item, d, NOW);
|
|
90
|
+
return null; // ALLOW
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e instanceof gate.GateDenied) return e.reason;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await (async () => {
|
|
98
|
+
// A. flag OFF → ALLOW as today (no receipt needed at all)
|
|
99
|
+
{
|
|
100
|
+
const r = await runL3(deps({ flag: false, receipts: [] }));
|
|
101
|
+
assert('A. flag OFF → ALLOW (no-op, no receipt)', r === null, r || '');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// B. flag ON + valid receipt → ALLOW
|
|
105
|
+
{
|
|
106
|
+
const r = await runL3(deps({ receipts: [freshReceipt()] }));
|
|
107
|
+
assert('B. flag ON + valid receipt → ALLOW', r === null, r || '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// C. flag ON + NO receipt → DENY
|
|
111
|
+
{
|
|
112
|
+
const r = await runL3(deps({ receipts: [] }));
|
|
113
|
+
assert('C. flag ON + no receipt → DENY', r && /no validator re-run receipt found/.test(r), r || 'no denial');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// D. flag ON + forged/tampered HMAC → DENY
|
|
117
|
+
{
|
|
118
|
+
const bad = freshReceipt();
|
|
119
|
+
bad.claim = 'TAMPERED after signing'; // signature no longer matches
|
|
120
|
+
const r = await runL3(deps({ receipts: [bad] }));
|
|
121
|
+
assert('D. flag ON + forged HMAC → DENY', r && /HMAC invalid\/absent/.test(r), r || 'no denial');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// E. flag ON + stale-by-age receipt → DENY
|
|
125
|
+
{
|
|
126
|
+
const stale = freshReceipt({ ts: new Date(NOW - 60 * 60_000).toISOString() });
|
|
127
|
+
const r = await runL3(deps({ receipts: [stale] }));
|
|
128
|
+
assert('E. flag ON + stale receipt → DENY', r && /stale/.test(r), r || 'no denial');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// F. flag ON + replayed nonce (in durable seen-set) → DENY
|
|
132
|
+
{
|
|
133
|
+
const rec = freshReceipt();
|
|
134
|
+
const r = await runL3(deps({ receipts: [rec], seenNonces: [rec.nonce] }));
|
|
135
|
+
assert('F. flag ON + replayed nonce → DENY', r && /replayed|already consumed/.test(r), r || 'no denial');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// G. flag ON + wrong git_sha (not the running brain) → DENY
|
|
139
|
+
{
|
|
140
|
+
const r = await runL3(deps({ receipts: [freshReceipt()], runningSha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' }));
|
|
141
|
+
assert('G. flag ON + wrong git_sha → DENY', r && /git_sha/.test(r), r || 'no denial');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// H. flag ON + nonce_in_output:false (cached artifact) → DENY
|
|
145
|
+
{
|
|
146
|
+
const r = await runL3(deps({ receipts: [freshReceipt({ nonce_in_output: false })] }));
|
|
147
|
+
assert('H. flag ON + nonce_in_output:false → DENY', r && /nonce_in_output|cached|replay/.test(r), r || 'no denial');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// I. flag ON + witness:"agent" (self-witness) → DENY
|
|
151
|
+
{
|
|
152
|
+
const r = await runL3(deps({ receipts: [freshReceipt({ witness: 'agent' })] }));
|
|
153
|
+
assert('I. flag ON + witness:agent → DENY', r && /witness/.test(r), r || 'no denial');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// I2. flag ON + result not a pass (exit_code:1) → DENY
|
|
157
|
+
{
|
|
158
|
+
const r = await runL3(deps({ receipts: [freshReceipt({ result: { exit_code: 1 } })] }));
|
|
159
|
+
assert('I2. flag ON + failing result → DENY', r && /pass/.test(r), r || 'no denial');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// J. INFRA fail-closed: no secret → DENY ; no running sha → DENY
|
|
163
|
+
{
|
|
164
|
+
const r1 = await runL3(deps({ secret: null, receipts: [freshReceipt()] }));
|
|
165
|
+
assert('J. flag ON + no secret → DENY (INFRA is a BLOCK)', r1 && /INFRA: no truth-gate HMAC secret/.test(r1), r1 || 'no denial');
|
|
166
|
+
const r2 = await runL3(deps({ runningSha: null, receipts: [freshReceipt()] }));
|
|
167
|
+
assert('J. flag ON + no running sha → DENY (INFRA is a BLOCK)', r2 && /INFRA: could not read the running brain git_sha/.test(r2), r2 || 'no denial');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// K. truthGateFlagEnabled reads the env switch (default OFF)
|
|
171
|
+
{
|
|
172
|
+
const prev = process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT;
|
|
173
|
+
delete process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT;
|
|
174
|
+
assert('K. flag default OFF', gate.truthGateFlagEnabled('require_validator_receipt') === false);
|
|
175
|
+
process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT = 'true';
|
|
176
|
+
assert('K. flag ON via env', gate.truthGateFlagEnabled('require_validator_receipt') === true);
|
|
177
|
+
process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT = '0';
|
|
178
|
+
assert('K. flag "0" is OFF', gate.truthGateFlagEnabled('require_validator_receipt') === false);
|
|
179
|
+
if (prev === undefined) delete process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT;
|
|
180
|
+
else process.env.RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT = prev;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// L. pickNewestValidatorReceipt picks the latest by ts
|
|
184
|
+
{
|
|
185
|
+
const older = freshReceipt({ nonce: 'older', ts: new Date(NOW - 10 * 60_000).toISOString() });
|
|
186
|
+
const newer = freshReceipt({ nonce: 'newer', ts: new Date(NOW - 60_000).toISOString() });
|
|
187
|
+
const picked = gate.pickNewestValidatorReceipt([{ payload: older }, { payload: newer }]);
|
|
188
|
+
assert('L. picks newest receipt by ts', picked && picked.nonce === 'newer', picked ? picked.nonce : 'null');
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
|
|
192
|
+
if (failures.length > 0) {
|
|
193
|
+
console.error('\nwork-item-exit-gate L3 tests — FAIL\n');
|
|
194
|
+
for (const f of failures) console.error(` - ${f}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
console.log('\nwork-item-exit-gate L3 tests — PASS');
|