@lifeaitools/rdc-skills 0.24.4 → 0.24.6
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 +1 -1
- package/hooks/gate-watchdog-selfcheck.js +245 -0
- package/hooks/post-tool-batch-gate.js +203 -0
- package/hooks/task-completed-gate.js +252 -0
- package/hooks/work-item-exit-gate.js +293 -0
- package/package.json +2 -2
- package/scripts/install-rdc-skills.js +11 -0
- package/tests/harness-gates.test.mjs +265 -0
- package/tests/work-item-exit-gate-l3.test.mjs +197 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* task-completed-gate.js — Truth Gate 3.0 Layer 5 (harness completion gate).
|
|
4
|
+
*
|
|
5
|
+
* Registered on the `TaskCompleted` event. Per the Claude Code hooks reference,
|
|
6
|
+
* `TaskCompleted` CAN block (exit code 2 / decision "block" prevents the task
|
|
7
|
+
* from being marked completed). This hook makes a task completion structurally
|
|
8
|
+
* dependent on a SOUND work-item closure: a task cannot complete unless the
|
|
9
|
+
* work item it references is `done` AND that `done` was admitted by Layers 2–3
|
|
10
|
+
* (the FUSED evidence gate + the validator re-run receipt) — i.e. the work item
|
|
11
|
+
* carries an HMAC-valid, witnessed `implementation_report.codeflow_post` and is
|
|
12
|
+
* in status `done`. The exit gate (work-item-exit-gate.js) is what proves
|
|
13
|
+
* Layers 2–3 at `done` time; this gate refuses to let a TASK finish on a work
|
|
14
|
+
* item that never reached that sound `done` state.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ FLAG-GATED, DEFAULT OFF. Until the flag is flipped at deploy this hook is a
|
|
17
|
+
* pure no-op (exit 0) so the in-flight build session is NOT disrupted — the
|
|
18
|
+
* blocking Stop/SubagentStop truth-gate is already live and gating capability
|
|
19
|
+
* claims; this layer is dormant until activation. When the flag is ON it is
|
|
20
|
+
* FAIL-CLOSED: an inability to verify a sound closure is a BLOCK, never a pass.
|
|
21
|
+
*
|
|
22
|
+
* Flag (default OFF), either enables it:
|
|
23
|
+
* - env RDC_TRUTHGATE_TASKCOMPLETED in {1,true,on,yes}, OR
|
|
24
|
+
* - DB public.truthgate_flags(flag='taskcompleted', enabled=true) (best-effort).
|
|
25
|
+
*
|
|
26
|
+
* Offline test seam: when RDC_TASKCOMPLETED_CLOSURE_SINK points at a JSON file,
|
|
27
|
+
* the hook reads the closure row from that file instead of Supabase. This lets
|
|
28
|
+
* the gate be proven without a live DB. The sink shape is a single work_items
|
|
29
|
+
* row: { id, status, item_type, implementation_report }.
|
|
30
|
+
*/
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const hookLog = require('./hook-logger');
|
|
36
|
+
|
|
37
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
38
|
+
const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
|
|
39
|
+
const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
|
|
40
|
+
|
|
41
|
+
function readStdin() {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let input = '';
|
|
44
|
+
process.stdin.setEncoding('utf8');
|
|
45
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
46
|
+
process.stdin.on('end', () => resolve(input));
|
|
47
|
+
process.stdin.resume();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Block the TaskCompleted with exit code 2 (the blocking semantics for this event). */
|
|
52
|
+
function block(reason, details = {}) {
|
|
53
|
+
hookLog('task-completed-gate', 'TaskCompleted', 'block', { reason, ...details });
|
|
54
|
+
process.stdout.write(JSON.stringify({
|
|
55
|
+
decision: 'block',
|
|
56
|
+
reason: `TASK COMPLETION BLOCKED — Truth Gate Layer 5 (TaskCompleted).\n\n${reason}`,
|
|
57
|
+
}));
|
|
58
|
+
process.stderr.write(`TASK COMPLETION BLOCKED: ${reason}\n`);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pass(details = {}) {
|
|
63
|
+
hookLog('task-completed-gate', 'TaskCompleted', 'pass', details);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is the Layer-5 TaskCompleted gate enabled? Default OFF (returns false).
|
|
69
|
+
* Mirrors work-item-exit-gate.js truthGateFlagEnabled — env is the authoritative,
|
|
70
|
+
* test-stable switch; a DB toggle is an optional deploy-time enable.
|
|
71
|
+
*/
|
|
72
|
+
function flagEnabledEnv() {
|
|
73
|
+
const v = String(process.env.RDC_TRUTHGATE_TASKCOMPLETED || '').trim().toLowerCase();
|
|
74
|
+
return v === '1' || v === 'true' || v === 'on' || v === 'yes';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getServiceKey() {
|
|
78
|
+
if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
79
|
+
if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
|
|
80
|
+
for (const endpoint of ['supabase-service', 'supabase-service-role']) {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const text = (await res.text()).trim();
|
|
85
|
+
if (text && !text.startsWith('{')) return text;
|
|
86
|
+
}
|
|
87
|
+
} catch (_) {}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function supabaseGet(pathname) {
|
|
93
|
+
const key = await getServiceKey();
|
|
94
|
+
if (!key) throw new Error('clauth/env did not provide a Supabase service key');
|
|
95
|
+
const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
|
|
96
|
+
const res = await fetch(`${base}/rest/v1/${pathname}`, {
|
|
97
|
+
headers: { apikey: key, Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
98
|
+
signal: AbortSignal.timeout(3500),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) throw new Error(`Supabase HTTP ${res.status}`);
|
|
101
|
+
return res.json();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Best-effort DB flag toggle. Absence/error → false (stays OFF). */
|
|
105
|
+
async function flagEnabledDb() {
|
|
106
|
+
try {
|
|
107
|
+
const rows = await supabaseGet('truthgate_flags?flag=eq.taskcompleted&select=enabled&limit=1');
|
|
108
|
+
return Array.isArray(rows) && rows[0] && rows[0].enabled === true;
|
|
109
|
+
} catch (_) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract the work-item UUID a task is bound to from the TaskCompleted payload.
|
|
116
|
+
* Looks in the common task fields and falls back to the first UUID anywhere in
|
|
117
|
+
* the serialized payload (the closure ref the agent recorded against the task).
|
|
118
|
+
* Returns the lowercased UUID or null.
|
|
119
|
+
*/
|
|
120
|
+
function extractWorkItemId(raw) {
|
|
121
|
+
const candidates = [
|
|
122
|
+
raw && raw.work_item_id,
|
|
123
|
+
raw && raw.task && raw.task.work_item_id,
|
|
124
|
+
raw && raw.task && raw.task.metadata && raw.task.metadata.work_item_id,
|
|
125
|
+
raw && raw.metadata && raw.metadata.work_item_id,
|
|
126
|
+
];
|
|
127
|
+
for (const c of candidates) {
|
|
128
|
+
if (typeof c === 'string' && UUID_RE.test(c)) return c.match(UUID_RE)[0].toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
let blob = '';
|
|
131
|
+
try { blob = JSON.stringify(raw); } catch (_) { blob = String(raw || ''); }
|
|
132
|
+
const m = blob.match(UUID_RE);
|
|
133
|
+
return m ? m[0].toLowerCase() : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Is the work-item closure SOUND for completion? Pure + unit-testable; returns
|
|
138
|
+
* { ok:true } or { ok:false, reason } — never throws. A sound closure for an
|
|
139
|
+
* implementation task requires (mirrors the L2/L3 contract the exit gate proves
|
|
140
|
+
* at `done` time):
|
|
141
|
+
* - the row exists
|
|
142
|
+
* - status === 'done' (the exit gate admitted it; epics are exempt below)
|
|
143
|
+
* - implementation_report.codeflow_post exists
|
|
144
|
+
* - codeflow_post.witness ∈ allow-list (never 'agent' / self-witness, D5)
|
|
145
|
+
* - codeflow_post.commit is present (a captured SHA the exit gate matched)
|
|
146
|
+
* - codeflow_post.files_changed is a non-empty array
|
|
147
|
+
* - codeflow_post.verification is a non-empty array
|
|
148
|
+
* Epics are not implementation tasks — an epic row is considered sound on
|
|
149
|
+
* status alone (the exit gate exempts epics from L2/L3).
|
|
150
|
+
*/
|
|
151
|
+
function closureIsSound(row) {
|
|
152
|
+
if (!row || typeof row !== 'object') {
|
|
153
|
+
return { ok: false, reason: 'no work-item closure row found for this task — cannot confirm a sound, Layer 2–3-admitted `done`.' };
|
|
154
|
+
}
|
|
155
|
+
if (row.item_type === 'epic') {
|
|
156
|
+
return row.status === 'done'
|
|
157
|
+
? { ok: true }
|
|
158
|
+
: { ok: false, reason: `linked epic ${row.id} is status "${row.status}", not "done".` };
|
|
159
|
+
}
|
|
160
|
+
if (row.status !== 'done') {
|
|
161
|
+
return { ok: false, reason: `linked work item ${row.id} is status "${row.status}", not "done" — the exit gate has not admitted a Layer 2–3 closure.` };
|
|
162
|
+
}
|
|
163
|
+
const report = row.implementation_report;
|
|
164
|
+
if (!report || typeof report !== 'object') {
|
|
165
|
+
return { ok: false, reason: `work item ${row.id} has no implementation_report — a sound closure must carry one.` };
|
|
166
|
+
}
|
|
167
|
+
const post = report.codeflow_post;
|
|
168
|
+
if (!post || typeof post !== 'object') {
|
|
169
|
+
return { ok: false, reason: `work item ${row.id} has no implementation_report.codeflow_post — Layer 2–3 evidence absent.` };
|
|
170
|
+
}
|
|
171
|
+
const witness = String(post.witness || '');
|
|
172
|
+
if (!WITNESS_ALLOWLIST.has(witness)) {
|
|
173
|
+
return { ok: false, reason: `closure witness "${witness || '<missing>'}" is not in {validator-rerun, ci, human-review} — the doer cannot self-witness (D5).` };
|
|
174
|
+
}
|
|
175
|
+
if (typeof post.commit !== 'string' || !post.commit.trim()) {
|
|
176
|
+
return { ok: false, reason: `closure codeflow_post.commit is missing — no captured commit to bind the closure to.` };
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(post.files_changed) || post.files_changed.length === 0) {
|
|
179
|
+
return { ok: false, reason: `closure codeflow_post.files_changed is empty — a sound closure names the files it changed.` };
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(post.verification) || post.verification.length === 0) {
|
|
182
|
+
return { ok: false, reason: `closure codeflow_post.verification is empty — a sound closure carries a captured verification artifact.` };
|
|
183
|
+
}
|
|
184
|
+
return { ok: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Load the closure row: offline sink first (test seam), else Supabase. */
|
|
188
|
+
async function loadClosureRow(workItemId) {
|
|
189
|
+
const sink = process.env.RDC_TASKCOMPLETED_CLOSURE_SINK;
|
|
190
|
+
if (sink) {
|
|
191
|
+
const txt = fs.readFileSync(sink, 'utf8');
|
|
192
|
+
const parsed = JSON.parse(txt);
|
|
193
|
+
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
194
|
+
}
|
|
195
|
+
const rows = await supabaseGet(
|
|
196
|
+
`work_items?id=eq.${encodeURIComponent(workItemId)}&select=id,status,item_type,implementation_report&limit=1`,
|
|
197
|
+
);
|
|
198
|
+
return Array.isArray(rows) ? rows[0] : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function main() {
|
|
202
|
+
let raw;
|
|
203
|
+
try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
|
|
204
|
+
|
|
205
|
+
// Default-OFF: a no-op until the flag is flipped at deploy.
|
|
206
|
+
const enabled = flagEnabledEnv() || (await flagEnabledDb().catch(() => false));
|
|
207
|
+
if (!enabled) return pass({ reason: 'flag-off' });
|
|
208
|
+
|
|
209
|
+
// FAIL-CLOSED from here: any inability to confirm a sound closure is a BLOCK.
|
|
210
|
+
const workItemId = extractWorkItemId(raw);
|
|
211
|
+
if (!workItemId) {
|
|
212
|
+
block(
|
|
213
|
+
'This task completion carries no work-item reference, so its closure cannot be verified against the ' +
|
|
214
|
+
'Layer 2–3 exit gate. Bind the task to its work item (record the work-item UUID on the task/closure) and retry.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let row;
|
|
219
|
+
try {
|
|
220
|
+
row = await loadClosureRow(workItemId);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
block(
|
|
223
|
+
`Could not load the work-item closure for ${workItemId} (${e && e.message ? e.message : String(e)}). ` +
|
|
224
|
+
`Fail-closed: not completing the task until the closure can be verified.`,
|
|
225
|
+
{ workItemId },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const verdict = closureIsSound(row);
|
|
230
|
+
if (!verdict.ok) {
|
|
231
|
+
block(
|
|
232
|
+
`${verdict.reason}\n\n` +
|
|
233
|
+
`A task may only complete once its work item has reached a sound, Layer 2–3-admitted \`done\` ` +
|
|
234
|
+
`(HMAC-valid, witnessed codeflow_post via the validator path). Move the work item through review → validator ` +
|
|
235
|
+
`\`done\` first, then complete the task.`,
|
|
236
|
+
{ workItemId },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
pass({ workItemId });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (require.main === module) {
|
|
244
|
+
main().catch((e) => block(`task-completed-gate crashed: ${e.message}`));
|
|
245
|
+
} else {
|
|
246
|
+
module.exports = {
|
|
247
|
+
flagEnabledEnv,
|
|
248
|
+
extractWorkItemId,
|
|
249
|
+
closureIsSound,
|
|
250
|
+
WITNESS_ALLOWLIST,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -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.6",
|
|
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 && node tests/harness-gates.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",
|
|
@@ -715,10 +715,21 @@ function buildHooksConfig(hooksDir, profile = 'core') {
|
|
|
715
715
|
cmd('check-rdc-environment.js', 'Checking RDC skills runtime...'),
|
|
716
716
|
cmd('check-cwd.js'),
|
|
717
717
|
cmd('check-stale-work-items.js', 'Checking for stale work items...'),
|
|
718
|
+
// Truth Gate 3.0 Layer 6 — gate watchdog (ADVISORY; SessionStart cannot block).
|
|
719
|
+
cmd('gate-watchdog-selfcheck.js', 'Truth Gate watchdog: verifying gate registration...'),
|
|
718
720
|
]}];
|
|
719
721
|
config.PreToolUse[0].hooks.push(
|
|
720
722
|
cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
|
|
721
723
|
);
|
|
724
|
+
// Truth Gate 3.0 Layer 5 — harness completion gates. Both blocking hooks are
|
|
725
|
+
// FLAG-GATED, default OFF (no-op until the env/DB flag is flipped at deploy),
|
|
726
|
+
// so registering them does NOT disrupt the in-flight build session.
|
|
727
|
+
config.TaskCompleted = [{ hooks: [
|
|
728
|
+
cmd('task-completed-gate.js', 'Truth Gate: verifying task closure...'),
|
|
729
|
+
]}];
|
|
730
|
+
config.PostToolBatch = [{ hooks: [
|
|
731
|
+
cmd('post-tool-batch-gate.js', 'Truth Gate: checking build-wave worktree bases...'),
|
|
732
|
+
]}];
|
|
722
733
|
config.PreCompact = [{ hooks: [
|
|
723
734
|
cmd('precompact-log.js'),
|
|
724
735
|
]}];
|