@lifeaitools/rdc-skills 0.24.42 → 0.25.1
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/.claude/settings.json +15 -15
- package/.claude-plugin/marketplace.json +21 -21
- package/.claude-plugin/plugin.json +1560 -1371
- package/.github/workflows/publish.yml +34 -34
- package/.github/workflows/self-test.yml +58 -58
- package/CHANGELOG.md +322 -310
- package/LICENSE +21 -21
- package/MANIFEST.md +224 -221
- package/README.md +377 -376
- package/README.sandbox.md +3 -3
- package/assets/watcher/viewer.html +164 -164
- package/bin/rdc-skills-mcp.mjs +316 -316
- package/commands/build.md +183 -183
- package/commands/collab.md +180 -180
- package/commands/deploy.md +152 -152
- package/commands/design.md +31 -31
- package/commands/edit.md +28 -28
- package/commands/fixit.md +150 -124
- package/commands/handoff.md +173 -173
- package/commands/help.md +95 -95
- package/commands/overnight.md +220 -220
- package/commands/plan.md +158 -158
- package/commands/preplan.md +131 -131
- package/commands/prototype.md +145 -145
- package/commands/release.md +49 -49
- package/commands/report.md +99 -99
- package/commands/review.md +120 -120
- package/commands/self-test.md +113 -113
- package/commands/status.md +86 -86
- package/commands/watch.md +98 -98
- package/commands/workitems.md +137 -137
- package/git-sha.json +1 -1
- package/guides/agent-bootstrap.md +295 -295
- package/guides/agents/backend.md +104 -104
- package/guides/agents/content.md +94 -94
- package/guides/agents/cs2.md +56 -56
- package/guides/agents/data.md +87 -87
- package/guides/agents/design.md +77 -77
- package/guides/agents/frontend.md +92 -92
- package/guides/agents/infrastructure.md +81 -81
- package/guides/agents/setup.md +281 -281
- package/guides/agents/verify.md +151 -151
- package/guides/agents/viz.md +106 -106
- package/guides/backend.md +146 -146
- package/guides/content.md +147 -147
- package/guides/cs2.md +190 -190
- package/guides/data.md +123 -123
- package/guides/design.md +116 -116
- package/guides/engineering-behavior.md +43 -43
- package/guides/escalation-protocol.md +125 -125
- package/guides/frontend.md +151 -151
- package/guides/history-md-spec.md +297 -297
- package/guides/infrastructure.md +179 -179
- package/guides/lessons-learned-spec.md +145 -151
- package/guides/output-contract.md +108 -108
- package/guides/publish-md-spec.md +289 -289
- package/guides/rdc-skills-startup.md +30 -30
- package/guides/verify.md +11 -11
- package/hooks/check-cwd.js +31 -31
- package/hooks/check-rdc-environment.js +164 -164
- package/hooks/check-services.js +6 -6
- package/hooks/check-stale-work-items.js +19 -19
- package/hooks/foreground-process-gate.js +128 -128
- package/hooks/gate-watchdog-selfcheck.js +257 -257
- package/hooks/hook-logger.js +25 -25
- package/hooks/lib/run-evidence-gate.mjs +241 -241
- package/hooks/no-stop-open-epics.js +127 -127
- package/hooks/post-tool-batch-gate.js +203 -203
- package/hooks/post-work-check.js +21 -21
- package/hooks/postcompact-log.js +13 -13
- package/hooks/precompact-log.js +13 -13
- package/hooks/rate-limit-retry.js +46 -46
- package/hooks/rdc-invocation-marker.js +157 -157
- package/hooks/rdc-output-contract-gate.js +94 -94
- package/hooks/require-work-item-on-commit.js +294 -294
- package/hooks/restart-brief.js +19 -19
- package/hooks/run-hidden-hook.ps1 +47 -47
- package/hooks/task-completed-gate.js +274 -274
- package/hooks/work-item-exit-gate.js +944 -944
- package/lib/catalog.mjs +236 -236
- package/lib/cloud-rewrite.mjs +155 -155
- package/package.json +57 -57
- package/rules/work-items-rpc.md +520 -520
- package/scaffold/templates/HISTORY.md.template +39 -39
- package/scaffold/templates/PUBLISH.md.template +21 -21
- package/scaffold/templates/brochure-studio-default.html +70 -70
- package/scripts/acceptance.mjs +502 -502
- package/scripts/fixtures/guides/bad-guide.md +15 -15
- package/scripts/fixtures/guides-clean/good-guide.md +16 -16
- package/scripts/install-rdc-skills.js +1401 -1289
- package/scripts/install.ps1 +202 -202
- package/scripts/install.sh +132 -132
- package/scripts/lib/assertions.mjs +287 -287
- package/scripts/lib/manifest-schema.mjs +754 -754
- package/scripts/lib/runner.mjs +465 -465
- package/scripts/lib/sandbox.mjs +435 -435
- package/scripts/prepack.mjs +32 -32
- package/scripts/rdc-brochure.mjs +482 -482
- package/scripts/rdc-design-cli.mjs +134 -134
- package/scripts/rebuild-mcp.mjs +107 -107
- package/scripts/self-test.mjs +1460 -1460
- package/scripts/stamp-git-sha.mjs +29 -29
- package/scripts/test-guide-validator.mjs +196 -196
- package/scripts/test-rdc-hooks.mjs +145 -145
- package/scripts/uninstall.ps1 +77 -77
- package/scripts/uninstall.sh +69 -69
- package/scripts/update.ps1 +43 -43
- package/scripts/update.sh +43 -43
- package/scripts/validate-place-histories.js +461 -461
- package/scripts/validate-publish-manifests.js +502 -424
- package/scripts/watch-init.mjs +100 -100
- package/skills/brochure/SKILL.md +107 -107
- package/skills/build/SKILL.md +578 -563
- package/skills/channel-formatter/SKILL.md +538 -533
- package/skills/co-develop/SKILL.md +196 -196
- package/skills/collab/SKILL.md +239 -239
- package/skills/convert/SKILL.md +167 -140
- package/skills/deploy/SKILL.md +541 -541
- package/skills/design/SKILL.md +211 -211
- package/skills/design/reference/ownership.md +16 -16
- package/skills/design/reference/rampa.md +92 -92
- package/skills/design/reference/studio-model.md +153 -153
- package/skills/edit/SKILL.md +98 -98
- package/skills/env/SKILL.md +141 -0
- package/skills/fixit/SKILL.md +203 -165
- package/skills/fs-mcp/SKILL.md +183 -148
- package/skills/handoff/SKILL.md +236 -236
- package/skills/help/SKILL.md +143 -143
- package/skills/housekeeping/SKILL.md +160 -219
- package/skills/lifeai-brochure-author/SKILL.md +340 -340
- package/skills/new-model/SKILL.md +49 -0
- package/skills/onramp/SKILL.md +1459 -0
- package/skills/overnight/SKILL.md +251 -251
- package/skills/plan/SKILL.md +345 -345
- package/skills/preplan/SKILL.md +90 -90
- package/skills/prototype/SKILL.md +150 -150
- package/skills/rdc-brochurify/SKILL.md +245 -245
- package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
- package/skills/regen-media/SKILL.md +94 -0
- package/skills/release/SKILL.md +140 -140
- package/skills/report/SKILL.md +100 -100
- package/skills/review/SKILL.md +160 -152
- package/skills/rpms-filemap/SKILL.cloud.md +111 -111
- package/skills/rpms-filemap/SKILL.md +111 -111
- package/skills/self-test/SKILL.md +132 -132
- package/skills/status/SKILL.md +99 -99
- package/skills/terminal-config/SKILL.md +62 -62
- package/skills/tests/MATRIX.md +55 -54
- package/skills/tests/README.md +47 -47
- package/skills/tests/onramp.test.json +87 -0
- package/skills/tests/rdc-brochure.test.json +34 -34
- package/skills/tests/rdc-build.test.json +36 -36
- package/skills/tests/rdc-channel-formatter.test.json +45 -45
- package/skills/tests/rdc-co-develop.test.json +29 -29
- package/skills/tests/rdc-collab.test.json +29 -29
- package/skills/tests/rdc-convert.test.json +35 -35
- package/skills/tests/rdc-deploy.test.json +30 -30
- package/skills/tests/rdc-design.test.json +27 -27
- package/skills/tests/rdc-edit.test.json +29 -29
- package/skills/tests/rdc-fixit.test.json +36 -36
- package/skills/tests/rdc-fs-mcp.test.json +36 -36
- package/skills/tests/rdc-handoff.test.json +28 -28
- package/skills/tests/rdc-help.test.json +29 -29
- package/skills/tests/rdc-housekeeping.test.json +28 -32
- package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
- package/skills/tests/rdc-overnight.test.json +37 -37
- package/skills/tests/rdc-plan.test.json +27 -27
- package/skills/tests/rdc-preplan.test.json +31 -31
- package/skills/tests/rdc-prototype.test.json +28 -28
- package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
- package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
- package/skills/tests/rdc-regen-media.test.json +29 -0
- package/skills/tests/rdc-release.test.json +29 -29
- package/skills/tests/rdc-report.test.json +28 -28
- package/skills/tests/rdc-review.test.json +29 -29
- package/skills/tests/rdc-rpms-filemap.test.json +28 -28
- package/skills/tests/rdc-self-test.test.json +24 -24
- package/skills/tests/rdc-status.test.json +29 -29
- package/skills/tests/rdc-terminal-config.test.json +29 -29
- package/skills/tests/rdc-watch.test.json +24 -24
- package/skills/tests/rdc-workitems.test.json +27 -27
- package/skills/watch/SKILL.md +97 -97
- package/skills/workitems/SKILL.md +151 -151
- package/tests/acceptance.test.mjs +59 -59
- package/tests/channel-formatter.contract.test.mjs +251 -251
- package/tests/curl-surface.test.mjs +289 -289
- package/tests/harness-gates.test.mjs +325 -325
- package/tests/help-surface.test.mjs +61 -61
- package/tests/install-rdc-skills.test.mjs +49 -49
- package/tests/manifest-contract-fields.test.mjs +78 -78
- package/tests/mcp.test.mjs +271 -271
- package/tests/rdc-brochure.test.mjs +125 -125
- package/tests/require-work-item-on-commit.test.mjs +162 -162
- package/tests/run-evidence-gate.test.mjs +82 -82
- package/tests/skill-test-matrix.test.mjs +66 -66
- package/tests/validate-skills.js +27 -27
- package/tests/work-item-exit-gate-l2.test.mjs +368 -368
- package/tests/work-item-exit-gate-l3.test.mjs +197 -197
- package/RELEASE.md +0 -42
- package/tests/housekeeping-lessons-triage.test.mjs +0 -49
- package/tests/lessons-pipeline-contract.test.mjs +0 -27
- package/tests/release-contract.test.mjs +0 -16
|
@@ -1,944 +1,944 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse hook — hard-stop unsafe work item exit transitions.
|
|
4
|
-
*
|
|
5
|
-
* This gate runs before tool calls and blocks legacy or supervisor-mutated
|
|
6
|
-
* CodeFlow/work-item close patterns before they reach Supabase. The database
|
|
7
|
-
* remains the final authority; this hook gives agents an immediate stop sign.
|
|
8
|
-
*/
|
|
9
|
-
'use strict';
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const os = require('os');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const crypto = require('crypto');
|
|
15
|
-
const { execFileSync } = require('child_process');
|
|
16
|
-
const hookLog = require('./hook-logger');
|
|
17
|
-
|
|
18
|
-
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
19
|
-
const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
20
|
-
const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
|
|
21
|
-
const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
|
|
22
|
-
|
|
23
|
-
// Truth Gate 3.0 — Layer 2 (FUSED evidence gate).
|
|
24
|
-
// Witness allow-list (D5 / SLSA: the doer cannot sign its own provenance).
|
|
25
|
-
const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
|
|
26
|
-
// Repo the gate runs `git` against. The work tree under verification is
|
|
27
|
-
// regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
|
|
28
|
-
const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
|
|
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
|
-
|
|
45
|
-
function readStdin() {
|
|
46
|
-
return new Promise((resolve) => {
|
|
47
|
-
let input = '';
|
|
48
|
-
process.stdin.setEncoding('utf8');
|
|
49
|
-
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
50
|
-
process.stdin.on('end', () => resolve(input));
|
|
51
|
-
process.stdin.resume();
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function block(message, details = {}) {
|
|
56
|
-
hookLog('work-item-exit-gate', 'PreToolUse', 'block', details);
|
|
57
|
-
process.stdout.write(JSON.stringify({
|
|
58
|
-
systemMessage: `HARD BLOCK — Work item exit gate rejected this tool call.\n\n${message}`,
|
|
59
|
-
}));
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function pass(details = {}) {
|
|
64
|
-
hookLog('work-item-exit-gate', 'PreToolUse', 'pass', details);
|
|
65
|
-
process.exit(0);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function stringifyTool(toolInput) {
|
|
69
|
-
if (typeof toolInput === 'string') return toolInput;
|
|
70
|
-
try { return JSON.stringify(toolInput); } catch { return ''; }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function stripCasts(value) {
|
|
74
|
-
return String(value || '')
|
|
75
|
-
.replace(/::\s*[a-z_][\w.]*/gi, '')
|
|
76
|
-
.trim();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function unquote(value) {
|
|
80
|
-
const v = stripCasts(value);
|
|
81
|
-
const m = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
|
|
82
|
-
return (m ? m[1] : v).replace(/''/g, "'").trim();
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getNamedArg(text, name) {
|
|
86
|
-
const re = new RegExp(`${name}\\s*:?=\\s*('(?:''|[^'])*'|"(?:\\\\"|[^"])*"|true|false|null|[0-9a-f-]{36})`, 'i');
|
|
87
|
-
const m = text.match(re);
|
|
88
|
-
return m ? unquote(m[1]) : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function splitArgs(argsText) {
|
|
92
|
-
const args = [];
|
|
93
|
-
let current = '';
|
|
94
|
-
let quote = null;
|
|
95
|
-
let depth = 0;
|
|
96
|
-
for (let i = 0; i < argsText.length; i++) {
|
|
97
|
-
const ch = argsText[i];
|
|
98
|
-
const next = argsText[i + 1];
|
|
99
|
-
if (quote) {
|
|
100
|
-
current += ch;
|
|
101
|
-
if (ch === quote && !(quote === "'" && next === "'")) quote = null;
|
|
102
|
-
else if (ch === "'" && next === "'") current += argsText[++i];
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (ch === "'" || ch === '"') {
|
|
106
|
-
quote = ch;
|
|
107
|
-
current += ch;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
111
|
-
if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
|
|
112
|
-
if (ch === ',' && depth === 0) {
|
|
113
|
-
args.push(current.trim());
|
|
114
|
-
current = '';
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
current += ch;
|
|
118
|
-
}
|
|
119
|
-
if (current.trim()) args.push(current.trim());
|
|
120
|
-
return args;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function findCall(text, name) {
|
|
124
|
-
const idx = text.toLowerCase().indexOf(name.toLowerCase());
|
|
125
|
-
if (idx < 0) return null;
|
|
126
|
-
const open = text.indexOf('(', idx + name.length);
|
|
127
|
-
if (open < 0) return null;
|
|
128
|
-
let quote = null;
|
|
129
|
-
let depth = 0;
|
|
130
|
-
for (let i = open; i < text.length; i++) {
|
|
131
|
-
const ch = text[i];
|
|
132
|
-
const next = text[i + 1];
|
|
133
|
-
if (quote) {
|
|
134
|
-
if (ch === quote && !(quote === "'" && next === "'")) quote = null;
|
|
135
|
-
else if (ch === "'" && next === "'") i++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (ch === "'" || ch === '"') {
|
|
139
|
-
quote = ch;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (ch === '(') depth++;
|
|
143
|
-
if (ch === ')') {
|
|
144
|
-
depth--;
|
|
145
|
-
if (depth === 0) return text.slice(open + 1, i);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function objectArg(blob, name) {
|
|
152
|
-
const re = new RegExp(`"${name}"\\s*:\\s*("([^"]*)"|true|false|null)`, 'i');
|
|
153
|
-
const m = blob.match(re);
|
|
154
|
-
if (!m) return null;
|
|
155
|
-
if (m[1] === 'true' || m[1] === 'false' || m[1] === 'null') return m[1];
|
|
156
|
-
return m[2] || null;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function extractStatusCall(blob) {
|
|
160
|
-
if (!/update_work_item_status/i.test(blob)) return null;
|
|
161
|
-
const call = findCall(blob, 'update_work_item_status');
|
|
162
|
-
const args = call ? splitArgs(call) : [];
|
|
163
|
-
return {
|
|
164
|
-
id: objectArg(blob, 'p_id') || getNamedArg(blob, 'p_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
|
|
165
|
-
status: objectArg(blob, 'p_status') || getNamedArg(blob, 'p_status') || unquote(args[1] || ''),
|
|
166
|
-
actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
|
|
167
|
-
actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function extractTickCall(blob) {
|
|
172
|
-
if (!/update_checklist_item/i.test(blob)) return null;
|
|
173
|
-
const call = findCall(blob, 'update_checklist_item');
|
|
174
|
-
const args = call ? splitArgs(call) : [];
|
|
175
|
-
return {
|
|
176
|
-
workItemId: objectArg(blob, 'p_work_item_id') || getNamedArg(blob, 'p_work_item_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
|
|
177
|
-
itemId: objectArg(blob, 'p_item_id') || getNamedArg(blob, 'p_item_id') || unquote(args[1] || ''),
|
|
178
|
-
checked: (objectArg(blob, 'p_checked') || getNamedArg(blob, 'p_checked') || unquote(args[2] || '')).toLowerCase() === 'true',
|
|
179
|
-
actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
|
|
180
|
-
actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function logTick(tick, rawTool) {
|
|
185
|
-
try {
|
|
186
|
-
fs.mkdirSync(path.dirname(EVENT_LOG), { recursive: true });
|
|
187
|
-
fs.appendFileSync(EVENT_LOG, JSON.stringify({
|
|
188
|
-
ts: new Date().toISOString(),
|
|
189
|
-
work_item_id: tick.workItemId || null,
|
|
190
|
-
item_id: tick.itemId || null,
|
|
191
|
-
checked: tick.checked,
|
|
192
|
-
actor_session_id: tick.actorSessionId || null,
|
|
193
|
-
actor_role: tick.actorRole || null,
|
|
194
|
-
tool_name: rawTool.tool_name || null,
|
|
195
|
-
}) + '\n');
|
|
196
|
-
} catch (_) {}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function getServiceKey() {
|
|
200
|
-
if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
201
|
-
if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
|
|
202
|
-
for (const endpoint of ['supabase-service', 'supabase-service-role']) {
|
|
203
|
-
try {
|
|
204
|
-
const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
|
|
205
|
-
if (res.ok) {
|
|
206
|
-
const text = (await res.text()).trim();
|
|
207
|
-
if (text && !text.startsWith('{')) return text;
|
|
208
|
-
}
|
|
209
|
-
} catch (_) {}
|
|
210
|
-
}
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function supabaseGet(pathname) {
|
|
215
|
-
const key = await getServiceKey();
|
|
216
|
-
if (!key) throw new Error('clauth/env did not provide a Supabase service key');
|
|
217
|
-
const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
|
|
218
|
-
const res = await fetch(`${base}/rest/v1/${pathname}`, {
|
|
219
|
-
headers: {
|
|
220
|
-
apikey: key,
|
|
221
|
-
Authorization: `Bearer ${key}`,
|
|
222
|
-
Accept: 'application/json',
|
|
223
|
-
},
|
|
224
|
-
signal: AbortSignal.timeout(3500),
|
|
225
|
-
});
|
|
226
|
-
if (!res.ok) throw new Error(`Supabase verification HTTP ${res.status}`);
|
|
227
|
-
return res.json();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function requiredItems(checklist) {
|
|
231
|
-
return Array.isArray(checklist)
|
|
232
|
-
? checklist.filter((item) => item && item.required === true)
|
|
233
|
-
: [];
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ===========================================================================
|
|
237
|
-
// Truth Gate 3.0 — Layer 2 (FUSED evidence gate / freeze-the-leak)
|
|
238
|
-
//
|
|
239
|
-
// Asserts, server-checkable and FAIL-CLOSED, that a `done` close corresponds to
|
|
240
|
-
// reality. Baru-trap hardening (from the auditors' OWN false positives):
|
|
241
|
-
// - resolve commits by FULL 40-hex SHA only — never short-prefix compare
|
|
242
|
-
// (prefix collisions falsely flagged real work as fabricated).
|
|
243
|
-
// - locate claimed files WHOLE-REPO (`git log --all -- <path>`), never
|
|
244
|
-
// cited-path-only (wrong-path lookups falsely cried "absent").
|
|
245
|
-
// ===========================================================================
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* A Layer-2 denial. Thrown by `deny()` so verifyLayer2 short-circuits without
|
|
249
|
-
* coupling to process.exit — production translates it to block(), tests assert
|
|
250
|
-
* on `.reason`. This keeps the gate logic pure + unit-testable while staying
|
|
251
|
-
* FAIL-CLOSED at the process boundary.
|
|
252
|
-
*/
|
|
253
|
-
class GateDenied extends Error {
|
|
254
|
-
constructor(reason, details) {
|
|
255
|
-
super(reason);
|
|
256
|
-
this.name = 'GateDenied';
|
|
257
|
-
this.reason = reason;
|
|
258
|
-
this.details = details || {};
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function deny(reason, details) {
|
|
263
|
-
throw new GateDenied(reason, details);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Build the L1-captured SHA set bound to the item's ORIGINATING session, or
|
|
268
|
-
* DENY (throw GateDenied) when there is no originating session to bind to.
|
|
269
|
-
*
|
|
270
|
-
* FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
|
|
271
|
-
* binding (the old `!originatingSession` short-circuit accepted ANY session's
|
|
272
|
-
* captured SHA — a fail-open laundering hole). With no session, there is no
|
|
273
|
-
* provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
|
|
274
|
-
* session_id exactly equals the originating session.
|
|
275
|
-
*
|
|
276
|
-
* @param originatingSession string|null the item's session_id
|
|
277
|
-
* @param capturedRows Array<{sha, session_id}> from work_item_commits
|
|
278
|
-
* @returns Set<string> full-SHA (lowercased) set for this item+session
|
|
279
|
-
*/
|
|
280
|
-
function buildCapturedShaSet(originatingSession, capturedRows) {
|
|
281
|
-
const sess = originatingSession || null;
|
|
282
|
-
if (!sess) {
|
|
283
|
-
deny(
|
|
284
|
-
'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
|
|
285
|
-
'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
|
|
286
|
-
{ originatingSession },
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
return new Set(
|
|
290
|
-
(Array.isArray(capturedRows) ? capturedRows : [])
|
|
291
|
-
.filter((r) => r && r.session_id === sess)
|
|
292
|
-
.map((r) => String((r && r.sha) || '').toLowerCase())
|
|
293
|
-
.filter((s) => FULL_SHA_RE.test(s)),
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Run a git command in the truth-gate repo; returns trimmed stdout or null.
|
|
298
|
-
* stderr is captured (piped) so a failure carries a usable diagnostic instead
|
|
299
|
-
* of being swallowed. On failure with allowFail, returns null; otherwise the
|
|
300
|
-
* thrown error retains git's stderr in e.stderr / e.message. */
|
|
301
|
-
function git(args, { allowFail = false } = {}) {
|
|
302
|
-
try {
|
|
303
|
-
return execFileSync('git', args, {
|
|
304
|
-
cwd: TRUTH_GATE_REPO,
|
|
305
|
-
encoding: 'utf8',
|
|
306
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
308
|
-
}).trim();
|
|
309
|
-
} catch (e) {
|
|
310
|
-
if (allowFail) return null;
|
|
311
|
-
throw e;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
|
|
317
|
-
* can interrogate. If git is missing or the path is not a work tree, every
|
|
318
|
-
* downstream SHA/file check would silently mis-verify, so we fail-closed with a
|
|
319
|
-
* clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
|
|
320
|
-
*/
|
|
321
|
-
function assertGitRepoAvailable() {
|
|
322
|
-
let out;
|
|
323
|
-
try {
|
|
324
|
-
out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
325
|
-
cwd: TRUTH_GATE_REPO,
|
|
326
|
-
encoding: 'utf8',
|
|
327
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
328
|
-
maxBuffer: 1024 * 1024,
|
|
329
|
-
}).trim();
|
|
330
|
-
} catch (e) {
|
|
331
|
-
const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
|
|
332
|
-
deny(
|
|
333
|
-
'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
|
|
334
|
-
'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
|
|
335
|
-
{ repo: TRUTH_GATE_REPO },
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
if (out !== 'true') {
|
|
339
|
-
deny(
|
|
340
|
-
'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
|
|
341
|
-
'The gate cannot verify commit/file provenance; fail-closed.',
|
|
342
|
-
{ repo: TRUTH_GATE_REPO },
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
|
|
349
|
-
* Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
|
|
350
|
-
* NEVER does a prefix/substring compare — the returned value is the canonical
|
|
351
|
-
* full SHA, and all equality downstream is full-SHA equality.
|
|
352
|
-
*/
|
|
353
|
-
function resolveFullSha(ref) {
|
|
354
|
-
if (typeof ref !== 'string' || !ref.trim()) return null;
|
|
355
|
-
const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
|
|
356
|
-
return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
|
|
360
|
-
function filesInCommit(fullSha) {
|
|
361
|
-
const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
|
|
362
|
-
if (out == null) return null;
|
|
363
|
-
return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function normalizeRepoPath(p) {
|
|
367
|
-
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Whole-repo existence check for a claimed file. A file "exists for this repo"
|
|
372
|
-
* when EITHER it is tracked anywhere in history (`git log --all -- <path>`
|
|
373
|
-
* returns a commit) OR it is present on disk. This is deliberately broad to
|
|
374
|
-
* avoid the cited-path-only false "absent" verdict.
|
|
375
|
-
*/
|
|
376
|
-
function fileKnownToRepo(repoPath) {
|
|
377
|
-
const p = normalizeRepoPath(repoPath);
|
|
378
|
-
if (!p) return false;
|
|
379
|
-
// On disk now?
|
|
380
|
-
try {
|
|
381
|
-
if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
|
|
382
|
-
} catch (_) { /* fall through to history */ }
|
|
383
|
-
// Tracked anywhere in history (whole-repo locator, never cited-path-only)?
|
|
384
|
-
const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
|
|
385
|
-
return Boolean(log && log.length > 0);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Does the file exist on disk in the work tree right now? */
|
|
389
|
-
function fileOnDisk(repoPath) {
|
|
390
|
-
const p = normalizeRepoPath(repoPath);
|
|
391
|
-
if (!p) return false;
|
|
392
|
-
try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
let _evidenceLib = null;
|
|
396
|
-
/** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
|
|
397
|
-
* Uses a file:// URL so the dynamic import works on Windows absolute paths.
|
|
398
|
-
* Returns { isMachineArtifact, isPassingArtifact }. */
|
|
399
|
-
async function loadEvidenceLib() {
|
|
400
|
-
if (_evidenceLib) return _evidenceLib;
|
|
401
|
-
const { pathToFileURL } = require('url');
|
|
402
|
-
const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
|
|
403
|
-
const mod = await import(pathToFileURL(libPath).href);
|
|
404
|
-
_evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
|
|
405
|
-
return _evidenceLib;
|
|
406
|
-
}
|
|
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
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Layer-2 verification of a `done` close against the real repo. Throws (caught
|
|
660
|
-
* by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
|
|
661
|
-
* an inability to verify is a DENY, never a silent pass.
|
|
662
|
-
*
|
|
663
|
-
* @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
|
|
664
|
-
* @param item the work_items row (has implementation_report)
|
|
665
|
-
* @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
|
|
666
|
-
*/
|
|
667
|
-
async function verifyLayer2(statusCall, item, capturedShas) {
|
|
668
|
-
// Fail-closed: the gate's commit/file checks all shell out to git. If the
|
|
669
|
-
// truth-gate repo is not a usable git work tree, verify nothing — DENY.
|
|
670
|
-
assertGitRepoAvailable();
|
|
671
|
-
|
|
672
|
-
const post = item.implementation_report && item.implementation_report.codeflow_post;
|
|
673
|
-
if (!post || typeof post !== 'object') {
|
|
674
|
-
deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
|
|
678
|
-
const witness = String(post.witness || '').trim();
|
|
679
|
-
if (!WITNESS_ALLOWLIST.has(witness)) {
|
|
680
|
-
deny(
|
|
681
|
-
'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
|
|
682
|
-
(witness || '<missing>') + '". ' +
|
|
683
|
-
'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
|
|
684
|
-
{ ...statusCall, witness },
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
|
|
689
|
-
const claimedCommit = post.commit;
|
|
690
|
-
const fullSha = resolveFullSha(claimedCommit);
|
|
691
|
-
if (!fullSha) {
|
|
692
|
-
deny(
|
|
693
|
-
'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
|
|
694
|
-
'") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
|
|
695
|
-
{ ...statusCall, claimedCommit },
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
if (!capturedShas.has(fullSha)) {
|
|
699
|
-
deny(
|
|
700
|
-
'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
|
|
701
|
-
'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
|
|
702
|
-
'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
|
|
703
|
-
{ ...statusCall, fullSha, capturedCount: capturedShas.size },
|
|
704
|
-
);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// (2)/(3) Every files_changed entry is in the commit AND exists on disk.
|
|
708
|
-
const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
|
|
709
|
-
if (filesChanged.length === 0) {
|
|
710
|
-
deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
|
|
711
|
-
}
|
|
712
|
-
const commitFiles = filesInCommit(fullSha);
|
|
713
|
-
if (commitFiles == null) {
|
|
714
|
-
deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
|
|
715
|
-
}
|
|
716
|
-
for (const raw of filesChanged) {
|
|
717
|
-
const p = normalizeRepoPath(raw);
|
|
718
|
-
if (!p) {
|
|
719
|
-
deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
|
|
720
|
-
}
|
|
721
|
-
// In the commit? (whole-repo lookup is implicit — commitFiles is the full
|
|
722
|
-
// name-only set of the resolved commit, not a cited-path filter.)
|
|
723
|
-
if (!commitFiles.has(p)) {
|
|
724
|
-
deny(
|
|
725
|
-
'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
|
|
726
|
-
' (git show --stat). files_changed must match the commit\'s actual contents.',
|
|
727
|
-
{ ...statusCall, file: p, fullSha },
|
|
728
|
-
);
|
|
729
|
-
}
|
|
730
|
-
// On disk now?
|
|
731
|
-
if (!fileOnDisk(p)) {
|
|
732
|
-
deny(
|
|
733
|
-
'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
|
|
734
|
-
'A deliverable that is not present is not done.',
|
|
735
|
-
{ ...statusCall, file: p },
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
// Whole-repo sanity (defense in depth; should always hold if on disk).
|
|
739
|
-
if (!fileKnownToRepo(p)) {
|
|
740
|
-
deny(
|
|
741
|
-
'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
|
|
742
|
-
{ ...statusCall, file: p },
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// (4) Every verification entry is a machine-parseable artifact, not prose,
|
|
748
|
-
// AND its OUTCOME is a PASS — not merely that it RAN. A failing run
|
|
749
|
-
// (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
|
|
750
|
-
// test artifact with failures must DENY: "ran" is not "passed".
|
|
751
|
-
const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
|
|
752
|
-
const verifications = Array.isArray(post.verification) ? post.verification : [];
|
|
753
|
-
if (verifications.length === 0) {
|
|
754
|
-
deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
|
|
755
|
-
}
|
|
756
|
-
for (const v of verifications) {
|
|
757
|
-
// 4a. Shape gate — it must be a captured machine artifact, not prose.
|
|
758
|
-
if (!isMachineArtifact(v)) {
|
|
759
|
-
const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
|
|
760
|
-
deny(
|
|
761
|
-
'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
|
|
762
|
-
'Each verification must be a run-evidence-gate result or a machine shape ' +
|
|
763
|
-
'({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
|
|
764
|
-
{ ...statusCall, verification: shown },
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
// 4b. Outcome gate — the captured artifact must read as a PASS.
|
|
768
|
-
if (!isPassingArtifact(v)) {
|
|
769
|
-
const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
|
|
770
|
-
deny(
|
|
771
|
-
'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
|
|
772
|
-
'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
|
|
773
|
-
'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
|
|
774
|
-
{ ...statusCall, verification: shown },
|
|
775
|
-
);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
// No denial thrown => Layer-2 verification PASSED.
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function verifyDone(statusCall, blob) {
|
|
782
|
-
if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
|
|
783
|
-
if (!statusCall.actorSessionId || !statusCall.actorRole) {
|
|
784
|
-
block('`done` requires the 5-argument RPC shape with `p_actor_session_id` and `p_actor_role`.', statusCall);
|
|
785
|
-
}
|
|
786
|
-
if (statusCall.actorRole !== 'validator') {
|
|
787
|
-
block('Only a validator may transition a non-epic work item to `done`; implementation agents move work to `review`.', statusCall);
|
|
788
|
-
}
|
|
789
|
-
if (/submit_implementation_report/i.test(blob)) {
|
|
790
|
-
block('Submit the implementation report in a separate tool call before `done`; the gate verifies committed DB state.', statusCall);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
let rows;
|
|
794
|
-
let events;
|
|
795
|
-
let capturedRows;
|
|
796
|
-
try {
|
|
797
|
-
rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
|
|
798
|
-
events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
|
|
799
|
-
capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
|
|
800
|
-
} catch (e) {
|
|
801
|
-
block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
const item = rows && rows[0];
|
|
805
|
-
if (!item) block(`Work item ${statusCall.id} was not found.`, statusCall);
|
|
806
|
-
if (item.item_type === 'epic') return;
|
|
807
|
-
if (item.status !== 'review') block('Non-epic work items must be in `review` before a validator may mark them `done`.', { ...statusCall, currentStatus: item.status });
|
|
808
|
-
|
|
809
|
-
const report = item.implementation_report;
|
|
810
|
-
if (!report || typeof report !== 'object') block('`done` rejected because `implementation_report` is null or not an object.', statusCall);
|
|
811
|
-
if (!report.codeflow_post || typeof report.codeflow_post !== 'object') block('`done` rejected because `implementation_report.codeflow_post` is missing.', statusCall);
|
|
812
|
-
|
|
813
|
-
const missing = requiredItems(item.checklist).filter((ci) => ci.checked !== true);
|
|
814
|
-
if (missing.length > 0) {
|
|
815
|
-
block(`Required checklist items are unchecked: ${missing.map((ci) => ci.id || ci.text || '<unknown>').join(', ')}`, statusCall);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
const latestByItem = new Map();
|
|
819
|
-
for (const event of Array.isArray(events) ? events : []) {
|
|
820
|
-
if (!latestByItem.has(event.item_id)) latestByItem.set(event.item_id, event);
|
|
821
|
-
}
|
|
822
|
-
const supervisorReticks = requiredItems(item.checklist)
|
|
823
|
-
.map((ci) => latestByItem.get(ci.id))
|
|
824
|
-
.filter((event) => event && event.checked === true && event.actor_role !== 'agent');
|
|
825
|
-
if (supervisorReticks.length > 0) {
|
|
826
|
-
block(
|
|
827
|
-
`Required checklist items were last ticked by a non-agent session: ${supervisorReticks.map((e) => e.item_id).join(', ')}`,
|
|
828
|
-
{ ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
|
|
833
|
-
// The L1-captured SHA set is restricted to the item's ORIGINATING session
|
|
834
|
-
// (the session that ticked the checklist) so a SHA captured by some other
|
|
835
|
-
// session against this item cannot launder a fabricated close. A null
|
|
836
|
-
// originating session is fail-closed (DENY) inside buildCapturedShaSet.
|
|
837
|
-
try {
|
|
838
|
-
const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
|
|
839
|
-
await verifyLayer2(statusCall, item, capturedShas);
|
|
840
|
-
} catch (e) {
|
|
841
|
-
if (e instanceof GateDenied) {
|
|
842
|
-
block(e.reason, e.details); // translate the L2 denial into a hard block
|
|
843
|
-
}
|
|
844
|
-
// Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
|
|
845
|
-
block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
|
|
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
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function validateTick(tick, rawTool) {
|
|
868
|
-
logTick(tick, rawTool);
|
|
869
|
-
if (!tick.checked) return;
|
|
870
|
-
if (!tick.workItemId || !tick.itemId) {
|
|
871
|
-
block('Checklist ticks must include `p_work_item_id` and `p_item_id`.', tick);
|
|
872
|
-
}
|
|
873
|
-
if (!tick.actorSessionId) {
|
|
874
|
-
block('Checklist ticks must include `p_actor_session_id`; unaudited ticks are rejected.', tick);
|
|
875
|
-
}
|
|
876
|
-
if (tick.actorRole !== 'agent') {
|
|
877
|
-
block('Only the originating implementation agent may tick required checklist evidence. Supervisors/validators reopen or review; they do not re-tick.', tick);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
async function main() {
|
|
882
|
-
let raw;
|
|
883
|
-
try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
|
|
884
|
-
|
|
885
|
-
const blob = stringifyTool(raw.tool_input || raw);
|
|
886
|
-
const tick = extractTickCall(blob);
|
|
887
|
-
if (tick) validateTick(tick, raw);
|
|
888
|
-
|
|
889
|
-
const statusCall = extractStatusCall(blob);
|
|
890
|
-
if (!statusCall) pass({ reason: 'no-status-call' });
|
|
891
|
-
|
|
892
|
-
// Ambiguous-parse fail-closed: the tool blob references update_work_item_status
|
|
893
|
-
// AND contains a 'done' literal, but we could not extract a usable id/status.
|
|
894
|
-
// A done-close we cannot parse must NOT slip through as a pass — block.
|
|
895
|
-
if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
|
|
896
|
-
if (!statusCall.id || !statusCall.status) {
|
|
897
|
-
block(
|
|
898
|
-
'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
|
|
899
|
-
'(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
|
|
900
|
-
'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
|
|
901
|
-
statusCall,
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const status = String(statusCall.status || '').toLowerCase();
|
|
907
|
-
if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
|
|
908
|
-
block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
|
|
909
|
-
}
|
|
910
|
-
if (status === 'done') {
|
|
911
|
-
await verifyDone(statusCall, blob);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
pass({ status });
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Run as a hook; export the Layer-2 internals when required by a test.
|
|
918
|
-
if (require.main === module) {
|
|
919
|
-
main().catch((e) => block(`Exit gate crashed: ${e.message}`));
|
|
920
|
-
} else {
|
|
921
|
-
module.exports = {
|
|
922
|
-
GateDenied,
|
|
923
|
-
deny,
|
|
924
|
-
verifyLayer2,
|
|
925
|
-
resolveFullSha,
|
|
926
|
-
filesInCommit,
|
|
927
|
-
fileOnDisk,
|
|
928
|
-
fileKnownToRepo,
|
|
929
|
-
normalizeRepoPath,
|
|
930
|
-
loadEvidenceLib,
|
|
931
|
-
assertGitRepoAvailable,
|
|
932
|
-
buildCapturedShaSet,
|
|
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,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse hook — hard-stop unsafe work item exit transitions.
|
|
4
|
+
*
|
|
5
|
+
* This gate runs before tool calls and blocks legacy or supervisor-mutated
|
|
6
|
+
* CodeFlow/work-item close patterns before they reach Supabase. The database
|
|
7
|
+
* remains the final authority; this hook gives agents an immediate stop sign.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
const hookLog = require('./hook-logger');
|
|
17
|
+
|
|
18
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
19
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
20
|
+
const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
|
|
21
|
+
const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
|
|
22
|
+
|
|
23
|
+
// Truth Gate 3.0 — Layer 2 (FUSED evidence gate).
|
|
24
|
+
// Witness allow-list (D5 / SLSA: the doer cannot sign its own provenance).
|
|
25
|
+
const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
|
|
26
|
+
// Repo the gate runs `git` against. The work tree under verification is
|
|
27
|
+
// regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
|
|
28
|
+
const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
|
|
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
|
+
|
|
45
|
+
function readStdin() {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let input = '';
|
|
48
|
+
process.stdin.setEncoding('utf8');
|
|
49
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
50
|
+
process.stdin.on('end', () => resolve(input));
|
|
51
|
+
process.stdin.resume();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function block(message, details = {}) {
|
|
56
|
+
hookLog('work-item-exit-gate', 'PreToolUse', 'block', details);
|
|
57
|
+
process.stdout.write(JSON.stringify({
|
|
58
|
+
systemMessage: `HARD BLOCK — Work item exit gate rejected this tool call.\n\n${message}`,
|
|
59
|
+
}));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pass(details = {}) {
|
|
64
|
+
hookLog('work-item-exit-gate', 'PreToolUse', 'pass', details);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringifyTool(toolInput) {
|
|
69
|
+
if (typeof toolInput === 'string') return toolInput;
|
|
70
|
+
try { return JSON.stringify(toolInput); } catch { return ''; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stripCasts(value) {
|
|
74
|
+
return String(value || '')
|
|
75
|
+
.replace(/::\s*[a-z_][\w.]*/gi, '')
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unquote(value) {
|
|
80
|
+
const v = stripCasts(value);
|
|
81
|
+
const m = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
|
|
82
|
+
return (m ? m[1] : v).replace(/''/g, "'").trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getNamedArg(text, name) {
|
|
86
|
+
const re = new RegExp(`${name}\\s*:?=\\s*('(?:''|[^'])*'|"(?:\\\\"|[^"])*"|true|false|null|[0-9a-f-]{36})`, 'i');
|
|
87
|
+
const m = text.match(re);
|
|
88
|
+
return m ? unquote(m[1]) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function splitArgs(argsText) {
|
|
92
|
+
const args = [];
|
|
93
|
+
let current = '';
|
|
94
|
+
let quote = null;
|
|
95
|
+
let depth = 0;
|
|
96
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
97
|
+
const ch = argsText[i];
|
|
98
|
+
const next = argsText[i + 1];
|
|
99
|
+
if (quote) {
|
|
100
|
+
current += ch;
|
|
101
|
+
if (ch === quote && !(quote === "'" && next === "'")) quote = null;
|
|
102
|
+
else if (ch === "'" && next === "'") current += argsText[++i];
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === "'" || ch === '"') {
|
|
106
|
+
quote = ch;
|
|
107
|
+
current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
111
|
+
if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
|
|
112
|
+
if (ch === ',' && depth === 0) {
|
|
113
|
+
args.push(current.trim());
|
|
114
|
+
current = '';
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
current += ch;
|
|
118
|
+
}
|
|
119
|
+
if (current.trim()) args.push(current.trim());
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findCall(text, name) {
|
|
124
|
+
const idx = text.toLowerCase().indexOf(name.toLowerCase());
|
|
125
|
+
if (idx < 0) return null;
|
|
126
|
+
const open = text.indexOf('(', idx + name.length);
|
|
127
|
+
if (open < 0) return null;
|
|
128
|
+
let quote = null;
|
|
129
|
+
let depth = 0;
|
|
130
|
+
for (let i = open; i < text.length; i++) {
|
|
131
|
+
const ch = text[i];
|
|
132
|
+
const next = text[i + 1];
|
|
133
|
+
if (quote) {
|
|
134
|
+
if (ch === quote && !(quote === "'" && next === "'")) quote = null;
|
|
135
|
+
else if (ch === "'" && next === "'") i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === "'" || ch === '"') {
|
|
139
|
+
quote = ch;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ch === '(') depth++;
|
|
143
|
+
if (ch === ')') {
|
|
144
|
+
depth--;
|
|
145
|
+
if (depth === 0) return text.slice(open + 1, i);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function objectArg(blob, name) {
|
|
152
|
+
const re = new RegExp(`"${name}"\\s*:\\s*("([^"]*)"|true|false|null)`, 'i');
|
|
153
|
+
const m = blob.match(re);
|
|
154
|
+
if (!m) return null;
|
|
155
|
+
if (m[1] === 'true' || m[1] === 'false' || m[1] === 'null') return m[1];
|
|
156
|
+
return m[2] || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractStatusCall(blob) {
|
|
160
|
+
if (!/update_work_item_status/i.test(blob)) return null;
|
|
161
|
+
const call = findCall(blob, 'update_work_item_status');
|
|
162
|
+
const args = call ? splitArgs(call) : [];
|
|
163
|
+
return {
|
|
164
|
+
id: objectArg(blob, 'p_id') || getNamedArg(blob, 'p_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
|
|
165
|
+
status: objectArg(blob, 'p_status') || getNamedArg(blob, 'p_status') || unquote(args[1] || ''),
|
|
166
|
+
actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
|
|
167
|
+
actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractTickCall(blob) {
|
|
172
|
+
if (!/update_checklist_item/i.test(blob)) return null;
|
|
173
|
+
const call = findCall(blob, 'update_checklist_item');
|
|
174
|
+
const args = call ? splitArgs(call) : [];
|
|
175
|
+
return {
|
|
176
|
+
workItemId: objectArg(blob, 'p_work_item_id') || getNamedArg(blob, 'p_work_item_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
|
|
177
|
+
itemId: objectArg(blob, 'p_item_id') || getNamedArg(blob, 'p_item_id') || unquote(args[1] || ''),
|
|
178
|
+
checked: (objectArg(blob, 'p_checked') || getNamedArg(blob, 'p_checked') || unquote(args[2] || '')).toLowerCase() === 'true',
|
|
179
|
+
actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
|
|
180
|
+
actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function logTick(tick, rawTool) {
|
|
185
|
+
try {
|
|
186
|
+
fs.mkdirSync(path.dirname(EVENT_LOG), { recursive: true });
|
|
187
|
+
fs.appendFileSync(EVENT_LOG, JSON.stringify({
|
|
188
|
+
ts: new Date().toISOString(),
|
|
189
|
+
work_item_id: tick.workItemId || null,
|
|
190
|
+
item_id: tick.itemId || null,
|
|
191
|
+
checked: tick.checked,
|
|
192
|
+
actor_session_id: tick.actorSessionId || null,
|
|
193
|
+
actor_role: tick.actorRole || null,
|
|
194
|
+
tool_name: rawTool.tool_name || null,
|
|
195
|
+
}) + '\n');
|
|
196
|
+
} catch (_) {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function getServiceKey() {
|
|
200
|
+
if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
201
|
+
if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
|
|
202
|
+
for (const endpoint of ['supabase-service', 'supabase-service-role']) {
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
|
|
205
|
+
if (res.ok) {
|
|
206
|
+
const text = (await res.text()).trim();
|
|
207
|
+
if (text && !text.startsWith('{')) return text;
|
|
208
|
+
}
|
|
209
|
+
} catch (_) {}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function supabaseGet(pathname) {
|
|
215
|
+
const key = await getServiceKey();
|
|
216
|
+
if (!key) throw new Error('clauth/env did not provide a Supabase service key');
|
|
217
|
+
const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
|
|
218
|
+
const res = await fetch(`${base}/rest/v1/${pathname}`, {
|
|
219
|
+
headers: {
|
|
220
|
+
apikey: key,
|
|
221
|
+
Authorization: `Bearer ${key}`,
|
|
222
|
+
Accept: 'application/json',
|
|
223
|
+
},
|
|
224
|
+
signal: AbortSignal.timeout(3500),
|
|
225
|
+
});
|
|
226
|
+
if (!res.ok) throw new Error(`Supabase verification HTTP ${res.status}`);
|
|
227
|
+
return res.json();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requiredItems(checklist) {
|
|
231
|
+
return Array.isArray(checklist)
|
|
232
|
+
? checklist.filter((item) => item && item.required === true)
|
|
233
|
+
: [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===========================================================================
|
|
237
|
+
// Truth Gate 3.0 — Layer 2 (FUSED evidence gate / freeze-the-leak)
|
|
238
|
+
//
|
|
239
|
+
// Asserts, server-checkable and FAIL-CLOSED, that a `done` close corresponds to
|
|
240
|
+
// reality. Baru-trap hardening (from the auditors' OWN false positives):
|
|
241
|
+
// - resolve commits by FULL 40-hex SHA only — never short-prefix compare
|
|
242
|
+
// (prefix collisions falsely flagged real work as fabricated).
|
|
243
|
+
// - locate claimed files WHOLE-REPO (`git log --all -- <path>`), never
|
|
244
|
+
// cited-path-only (wrong-path lookups falsely cried "absent").
|
|
245
|
+
// ===========================================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A Layer-2 denial. Thrown by `deny()` so verifyLayer2 short-circuits without
|
|
249
|
+
* coupling to process.exit — production translates it to block(), tests assert
|
|
250
|
+
* on `.reason`. This keeps the gate logic pure + unit-testable while staying
|
|
251
|
+
* FAIL-CLOSED at the process boundary.
|
|
252
|
+
*/
|
|
253
|
+
class GateDenied extends Error {
|
|
254
|
+
constructor(reason, details) {
|
|
255
|
+
super(reason);
|
|
256
|
+
this.name = 'GateDenied';
|
|
257
|
+
this.reason = reason;
|
|
258
|
+
this.details = details || {};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function deny(reason, details) {
|
|
263
|
+
throw new GateDenied(reason, details);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the L1-captured SHA set bound to the item's ORIGINATING session, or
|
|
268
|
+
* DENY (throw GateDenied) when there is no originating session to bind to.
|
|
269
|
+
*
|
|
270
|
+
* FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
|
|
271
|
+
* binding (the old `!originatingSession` short-circuit accepted ANY session's
|
|
272
|
+
* captured SHA — a fail-open laundering hole). With no session, there is no
|
|
273
|
+
* provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
|
|
274
|
+
* session_id exactly equals the originating session.
|
|
275
|
+
*
|
|
276
|
+
* @param originatingSession string|null the item's session_id
|
|
277
|
+
* @param capturedRows Array<{sha, session_id}> from work_item_commits
|
|
278
|
+
* @returns Set<string> full-SHA (lowercased) set for this item+session
|
|
279
|
+
*/
|
|
280
|
+
function buildCapturedShaSet(originatingSession, capturedRows) {
|
|
281
|
+
const sess = originatingSession || null;
|
|
282
|
+
if (!sess) {
|
|
283
|
+
deny(
|
|
284
|
+
'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
|
|
285
|
+
'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
|
|
286
|
+
{ originatingSession },
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return new Set(
|
|
290
|
+
(Array.isArray(capturedRows) ? capturedRows : [])
|
|
291
|
+
.filter((r) => r && r.session_id === sess)
|
|
292
|
+
.map((r) => String((r && r.sha) || '').toLowerCase())
|
|
293
|
+
.filter((s) => FULL_SHA_RE.test(s)),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Run a git command in the truth-gate repo; returns trimmed stdout or null.
|
|
298
|
+
* stderr is captured (piped) so a failure carries a usable diagnostic instead
|
|
299
|
+
* of being swallowed. On failure with allowFail, returns null; otherwise the
|
|
300
|
+
* thrown error retains git's stderr in e.stderr / e.message. */
|
|
301
|
+
function git(args, { allowFail = false } = {}) {
|
|
302
|
+
try {
|
|
303
|
+
return execFileSync('git', args, {
|
|
304
|
+
cwd: TRUTH_GATE_REPO,
|
|
305
|
+
encoding: 'utf8',
|
|
306
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
308
|
+
}).trim();
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if (allowFail) return null;
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
|
|
317
|
+
* can interrogate. If git is missing or the path is not a work tree, every
|
|
318
|
+
* downstream SHA/file check would silently mis-verify, so we fail-closed with a
|
|
319
|
+
* clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
|
|
320
|
+
*/
|
|
321
|
+
function assertGitRepoAvailable() {
|
|
322
|
+
let out;
|
|
323
|
+
try {
|
|
324
|
+
out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
325
|
+
cwd: TRUTH_GATE_REPO,
|
|
326
|
+
encoding: 'utf8',
|
|
327
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
328
|
+
maxBuffer: 1024 * 1024,
|
|
329
|
+
}).trim();
|
|
330
|
+
} catch (e) {
|
|
331
|
+
const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
|
|
332
|
+
deny(
|
|
333
|
+
'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
|
|
334
|
+
'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
|
|
335
|
+
{ repo: TRUTH_GATE_REPO },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (out !== 'true') {
|
|
339
|
+
deny(
|
|
340
|
+
'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
|
|
341
|
+
'The gate cannot verify commit/file provenance; fail-closed.',
|
|
342
|
+
{ repo: TRUTH_GATE_REPO },
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
|
|
349
|
+
* Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
|
|
350
|
+
* NEVER does a prefix/substring compare — the returned value is the canonical
|
|
351
|
+
* full SHA, and all equality downstream is full-SHA equality.
|
|
352
|
+
*/
|
|
353
|
+
function resolveFullSha(ref) {
|
|
354
|
+
if (typeof ref !== 'string' || !ref.trim()) return null;
|
|
355
|
+
const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
|
|
356
|
+
return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
|
|
360
|
+
function filesInCommit(fullSha) {
|
|
361
|
+
const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
|
|
362
|
+
if (out == null) return null;
|
|
363
|
+
return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function normalizeRepoPath(p) {
|
|
367
|
+
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Whole-repo existence check for a claimed file. A file "exists for this repo"
|
|
372
|
+
* when EITHER it is tracked anywhere in history (`git log --all -- <path>`
|
|
373
|
+
* returns a commit) OR it is present on disk. This is deliberately broad to
|
|
374
|
+
* avoid the cited-path-only false "absent" verdict.
|
|
375
|
+
*/
|
|
376
|
+
function fileKnownToRepo(repoPath) {
|
|
377
|
+
const p = normalizeRepoPath(repoPath);
|
|
378
|
+
if (!p) return false;
|
|
379
|
+
// On disk now?
|
|
380
|
+
try {
|
|
381
|
+
if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
|
|
382
|
+
} catch (_) { /* fall through to history */ }
|
|
383
|
+
// Tracked anywhere in history (whole-repo locator, never cited-path-only)?
|
|
384
|
+
const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
|
|
385
|
+
return Boolean(log && log.length > 0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Does the file exist on disk in the work tree right now? */
|
|
389
|
+
function fileOnDisk(repoPath) {
|
|
390
|
+
const p = normalizeRepoPath(repoPath);
|
|
391
|
+
if (!p) return false;
|
|
392
|
+
try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let _evidenceLib = null;
|
|
396
|
+
/** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
|
|
397
|
+
* Uses a file:// URL so the dynamic import works on Windows absolute paths.
|
|
398
|
+
* Returns { isMachineArtifact, isPassingArtifact }. */
|
|
399
|
+
async function loadEvidenceLib() {
|
|
400
|
+
if (_evidenceLib) return _evidenceLib;
|
|
401
|
+
const { pathToFileURL } = require('url');
|
|
402
|
+
const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
|
|
403
|
+
const mod = await import(pathToFileURL(libPath).href);
|
|
404
|
+
_evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
|
|
405
|
+
return _evidenceLib;
|
|
406
|
+
}
|
|
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
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Layer-2 verification of a `done` close against the real repo. Throws (caught
|
|
660
|
+
* by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
|
|
661
|
+
* an inability to verify is a DENY, never a silent pass.
|
|
662
|
+
*
|
|
663
|
+
* @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
|
|
664
|
+
* @param item the work_items row (has implementation_report)
|
|
665
|
+
* @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
|
|
666
|
+
*/
|
|
667
|
+
async function verifyLayer2(statusCall, item, capturedShas) {
|
|
668
|
+
// Fail-closed: the gate's commit/file checks all shell out to git. If the
|
|
669
|
+
// truth-gate repo is not a usable git work tree, verify nothing — DENY.
|
|
670
|
+
assertGitRepoAvailable();
|
|
671
|
+
|
|
672
|
+
const post = item.implementation_report && item.implementation_report.codeflow_post;
|
|
673
|
+
if (!post || typeof post !== 'object') {
|
|
674
|
+
deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
|
|
678
|
+
const witness = String(post.witness || '').trim();
|
|
679
|
+
if (!WITNESS_ALLOWLIST.has(witness)) {
|
|
680
|
+
deny(
|
|
681
|
+
'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
|
|
682
|
+
(witness || '<missing>') + '". ' +
|
|
683
|
+
'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
|
|
684
|
+
{ ...statusCall, witness },
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
|
|
689
|
+
const claimedCommit = post.commit;
|
|
690
|
+
const fullSha = resolveFullSha(claimedCommit);
|
|
691
|
+
if (!fullSha) {
|
|
692
|
+
deny(
|
|
693
|
+
'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
|
|
694
|
+
'") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
|
|
695
|
+
{ ...statusCall, claimedCommit },
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
if (!capturedShas.has(fullSha)) {
|
|
699
|
+
deny(
|
|
700
|
+
'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
|
|
701
|
+
'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
|
|
702
|
+
'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
|
|
703
|
+
{ ...statusCall, fullSha, capturedCount: capturedShas.size },
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// (2)/(3) Every files_changed entry is in the commit AND exists on disk.
|
|
708
|
+
const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
|
|
709
|
+
if (filesChanged.length === 0) {
|
|
710
|
+
deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
|
|
711
|
+
}
|
|
712
|
+
const commitFiles = filesInCommit(fullSha);
|
|
713
|
+
if (commitFiles == null) {
|
|
714
|
+
deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
|
|
715
|
+
}
|
|
716
|
+
for (const raw of filesChanged) {
|
|
717
|
+
const p = normalizeRepoPath(raw);
|
|
718
|
+
if (!p) {
|
|
719
|
+
deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
|
|
720
|
+
}
|
|
721
|
+
// In the commit? (whole-repo lookup is implicit — commitFiles is the full
|
|
722
|
+
// name-only set of the resolved commit, not a cited-path filter.)
|
|
723
|
+
if (!commitFiles.has(p)) {
|
|
724
|
+
deny(
|
|
725
|
+
'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
|
|
726
|
+
' (git show --stat). files_changed must match the commit\'s actual contents.',
|
|
727
|
+
{ ...statusCall, file: p, fullSha },
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
// On disk now?
|
|
731
|
+
if (!fileOnDisk(p)) {
|
|
732
|
+
deny(
|
|
733
|
+
'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
|
|
734
|
+
'A deliverable that is not present is not done.',
|
|
735
|
+
{ ...statusCall, file: p },
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
// Whole-repo sanity (defense in depth; should always hold if on disk).
|
|
739
|
+
if (!fileKnownToRepo(p)) {
|
|
740
|
+
deny(
|
|
741
|
+
'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
|
|
742
|
+
{ ...statusCall, file: p },
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// (4) Every verification entry is a machine-parseable artifact, not prose,
|
|
748
|
+
// AND its OUTCOME is a PASS — not merely that it RAN. A failing run
|
|
749
|
+
// (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
|
|
750
|
+
// test artifact with failures must DENY: "ran" is not "passed".
|
|
751
|
+
const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
|
|
752
|
+
const verifications = Array.isArray(post.verification) ? post.verification : [];
|
|
753
|
+
if (verifications.length === 0) {
|
|
754
|
+
deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
|
|
755
|
+
}
|
|
756
|
+
for (const v of verifications) {
|
|
757
|
+
// 4a. Shape gate — it must be a captured machine artifact, not prose.
|
|
758
|
+
if (!isMachineArtifact(v)) {
|
|
759
|
+
const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
|
|
760
|
+
deny(
|
|
761
|
+
'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
|
|
762
|
+
'Each verification must be a run-evidence-gate result or a machine shape ' +
|
|
763
|
+
'({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
|
|
764
|
+
{ ...statusCall, verification: shown },
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
// 4b. Outcome gate — the captured artifact must read as a PASS.
|
|
768
|
+
if (!isPassingArtifact(v)) {
|
|
769
|
+
const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
|
|
770
|
+
deny(
|
|
771
|
+
'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
|
|
772
|
+
'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
|
|
773
|
+
'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
|
|
774
|
+
{ ...statusCall, verification: shown },
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// No denial thrown => Layer-2 verification PASSED.
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function verifyDone(statusCall, blob) {
|
|
782
|
+
if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
|
|
783
|
+
if (!statusCall.actorSessionId || !statusCall.actorRole) {
|
|
784
|
+
block('`done` requires the 5-argument RPC shape with `p_actor_session_id` and `p_actor_role`.', statusCall);
|
|
785
|
+
}
|
|
786
|
+
if (statusCall.actorRole !== 'validator') {
|
|
787
|
+
block('Only a validator may transition a non-epic work item to `done`; implementation agents move work to `review`.', statusCall);
|
|
788
|
+
}
|
|
789
|
+
if (/submit_implementation_report/i.test(blob)) {
|
|
790
|
+
block('Submit the implementation report in a separate tool call before `done`; the gate verifies committed DB state.', statusCall);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let rows;
|
|
794
|
+
let events;
|
|
795
|
+
let capturedRows;
|
|
796
|
+
try {
|
|
797
|
+
rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
|
|
798
|
+
events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
|
|
799
|
+
capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
|
|
800
|
+
} catch (e) {
|
|
801
|
+
block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const item = rows && rows[0];
|
|
805
|
+
if (!item) block(`Work item ${statusCall.id} was not found.`, statusCall);
|
|
806
|
+
if (item.item_type === 'epic') return;
|
|
807
|
+
if (item.status !== 'review') block('Non-epic work items must be in `review` before a validator may mark them `done`.', { ...statusCall, currentStatus: item.status });
|
|
808
|
+
|
|
809
|
+
const report = item.implementation_report;
|
|
810
|
+
if (!report || typeof report !== 'object') block('`done` rejected because `implementation_report` is null or not an object.', statusCall);
|
|
811
|
+
if (!report.codeflow_post || typeof report.codeflow_post !== 'object') block('`done` rejected because `implementation_report.codeflow_post` is missing.', statusCall);
|
|
812
|
+
|
|
813
|
+
const missing = requiredItems(item.checklist).filter((ci) => ci.checked !== true);
|
|
814
|
+
if (missing.length > 0) {
|
|
815
|
+
block(`Required checklist items are unchecked: ${missing.map((ci) => ci.id || ci.text || '<unknown>').join(', ')}`, statusCall);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const latestByItem = new Map();
|
|
819
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
820
|
+
if (!latestByItem.has(event.item_id)) latestByItem.set(event.item_id, event);
|
|
821
|
+
}
|
|
822
|
+
const supervisorReticks = requiredItems(item.checklist)
|
|
823
|
+
.map((ci) => latestByItem.get(ci.id))
|
|
824
|
+
.filter((event) => event && event.checked === true && event.actor_role !== 'agent');
|
|
825
|
+
if (supervisorReticks.length > 0) {
|
|
826
|
+
block(
|
|
827
|
+
`Required checklist items were last ticked by a non-agent session: ${supervisorReticks.map((e) => e.item_id).join(', ')}`,
|
|
828
|
+
{ ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
|
|
833
|
+
// The L1-captured SHA set is restricted to the item's ORIGINATING session
|
|
834
|
+
// (the session that ticked the checklist) so a SHA captured by some other
|
|
835
|
+
// session against this item cannot launder a fabricated close. A null
|
|
836
|
+
// originating session is fail-closed (DENY) inside buildCapturedShaSet.
|
|
837
|
+
try {
|
|
838
|
+
const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
|
|
839
|
+
await verifyLayer2(statusCall, item, capturedShas);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
if (e instanceof GateDenied) {
|
|
842
|
+
block(e.reason, e.details); // translate the L2 denial into a hard block
|
|
843
|
+
}
|
|
844
|
+
// Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
|
|
845
|
+
block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
|
|
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
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function validateTick(tick, rawTool) {
|
|
868
|
+
logTick(tick, rawTool);
|
|
869
|
+
if (!tick.checked) return;
|
|
870
|
+
if (!tick.workItemId || !tick.itemId) {
|
|
871
|
+
block('Checklist ticks must include `p_work_item_id` and `p_item_id`.', tick);
|
|
872
|
+
}
|
|
873
|
+
if (!tick.actorSessionId) {
|
|
874
|
+
block('Checklist ticks must include `p_actor_session_id`; unaudited ticks are rejected.', tick);
|
|
875
|
+
}
|
|
876
|
+
if (tick.actorRole !== 'agent') {
|
|
877
|
+
block('Only the originating implementation agent may tick required checklist evidence. Supervisors/validators reopen or review; they do not re-tick.', tick);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function main() {
|
|
882
|
+
let raw;
|
|
883
|
+
try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
|
|
884
|
+
|
|
885
|
+
const blob = stringifyTool(raw.tool_input || raw);
|
|
886
|
+
const tick = extractTickCall(blob);
|
|
887
|
+
if (tick) validateTick(tick, raw);
|
|
888
|
+
|
|
889
|
+
const statusCall = extractStatusCall(blob);
|
|
890
|
+
if (!statusCall) pass({ reason: 'no-status-call' });
|
|
891
|
+
|
|
892
|
+
// Ambiguous-parse fail-closed: the tool blob references update_work_item_status
|
|
893
|
+
// AND contains a 'done' literal, but we could not extract a usable id/status.
|
|
894
|
+
// A done-close we cannot parse must NOT slip through as a pass — block.
|
|
895
|
+
if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
|
|
896
|
+
if (!statusCall.id || !statusCall.status) {
|
|
897
|
+
block(
|
|
898
|
+
'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
|
|
899
|
+
'(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
|
|
900
|
+
'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
|
|
901
|
+
statusCall,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const status = String(statusCall.status || '').toLowerCase();
|
|
907
|
+
if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
|
|
908
|
+
block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
|
|
909
|
+
}
|
|
910
|
+
if (status === 'done') {
|
|
911
|
+
await verifyDone(statusCall, blob);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
pass({ status });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Run as a hook; export the Layer-2 internals when required by a test.
|
|
918
|
+
if (require.main === module) {
|
|
919
|
+
main().catch((e) => block(`Exit gate crashed: ${e.message}`));
|
|
920
|
+
} else {
|
|
921
|
+
module.exports = {
|
|
922
|
+
GateDenied,
|
|
923
|
+
deny,
|
|
924
|
+
verifyLayer2,
|
|
925
|
+
resolveFullSha,
|
|
926
|
+
filesInCommit,
|
|
927
|
+
fileOnDisk,
|
|
928
|
+
fileKnownToRepo,
|
|
929
|
+
normalizeRepoPath,
|
|
930
|
+
loadEvidenceLib,
|
|
931
|
+
assertGitRepoAvailable,
|
|
932
|
+
buildCapturedShaSet,
|
|
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,
|
|
943
|
+
};
|
|
944
|
+
}
|