@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
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "e6ebb365ff8dc12473f5e2a8e0de04dee4003a91"
2
+ "sha": "df00249f44ad6e02c26d70bbc8b677dce3fd53e6"
3
3
  }
@@ -171,4 +171,71 @@ export function isMachineArtifact(v) {
171
171
  return false;
172
172
  }
173
173
 
174
- export default { runEvidenceGate, hashOutput, isMachineArtifact, EVIDENCE_KIND };
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
- /** Run a git command in the truth-gate repo; returns trimmed stdout or null. */
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', 'ignore'],
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 _isMachineArtifact = null;
314
- /** Lazy-load the fused primitive's artifact discriminator (ESM from CJS).
315
- * Uses a file:// URL so the dynamic import works on Windows absolute paths. */
316
- async function loadIsMachineArtifact() {
317
- if (_isMachineArtifact) return _isMachineArtifact;
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
- _isMachineArtifact = mod.isMachineArtifact;
322
- return _isMachineArtifact;
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
- const isMachineArtifact = await loadIsMachineArtifact();
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
- const originatingSession = item.session_id || null;
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
- loadIsMachineArtifact,
646
+ loadEvidenceLib,
647
+ assertGitRepoAvailable,
648
+ buildCapturedShaSet,
553
649
  WITNESS_ALLOWLIST,
554
650
  };
555
651
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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 res = runHook({
132
- hook_event_name: 'PreToolUse',
133
- tool_name: 'Bash',
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
- const ok = runHook({
140
- hook_event_name: 'PreToolUse',
141
- tool_name: 'Bash',
142
- tool_input: { command: 'git commit -m "feat(x): conventional"' },
143
- });
144
- assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
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 });