@lifeaitools/rdc-skills 0.24.2 → 0.24.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/git-sha.json
CHANGED
|
@@ -171,4 +171,71 @@ export function isMachineArtifact(v) {
|
|
|
171
171
|
return false;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Does `v` represent a verification whose OUTCOME is a PASS — not merely that it
|
|
176
|
+
* ran? This is the outcome gate that complements isMachineArtifact (the shape
|
|
177
|
+
* gate). A failing run (exit_code:1), an error HTTP status (500), a fused
|
|
178
|
+
* artifact with verdict:'fail', or a test artifact with failures must NOT be
|
|
179
|
+
* accepted as evidence of a passing verification.
|
|
180
|
+
*
|
|
181
|
+
* Returns true ONLY when the artifact is a recognised machine shape AND its
|
|
182
|
+
* outcome reads as a pass. Anything ambiguous or non-passing returns false.
|
|
183
|
+
*
|
|
184
|
+
* Pass rules (mirrors isMachineArtifact's accepted shapes):
|
|
185
|
+
* - fused run-evidence-gate/v1 → ran===true && verdict==='pass'
|
|
186
|
+
* - { exit_code } → exit_code === 0
|
|
187
|
+
* - { http_status|status_code }→ 200 <= s <= 399
|
|
188
|
+
* - { passed, failed } → failed === 0
|
|
189
|
+
* - { passed, total } → passed === total
|
|
190
|
+
* - { tsc_errors } → tsc_errors === 0
|
|
191
|
+
* - { rowcount } → a captured rowcount is presence-only evidence;
|
|
192
|
+
* any numeric rowcount counts as a pass.
|
|
193
|
+
*/
|
|
194
|
+
export function isPassingArtifact(v) {
|
|
195
|
+
if (v == null) return false;
|
|
196
|
+
|
|
197
|
+
// String input: only accepted if it parses to a recognised JSON artifact.
|
|
198
|
+
if (typeof v === 'string') {
|
|
199
|
+
const s = v.trim();
|
|
200
|
+
if (!(s.startsWith('{') || s.startsWith('['))) return false; // bare prose
|
|
201
|
+
let parsed;
|
|
202
|
+
try { parsed = JSON.parse(s); } catch { return false; }
|
|
203
|
+
return isPassingArtifact(parsed);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof v !== 'object') return false;
|
|
207
|
+
|
|
208
|
+
// Must be a recognised machine shape first.
|
|
209
|
+
if (!isMachineArtifact(v)) return false;
|
|
210
|
+
|
|
211
|
+
// 1. Fused artifact — the verdict is authoritative.
|
|
212
|
+
if (v.kind === EVIDENCE_KIND) {
|
|
213
|
+
return v.ran === true && v.verdict === 'pass';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. Captured-artifact shapes — read the outcome, not just the presence.
|
|
217
|
+
// A tsc/test error count is checked even alongside another field.
|
|
218
|
+
if (typeof v.tsc_errors === 'number') return v.tsc_errors === 0;
|
|
219
|
+
if (typeof v.tscErrors === 'number') return v.tscErrors === 0;
|
|
220
|
+
|
|
221
|
+
if (typeof v.exit_code === 'number') return v.exit_code === 0;
|
|
222
|
+
|
|
223
|
+
if (typeof v.http_status === 'number') return v.http_status >= 200 && v.http_status <= 399;
|
|
224
|
+
if (typeof v.httpStatus === 'number') return v.httpStatus >= 200 && v.httpStatus <= 399;
|
|
225
|
+
if (typeof v.status_code === 'number') return v.status_code >= 200 && v.status_code <= 399;
|
|
226
|
+
if (typeof v.statusCode === 'number') return v.statusCode >= 200 && v.statusCode <= 399;
|
|
227
|
+
|
|
228
|
+
if (typeof v.passed === 'number') {
|
|
229
|
+
if (typeof v.failed === 'number') return v.failed === 0;
|
|
230
|
+
if (typeof v.total === 'number') return v.passed === v.total;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (typeof v.rowcount === 'number') return true;
|
|
234
|
+
if (typeof v.row_count === 'number') return true;
|
|
235
|
+
if (typeof v.rowCount === 'number') return true;
|
|
236
|
+
|
|
237
|
+
// Recognised shape but no readable outcome → not a pass (fail-closed).
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default { runEvidenceGate, hashOutput, isMachineArtifact, isPassingArtifact, EVIDENCE_KIND };
|
|
@@ -247,13 +247,47 @@ function deny(reason, details) {
|
|
|
247
247
|
throw new GateDenied(reason, details);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Build the L1-captured SHA set bound to the item's ORIGINATING session, or
|
|
252
|
+
* DENY (throw GateDenied) when there is no originating session to bind to.
|
|
253
|
+
*
|
|
254
|
+
* FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
|
|
255
|
+
* binding (the old `!originatingSession` short-circuit accepted ANY session's
|
|
256
|
+
* captured SHA — a fail-open laundering hole). With no session, there is no
|
|
257
|
+
* provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
|
|
258
|
+
* session_id exactly equals the originating session.
|
|
259
|
+
*
|
|
260
|
+
* @param originatingSession string|null the item's session_id
|
|
261
|
+
* @param capturedRows Array<{sha, session_id}> from work_item_commits
|
|
262
|
+
* @returns Set<string> full-SHA (lowercased) set for this item+session
|
|
263
|
+
*/
|
|
264
|
+
function buildCapturedShaSet(originatingSession, capturedRows) {
|
|
265
|
+
const sess = originatingSession || null;
|
|
266
|
+
if (!sess) {
|
|
267
|
+
deny(
|
|
268
|
+
'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
|
|
269
|
+
'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
|
|
270
|
+
{ originatingSession },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return new Set(
|
|
274
|
+
(Array.isArray(capturedRows) ? capturedRows : [])
|
|
275
|
+
.filter((r) => r && r.session_id === sess)
|
|
276
|
+
.map((r) => String((r && r.sha) || '').toLowerCase())
|
|
277
|
+
.filter((s) => FULL_SHA_RE.test(s)),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Run a git command in the truth-gate repo; returns trimmed stdout or null.
|
|
282
|
+
* stderr is captured (piped) so a failure carries a usable diagnostic instead
|
|
283
|
+
* of being swallowed. On failure with allowFail, returns null; otherwise the
|
|
284
|
+
* thrown error retains git's stderr in e.stderr / e.message. */
|
|
251
285
|
function git(args, { allowFail = false } = {}) {
|
|
252
286
|
try {
|
|
253
287
|
return execFileSync('git', args, {
|
|
254
288
|
cwd: TRUTH_GATE_REPO,
|
|
255
289
|
encoding: 'utf8',
|
|
256
|
-
stdio: ['ignore', 'pipe', '
|
|
290
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
291
|
maxBuffer: 16 * 1024 * 1024,
|
|
258
292
|
}).trim();
|
|
259
293
|
} catch (e) {
|
|
@@ -262,6 +296,38 @@ function git(args, { allowFail = false } = {}) {
|
|
|
262
296
|
}
|
|
263
297
|
}
|
|
264
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
|
|
301
|
+
* can interrogate. If git is missing or the path is not a work tree, every
|
|
302
|
+
* downstream SHA/file check would silently mis-verify, so we fail-closed with a
|
|
303
|
+
* clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
|
|
304
|
+
*/
|
|
305
|
+
function assertGitRepoAvailable() {
|
|
306
|
+
let out;
|
|
307
|
+
try {
|
|
308
|
+
out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
309
|
+
cwd: TRUTH_GATE_REPO,
|
|
310
|
+
encoding: 'utf8',
|
|
311
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
312
|
+
maxBuffer: 1024 * 1024,
|
|
313
|
+
}).trim();
|
|
314
|
+
} catch (e) {
|
|
315
|
+
const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
|
|
316
|
+
deny(
|
|
317
|
+
'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
|
|
318
|
+
'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
|
|
319
|
+
{ repo: TRUTH_GATE_REPO },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (out !== 'true') {
|
|
323
|
+
deny(
|
|
324
|
+
'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
|
|
325
|
+
'The gate cannot verify commit/file provenance; fail-closed.',
|
|
326
|
+
{ repo: TRUTH_GATE_REPO },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
265
331
|
/**
|
|
266
332
|
* Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
|
|
267
333
|
* Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
|
|
@@ -310,16 +376,17 @@ function fileOnDisk(repoPath) {
|
|
|
310
376
|
try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
|
|
311
377
|
}
|
|
312
378
|
|
|
313
|
-
let
|
|
314
|
-
/** Lazy-load the fused primitive's artifact
|
|
315
|
-
* Uses a file:// URL so the dynamic import works on Windows absolute paths.
|
|
316
|
-
|
|
317
|
-
|
|
379
|
+
let _evidenceLib = null;
|
|
380
|
+
/** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
|
|
381
|
+
* Uses a file:// URL so the dynamic import works on Windows absolute paths.
|
|
382
|
+
* Returns { isMachineArtifact, isPassingArtifact }. */
|
|
383
|
+
async function loadEvidenceLib() {
|
|
384
|
+
if (_evidenceLib) return _evidenceLib;
|
|
318
385
|
const { pathToFileURL } = require('url');
|
|
319
386
|
const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
|
|
320
387
|
const mod = await import(pathToFileURL(libPath).href);
|
|
321
|
-
|
|
322
|
-
return
|
|
388
|
+
_evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
|
|
389
|
+
return _evidenceLib;
|
|
323
390
|
}
|
|
324
391
|
|
|
325
392
|
/**
|
|
@@ -332,6 +399,10 @@ async function loadIsMachineArtifact() {
|
|
|
332
399
|
* @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
|
|
333
400
|
*/
|
|
334
401
|
async function verifyLayer2(statusCall, item, capturedShas) {
|
|
402
|
+
// Fail-closed: the gate's commit/file checks all shell out to git. If the
|
|
403
|
+
// truth-gate repo is not a usable git work tree, verify nothing — DENY.
|
|
404
|
+
assertGitRepoAvailable();
|
|
405
|
+
|
|
335
406
|
const post = item.implementation_report && item.implementation_report.codeflow_post;
|
|
336
407
|
if (!post || typeof post !== 'object') {
|
|
337
408
|
deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
|
|
@@ -407,13 +478,17 @@ async function verifyLayer2(statusCall, item, capturedShas) {
|
|
|
407
478
|
}
|
|
408
479
|
}
|
|
409
480
|
|
|
410
|
-
// (4) Every verification entry is a machine-parseable artifact, not prose
|
|
411
|
-
|
|
481
|
+
// (4) Every verification entry is a machine-parseable artifact, not prose,
|
|
482
|
+
// AND its OUTCOME is a PASS — not merely that it RAN. A failing run
|
|
483
|
+
// (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
|
|
484
|
+
// test artifact with failures must DENY: "ran" is not "passed".
|
|
485
|
+
const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
|
|
412
486
|
const verifications = Array.isArray(post.verification) ? post.verification : [];
|
|
413
487
|
if (verifications.length === 0) {
|
|
414
488
|
deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
|
|
415
489
|
}
|
|
416
490
|
for (const v of verifications) {
|
|
491
|
+
// 4a. Shape gate — it must be a captured machine artifact, not prose.
|
|
417
492
|
if (!isMachineArtifact(v)) {
|
|
418
493
|
const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
|
|
419
494
|
deny(
|
|
@@ -423,6 +498,16 @@ async function verifyLayer2(statusCall, item, capturedShas) {
|
|
|
423
498
|
{ ...statusCall, verification: shown },
|
|
424
499
|
);
|
|
425
500
|
}
|
|
501
|
+
// 4b. Outcome gate — the captured artifact must read as a PASS.
|
|
502
|
+
if (!isPassingArtifact(v)) {
|
|
503
|
+
const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
|
|
504
|
+
deny(
|
|
505
|
+
'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
|
|
506
|
+
'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
|
|
507
|
+
'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
|
|
508
|
+
{ ...statusCall, verification: shown },
|
|
509
|
+
);
|
|
510
|
+
}
|
|
426
511
|
}
|
|
427
512
|
// No denial thrown => Layer-2 verification PASSED.
|
|
428
513
|
}
|
|
@@ -481,15 +566,10 @@ async function verifyDone(statusCall, blob) {
|
|
|
481
566
|
// --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
|
|
482
567
|
// The L1-captured SHA set is restricted to the item's ORIGINATING session
|
|
483
568
|
// (the session that ticked the checklist) so a SHA captured by some other
|
|
484
|
-
// session against this item cannot launder a fabricated close.
|
|
485
|
-
|
|
486
|
-
const capturedShas = new Set(
|
|
487
|
-
(Array.isArray(capturedRows) ? capturedRows : [])
|
|
488
|
-
.filter((r) => !originatingSession || r.session_id === originatingSession)
|
|
489
|
-
.map((r) => String(r.sha || '').toLowerCase())
|
|
490
|
-
.filter((s) => FULL_SHA_RE.test(s)),
|
|
491
|
-
);
|
|
569
|
+
// session against this item cannot launder a fabricated close. A null
|
|
570
|
+
// originating session is fail-closed (DENY) inside buildCapturedShaSet.
|
|
492
571
|
try {
|
|
572
|
+
const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
|
|
493
573
|
await verifyLayer2(statusCall, item, capturedShas);
|
|
494
574
|
} catch (e) {
|
|
495
575
|
if (e instanceof GateDenied) {
|
|
@@ -525,6 +605,20 @@ async function main() {
|
|
|
525
605
|
const statusCall = extractStatusCall(blob);
|
|
526
606
|
if (!statusCall) pass({ reason: 'no-status-call' });
|
|
527
607
|
|
|
608
|
+
// Ambiguous-parse fail-closed: the tool blob references update_work_item_status
|
|
609
|
+
// AND contains a 'done' literal, but we could not extract a usable id/status.
|
|
610
|
+
// A done-close we cannot parse must NOT slip through as a pass — block.
|
|
611
|
+
if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
|
|
612
|
+
if (!statusCall.id || !statusCall.status) {
|
|
613
|
+
block(
|
|
614
|
+
'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
|
|
615
|
+
'(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
|
|
616
|
+
'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
|
|
617
|
+
statusCall,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
528
622
|
const status = String(statusCall.status || '').toLowerCase();
|
|
529
623
|
if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
|
|
530
624
|
block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
|
|
@@ -549,7 +643,9 @@ if (require.main === module) {
|
|
|
549
643
|
fileOnDisk,
|
|
550
644
|
fileKnownToRepo,
|
|
551
645
|
normalizeRepoPath,
|
|
552
|
-
|
|
646
|
+
loadEvidenceLib,
|
|
647
|
+
assertGitRepoAvailable,
|
|
648
|
+
buildCapturedShaSet,
|
|
553
649
|
WITNESS_ALLOWLIST,
|
|
554
650
|
};
|
|
555
651
|
}
|
package/package.json
CHANGED
|
@@ -126,22 +126,31 @@ function runHook(payload, extraEnv = {}) {
|
|
|
126
126
|
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
128
|
// 4. PreToolUse legacy behavior preserved (warn-only, never blocks)
|
|
129
|
+
// Uses a fresh temp dir as HOME/USERPROFILE so no fixit.marker is visible
|
|
130
|
+
// regardless of real machine state — makes the warn-path assertion deterministic.
|
|
129
131
|
// ---------------------------------------------------------------------------
|
|
130
132
|
{
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
tool_input: { command: 'git commit -m "no convention and no uuid"' },
|
|
135
|
-
});
|
|
136
|
-
assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
|
|
137
|
-
assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
|
|
133
|
+
const fakeHome = mkdtempSync(join(tmpdir(), 'wic-home-'));
|
|
134
|
+
try {
|
|
135
|
+
const preEnv = { HOME: fakeHome, USERPROFILE: fakeHome };
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
const res = runHook({
|
|
138
|
+
hook_event_name: 'PreToolUse',
|
|
139
|
+
tool_name: 'Bash',
|
|
140
|
+
tool_input: { command: 'git commit -m "no convention and no uuid"' },
|
|
141
|
+
}, preEnv);
|
|
142
|
+
assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
|
|
143
|
+
assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
|
|
144
|
+
|
|
145
|
+
const ok = runHook({
|
|
146
|
+
hook_event_name: 'PreToolUse',
|
|
147
|
+
tool_name: 'Bash',
|
|
148
|
+
tool_input: { command: 'git commit -m "feat(x): conventional"' },
|
|
149
|
+
}, preEnv);
|
|
150
|
+
assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
153
|
+
}
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
// ---------------------------------------------------------------------------
|
|
@@ -197,6 +197,164 @@ await (async () => {
|
|
|
197
197
|
const none = gate.resolveFullSha('00000000'); // prefix of no commit
|
|
198
198
|
assert('10. resolveFullSha returns null for a non-existent prefix', none === null, none || 'not-null');
|
|
199
199
|
}
|
|
200
|
+
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
// Fix 1 — OUTCOME GATE: a verification that RAN but did NOT pass must DENY.
|
|
203
|
+
// isMachineArtifact passes (correct shape) but isPassingArtifact must fail.
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
{
|
|
206
|
+
// 11a. exit_code:1 (a real machine shape, but a FAILING run) -> DENY
|
|
207
|
+
const r1 = await runL2({
|
|
208
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 1 }], witness: 'validator-rerun',
|
|
209
|
+
}, CAPTURED);
|
|
210
|
+
assert('11a. verification exit_code:1 -> DENY (verification-not-passing)',
|
|
211
|
+
r1 && /verification-not-passing/.test(r1), r1 || 'no denial');
|
|
212
|
+
|
|
213
|
+
// 11b. fused run-evidence-gate artifact with verdict:'fail' -> DENY
|
|
214
|
+
const lib = await import(GATE_LIB);
|
|
215
|
+
const fusedFail = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'], label: 'fused-fail' });
|
|
216
|
+
assert('11b. fused failing run has verdict:fail', fusedFail.ran === true && fusedFail.verdict === 'fail', JSON.stringify(fusedFail));
|
|
217
|
+
const r2 = await runL2({
|
|
218
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [fusedFail], witness: 'ci',
|
|
219
|
+
}, CAPTURED);
|
|
220
|
+
assert('11b. fused verdict:fail -> DENY (verification-not-passing)',
|
|
221
|
+
r2 && /verification-not-passing/.test(r2), r2 || 'no denial');
|
|
222
|
+
|
|
223
|
+
// 11c. http_status:500 (error status) -> DENY
|
|
224
|
+
const r3 = await runL2({
|
|
225
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 500 }], witness: 'human-review',
|
|
226
|
+
}, CAPTURED);
|
|
227
|
+
assert('11c. verification http_status:500 -> DENY (verification-not-passing)',
|
|
228
|
+
r3 && /verification-not-passing/.test(r3), r3 || 'no denial');
|
|
229
|
+
|
|
230
|
+
// 11d. failed test-runner JSON ({passed:3, failed:2}) -> DENY
|
|
231
|
+
const r4 = await runL2({
|
|
232
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 3, failed: 2 }], witness: 'ci',
|
|
233
|
+
}, CAPTURED);
|
|
234
|
+
assert('11d. verification {passed:3,failed:2} -> DENY (verification-not-passing)',
|
|
235
|
+
r4 && /verification-not-passing/.test(r4), r4 || 'no denial');
|
|
236
|
+
|
|
237
|
+
// 11e. tsc_errors:4 -> DENY
|
|
238
|
+
const r5 = await runL2({
|
|
239
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ tsc_errors: 4 }], witness: 'ci',
|
|
240
|
+
}, CAPTURED);
|
|
241
|
+
assert('11e. verification tsc_errors:4 -> DENY (verification-not-passing)',
|
|
242
|
+
r5 && /verification-not-passing/.test(r5), r5 || 'no denial');
|
|
243
|
+
|
|
244
|
+
// 11f. PASSING shapes still ALLOW (regression guard for the outcome gate).
|
|
245
|
+
const okExit = await runL2({
|
|
246
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 0 }], witness: 'validator-rerun',
|
|
247
|
+
}, CAPTURED);
|
|
248
|
+
assert('11f. exit_code:0 still ALLOWs', okExit === null, `unexpected denial: ${okExit}`);
|
|
249
|
+
const okHttp = await runL2({
|
|
250
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 200 }], witness: 'ci',
|
|
251
|
+
}, CAPTURED);
|
|
252
|
+
assert('11f. http_status:200 still ALLOWs', okHttp === null, `unexpected denial: ${okHttp}`);
|
|
253
|
+
const okTests = await runL2({
|
|
254
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 5, total: 5 }], witness: 'ci',
|
|
255
|
+
}, CAPTURED);
|
|
256
|
+
assert('11f. {passed:5,total:5} still ALLOWs', okTests === null, `unexpected denial: ${okTests}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 11g. isPassingArtifact unit checks (direct, lib-level).
|
|
260
|
+
{
|
|
261
|
+
const lib = await import(GATE_LIB);
|
|
262
|
+
const { isPassingArtifact } = lib;
|
|
263
|
+
assert('11g. isPassingArtifact rejects {exit_code:1}', isPassingArtifact({ exit_code: 1 }) === false);
|
|
264
|
+
assert('11g. isPassingArtifact accepts {exit_code:0}', isPassingArtifact({ exit_code: 0 }) === true);
|
|
265
|
+
assert('11g. isPassingArtifact rejects {http_status:500}', isPassingArtifact({ http_status: 500 }) === false);
|
|
266
|
+
assert('11g. isPassingArtifact accepts {http_status:204}', isPassingArtifact({ http_status: 204 }) === true);
|
|
267
|
+
assert('11g. isPassingArtifact rejects {passed:1,failed:1}', isPassingArtifact({ passed: 1, failed: 1 }) === false);
|
|
268
|
+
assert('11g. isPassingArtifact accepts {passed:5,total:5}', isPassingArtifact({ passed: 5, total: 5 }) === true);
|
|
269
|
+
assert('11g. isPassingArtifact rejects {passed:4,total:5}', isPassingArtifact({ passed: 4, total: 5 }) === false);
|
|
270
|
+
assert('11g. isPassingArtifact rejects prose "HTTP 200"', isPassingArtifact('HTTP 200') === false);
|
|
271
|
+
const fp = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'] });
|
|
272
|
+
const ff = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'] });
|
|
273
|
+
assert('11g. isPassingArtifact accepts fused verdict:pass', isPassingArtifact(fp) === true);
|
|
274
|
+
assert('11g. isPassingArtifact rejects fused verdict:fail', isPassingArtifact(ff) === false);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// Fix 2 — NULL ORIGINATING SESSION must be FAIL-CLOSED. A null session must
|
|
279
|
+
// NOT disable the per-session commit binding (the old `!originatingSession`
|
|
280
|
+
// short-circuit accepted ANY session's captured SHA). buildCapturedShaSet
|
|
281
|
+
// DENIES when the originating session is null/empty.
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
{
|
|
284
|
+
const otherSessionRows = [{ sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' }];
|
|
285
|
+
|
|
286
|
+
// 12a. session_id=null + a SHA captured by a DIFFERENT session -> DENY
|
|
287
|
+
let denied = null;
|
|
288
|
+
try {
|
|
289
|
+
gate.buildCapturedShaSet(null, otherSessionRows);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (e instanceof gate.GateDenied) denied = e.reason; else throw e;
|
|
292
|
+
}
|
|
293
|
+
assert('12a. null originating session -> DENY (no provenance to bind)',
|
|
294
|
+
denied && /no originating session/.test(denied), denied || 'no denial');
|
|
295
|
+
|
|
296
|
+
// 12b. empty-string session is treated the same (fail-closed) -> DENY
|
|
297
|
+
let denied2 = null;
|
|
298
|
+
try {
|
|
299
|
+
gate.buildCapturedShaSet('', otherSessionRows);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
if (e instanceof gate.GateDenied) denied2 = e.reason; else throw e;
|
|
302
|
+
}
|
|
303
|
+
assert('12b. empty originating session -> DENY', denied2 && /no originating session/.test(denied2), denied2 || 'no denial');
|
|
304
|
+
|
|
305
|
+
// 12c. with a real originating session, ONLY that session's SHAs are kept;
|
|
306
|
+
// a DIFFERENT session's captured SHA is excluded (not laundered in).
|
|
307
|
+
const mixed = [
|
|
308
|
+
{ sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' },
|
|
309
|
+
{ sha: 'a'.repeat(40), session_id: SESS },
|
|
310
|
+
];
|
|
311
|
+
const set = gate.buildCapturedShaSet(SESS, mixed);
|
|
312
|
+
assert('12c. only originating-session SHAs bound', set.has('a'.repeat(40)) && !set.has(HEAD.toLowerCase()),
|
|
313
|
+
`set=${[...set].join(',')}`);
|
|
314
|
+
|
|
315
|
+
// 12d. end-to-end consequence: null session + DIFFERENT-session SHA means
|
|
316
|
+
// the SHA is never bound, so even a structurally valid post is denied.
|
|
317
|
+
// (Drive verifyLayer2 with the EMPTY set buildCapturedShaSet would have
|
|
318
|
+
// produced were the session real-but-mismatched; null short-circuits
|
|
319
|
+
// earlier, but this proves a cross-session SHA never reaches capture.)
|
|
320
|
+
const crossSet = gate.buildCapturedShaSet(SESS, otherSessionRows); // SESS has none of these
|
|
321
|
+
const r = await runL2({
|
|
322
|
+
commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
|
|
323
|
+
}, crossSet);
|
|
324
|
+
assert('12d. cross-session-only SHA not captured for this session -> DENY',
|
|
325
|
+
r && /was not captured by Layer 1/.test(r), r || 'no denial');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
// Fix 3a — assertGitRepoAvailable: a non-git directory is fail-closed with a
|
|
330
|
+
// distinct `truth-gate repo unavailable` reason (not `ref not found`).
|
|
331
|
+
// Drive it directly with the function's own repo via TRUTH_GATE_REPO capture:
|
|
332
|
+
// since TRUTH_GATE_REPO is module-captured to `repo` (a real git tree), the
|
|
333
|
+
// happy path must NOT throw; assert that, and assert a non-git path denies by
|
|
334
|
+
// re-importing the hook under a fresh env pointed at a non-git dir.
|
|
335
|
+
{
|
|
336
|
+
// happy path: the live throwaway repo is a work tree -> no throw
|
|
337
|
+
let ok = true;
|
|
338
|
+
try { gate.assertGitRepoAvailable(); } catch { ok = false; }
|
|
339
|
+
assert('13a. assertGitRepoAvailable passes for a real git work tree', ok === true);
|
|
340
|
+
|
|
341
|
+
// non-git path: re-load the hook with RDC_TRUTH_GATE_REPO pointed at a
|
|
342
|
+
// brand-new empty (non-git) temp dir; assertGitRepoAvailable must DENY.
|
|
343
|
+
const nonGit = mkdtempSync(join(tmpdir(), 'l2-nongit-'));
|
|
344
|
+
const prevRepo = process.env.RDC_TRUTH_GATE_REPO;
|
|
345
|
+
process.env.RDC_TRUTH_GATE_REPO = nonGit;
|
|
346
|
+
delete require.cache[require.resolve(HOOK)];
|
|
347
|
+
const gate2 = require(HOOK);
|
|
348
|
+
let denied = null;
|
|
349
|
+
try { gate2.assertGitRepoAvailable(); }
|
|
350
|
+
catch (e) { if (e instanceof gate2.GateDenied) denied = e.reason; else throw e; }
|
|
351
|
+
assert('13a. non-git dir -> DENY (truth-gate repo unavailable)',
|
|
352
|
+
denied && /truth-gate repo unavailable/.test(denied), denied || 'no denial');
|
|
353
|
+
// restore env + module cache for any later use
|
|
354
|
+
process.env.RDC_TRUTH_GATE_REPO = prevRepo;
|
|
355
|
+
delete require.cache[require.resolve(HOOK)];
|
|
356
|
+
rmSync(nonGit, { recursive: true, force: true });
|
|
357
|
+
}
|
|
200
358
|
})();
|
|
201
359
|
|
|
202
360
|
rmSync(repo, { recursive: true, force: true });
|