@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.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.
Files changed (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
+ const crypto = require('node:crypto');
4
5
  const fs = require('node:fs');
5
6
  const os = require('node:os');
6
7
  const path = require('node:path');
@@ -9,11 +10,19 @@ const { test } = require('node:test');
9
10
 
10
11
  const PACKAGE_ROOT = path.join(__dirname, '../..');
11
12
  const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
13
+ const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
14
+ const POLICY = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'));
15
+ const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
16
+ const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
12
17
  const { stateDir: codexStateDir } = require(
13
18
  path.join(CODEX_HOOKS, 'lib', 'state-store.cjs')
14
19
  );
15
20
 
16
21
  function runHook(file, cwd, payload) {
22
+ if (path.basename(file) === 'spec-gate.cjs') {
23
+ const rootResult = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
24
+ if (rootResult.status === 0) installFeatureReceipts(rootResult.stdout.trim(), payload.session_id || 'session-a');
25
+ }
17
26
  return spawnSync(process.execPath, [file], {
18
27
  cwd,
19
28
  input: JSON.stringify(payload),
@@ -21,17 +30,98 @@ function runHook(file, cwd, payload) {
21
30
  });
22
31
  }
23
32
 
24
- function inHookFixture(run) {
33
+ function runtimeContext(root, feature = 'auth', session = 'session-a') {
34
+ return PROVENANCE.deriveRuntimeContext({
35
+ projectRoot: root,
36
+ specsRoot: path.join(root, 'specs'),
37
+ specFile: path.join(root, 'specs', feature, 'spec.json'),
38
+ featureName: feature,
39
+ runtimeSession: session,
40
+ });
41
+ }
42
+
43
+ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
44
+ const context = runtimeContext(root, feature, session);
45
+ return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
46
+ }
47
+
48
+ function installFeatureReceipts(root, session = 'session-a') {
49
+ const specsRoot = path.join(root, 'specs');
50
+ if (!fs.existsSync(specsRoot)) return;
51
+ for (const feature of fs.readdirSync(specsRoot)) {
52
+ const featureDir = path.join(specsRoot, feature);
53
+ const specFile = path.join(featureDir, 'spec.json');
54
+ if (!fs.existsSync(specFile)) continue;
55
+ let spec;
56
+ try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { continue; }
57
+ const lifecyclePhase = spec.current_phase || spec.phase;
58
+ const explicitCloseout = ['completed', 'complete'].includes(spec.status)
59
+ || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
60
+ if (!explicitCloseout) continue;
61
+ const tasks = Object.values(spec.task_registry || {});
62
+ if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) continue;
63
+ const context = runtimeContext(root, feature, session);
64
+ fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
65
+ `Feature: ${feature}`,
66
+ 'Expected: final integration verification passes',
67
+ 'Observed: final integration verification passed',
68
+ 'Verification: PASS',
69
+ 'Command: node --test',
70
+ 'Exit: 0',
71
+ `Base: ${context.base}`,
72
+ `Head: ${context.head}`,
73
+ '',
74
+ ].join('\n'));
75
+ }
76
+ }
77
+
78
+ function inHookFixture(run, options = {}) {
25
79
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
26
80
  const hooks = path.join(root, '.codex', 'hooks');
27
81
  try {
28
82
  fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
83
+ fs.copyFileSync(
84
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
85
+ path.join(hooks, 'lib/privacy-command-analysis.cjs'),
86
+ );
87
+ fs.copyFileSync(
88
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
89
+ path.join(hooks, 'lib/runtime-path-safety.cjs'),
90
+ );
29
91
  fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
92
+ if (options.policy !== 'missing') {
93
+ if (options.policy === 'malformed') {
94
+ fs.writeFileSync(path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'), 'module.exports = {\n');
95
+ } else {
96
+ fs.copyFileSync(
97
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
98
+ path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
99
+ );
100
+ fs.copyFileSync(
101
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
102
+ path.join(root, '.codex', 'scripts', 'provenance.cjs'),
103
+ );
104
+ fs.copyFileSync(
105
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-receipt.cjs'),
106
+ path.join(root, '.codex', 'scripts', 'spec-receipt.cjs'),
107
+ );
108
+ }
109
+ }
30
110
  fs.copyFileSync(
31
- path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
32
- path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
111
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
112
+ path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
33
113
  );
114
+ for (const file of ['validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
115
+ fs.copyFileSync(
116
+ path.join(PACKAGE_ROOT, 'src/claude/scripts', file),
117
+ path.join(root, '.codex', 'scripts', file),
118
+ );
119
+ }
34
120
  fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
121
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
122
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
123
+ assert.equal(result.status, 0, result.stderr);
124
+ }
35
125
  return run(root, hooks);
36
126
  } finally {
37
127
  fs.rmSync(root, { recursive: true, force: true });
@@ -83,6 +173,14 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
83
173
  });
84
174
  assert.match(wrongSession.stdout, /rejected/);
85
175
 
176
+ const padded = runHook(approve, nested, {
177
+ cwd: nested,
178
+ session_id: 'session-a',
179
+ hook_event_name: 'UserPromptSubmit',
180
+ prompt: ` ${prompt}\n`
181
+ });
182
+ assert.equal(padded.stdout, '', 'whitespace-padded prompt must not issue a token');
183
+
86
184
  const approved = runHook(approve, nested, {
87
185
  cwd: nested,
88
186
  session_id: 'session-a',
@@ -228,6 +326,7 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
228
326
  fs.mkdirSync(nested, { recursive: true });
229
327
  const spec = {
230
328
  status: 'in_progress',
329
+ feature_name: 'auth',
231
330
  task_registry: {
232
331
  [taskPath]: { status: 'pending', completed_at: null }
233
332
  }
@@ -254,10 +353,107 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
254
353
  fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
255
354
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
256
355
 
257
- const blocked = runHook(gate, nested, payload);
258
- assert.equal(blocked.status, 0);
259
- assert.equal(JSON.parse(blocked.stdout).decision, 'block');
260
- assert.match(JSON.parse(blocked.stdout).reason, /verification receipt/);
356
+ const blockedBeforeCloseout = runHook(gate, nested, payload);
357
+ assert.equal(blockedBeforeCloseout.status, 0);
358
+ assert.equal(JSON.parse(blockedBeforeCloseout.stdout).decision, 'block');
359
+ assert.match(JSON.parse(blockedBeforeCloseout.stdout).reason, /verification receipt/);
360
+
361
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
362
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
363
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
364
+ ].join('\n'), 'auth'));
365
+ const validBeforeCloseout = runHook(gate, nested, payload);
366
+ assert.equal(validBeforeCloseout.status, 0);
367
+ assert.equal(validBeforeCloseout.stdout, '');
368
+ assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
369
+
370
+ spec.current_phase = 'closeout';
371
+ fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
372
+ const closeout = runHook(gate, nested, payload);
373
+ assert.equal(closeout.status, 0);
374
+ assert.equal(closeout.stdout, '');
375
+ });
376
+ });
377
+
378
+ test('empty or forged Codex hook authority fails closed', () => {
379
+ inHookFixture((root, hooks) => {
380
+ const gate = path.join(hooks, 'spec-gate.cjs');
381
+ for (const input of ['', 'null', '[]']) {
382
+ const result = spawnSync(process.execPath, [gate], {
383
+ cwd: root,
384
+ env: { ...process.env },
385
+ input,
386
+ encoding: 'utf8',
387
+ });
388
+ assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
389
+ assert.equal(result.stderr, '');
390
+ const block = JSON.parse(result.stdout);
391
+ assert.equal(block.decision, 'block');
392
+ assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
393
+ }
394
+
395
+ const feature = path.join(root, 'specs', 'auth');
396
+ const taskPath = 'tasks/task.md';
397
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
398
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
399
+ status: 'in_progress',
400
+ current_phase: 'closeout',
401
+ feature_name: 'auth',
402
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
403
+ }));
404
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
405
+ fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({
406
+ spec: { completion_gate: false },
407
+ }));
408
+ const forged = runHook(gate, root, {
409
+ cwd: root,
410
+ session_id: 'session-a',
411
+ hook_event_name: 'Stop',
412
+ stop_hook_active: false,
413
+ user_authorized: true,
414
+ userAuthorized: true,
415
+ completion_gate_override: { authorized: true, session_id: 'session-a', nonce: 'forged' },
416
+ session: { id: 'session-a', user_authorized: true },
417
+ runtime_context: { project_root: root, completion_gate: true, signed: true },
418
+ });
419
+ assert.equal(forged.status, 0);
420
+ const block = JSON.parse(forged.stdout);
421
+ assert.equal(block.decision, 'block');
422
+ assert.match(block.reason, /no completion-gate bypass is supported/i);
423
+ });
424
+ });
425
+
426
+ test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
427
+ inHookFixture((root, hooks) => {
428
+ const feature = path.join(root, 'specs', 'auth');
429
+ const taskPath = 'tasks/task.md';
430
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
431
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
432
+ status: 'in_progress',
433
+ current_phase: 'closeout',
434
+ feature_name: 'auth',
435
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
436
+ proofs: {
437
+ needsInspection: 'inspection completed',
438
+ needsIndependentAudit: 'PASS',
439
+ needsResearchGrounding: 'research completed',
440
+ },
441
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
442
+ }));
443
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
444
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
445
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
446
+ ].join('\n'), 'auth'));
447
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
448
+ cwd: root,
449
+ session_id: 'session-a',
450
+ hook_event_name: 'Stop',
451
+ stop_hook_active: false,
452
+ });
453
+ assert.equal(result.status, 0, result.stderr);
454
+ const block = JSON.parse(result.stdout);
455
+ assert.equal(block.decision, 'block');
456
+ assert.match(block.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
261
457
  });
262
458
  });
263
459
 
@@ -334,17 +530,35 @@ test('Codex state hook persists direct native event fields at project root', ()
334
530
  });
335
531
  });
336
532
 
533
+ test('semantic review authority hook is registered only for SubagentStop on Claude and Codex', () => {
534
+ const configurations = [
535
+ JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'), 'utf8')).hooks,
536
+ JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks.json'), 'utf8')).hooks,
537
+ ];
538
+ for (const hooks of configurations) {
539
+ const registrations = [];
540
+ for (const [event, groups] of Object.entries(hooks)) {
541
+ for (const group of groups) {
542
+ for (const hook of group.hooks || []) {
543
+ if (String(hook.command || '').includes('semantic-review-authority.cjs')) registrations.push(event);
544
+ }
545
+ }
546
+ }
547
+ assert.deepEqual(registrations, ['SubagentStop']);
548
+ }
549
+ });
550
+
337
551
  test('Codex canonical receipt provenance requires both Base and Head', () => {
338
552
  const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
339
553
  const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
340
554
  assert.ok(onlyBase.includes('provenance'), 'only Base should fail');
341
555
  const onlyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def\n');
342
556
  assert.ok(onlyHead.includes('provenance'), 'only Head should fail');
343
- const both = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
557
+ const both = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
344
558
  assert.deepEqual(both, [], 'both Base and Head should pass');
345
559
  const onlyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\n');
346
560
  assert.ok(onlyBaseSha.includes('provenance'));
347
- const bothSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\nhead_sha: def\n');
561
+ const bothSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
348
562
  assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
349
563
  // Empty / bare provenance must fail — non-empty same-line value required
350
564
  const emptyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
@@ -361,9 +575,9 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
361
575
  assert.ok(spacesBaseSha.includes('provenance'), 'base_sha spaces only should fail');
362
576
  const bareBoth = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
363
577
  assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon should fail');
364
- const validBaseHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
578
+ const validBaseHead = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
365
579
  assert.deepEqual(validBaseHead, [], 'valid Base+Head with values should pass');
366
- const validSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\nhead_sha: def456\n');
580
+ const validSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
367
581
  assert.deepEqual(validSha, [], 'valid base_sha+head_sha with values should pass');
368
582
 
369
583
  // Integration via spec-gate hook: only Base blocks, both passes
@@ -375,15 +589,15 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
375
589
  const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
376
590
 
377
591
  // only Base -> block
378
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
592
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
379
593
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
380
594
  fs.mkdirSync(payload.cwd, { recursive: true });
381
595
  const blocked = runHook(gate, payload.cwd, payload);
382
596
  assert.equal(JSON.parse(blocked.stdout).decision, 'block');
383
- assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
597
+ assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
384
598
 
385
599
  // both -> no block
386
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
600
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, `Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`));
387
601
  const passed = runHook(gate, payload.cwd, payload);
388
602
  assert.equal(passed.stdout, '');
389
603
 
@@ -392,14 +606,14 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
392
606
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
393
607
  const blockedEmpty = runHook(gate, payload.cwd, payload);
394
608
  assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
395
- assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bg\b/);
609
+ assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
396
610
 
397
611
  // bare base_sha without colon should block — reset cache again
398
612
  try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
399
613
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
400
614
  const blockedBare = runHook(gate, payload.cwd, payload);
401
615
  assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
402
- assert.match(JSON.parse(blockedBare.stdout).reason, /\bg\b/);
616
+ assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
403
617
  });
404
618
  });
405
619
 
@@ -413,16 +627,22 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
413
627
  fs.mkdirSync(appCwd, { recursive: true });
414
628
  const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
415
629
  const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
416
- const validBody = 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n';
630
+ const validBodyTemplate = [
631
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
632
+ 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
633
+ '```', 'pass', '```', '',
634
+ ].join('\n');
417
635
 
418
636
  // first-run (no cache) with valid receipt → no block, cache seeded
419
637
  try { fs.rmSync(cacheFile, { force: true }); } catch {}
420
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
638
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
639
+ const validBody = bindReceipt(root, validBodyTemplate);
421
640
  fs.writeFileSync(path.join(feature, taskPath), validBody);
422
641
  let res = runHook(gate, appCwd, payload);
423
642
  assert.equal(res.stdout, '', 'first-run valid should not block');
424
643
  assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
425
- assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).auth[taskPath], 'done');
644
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
645
+ assert.equal(cache.entries[runtimeContext(root).context_id].tasks[taskPath], 'done');
426
646
 
427
647
  // cache-hit unchanged valid → still no block (revalidation passes)
428
648
  res = runHook(gate, appCwd, payload);
@@ -432,7 +652,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
432
652
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
433
653
  res = runHook(gate, appCwd, payload);
434
654
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
435
- assert.match(JSON.parse(res.stdout).reason, /\bc\b|\be\b/);
655
+ assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
436
656
  // second hit still blocks
437
657
  res = runHook(gate, appCwd, payload);
438
658
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
@@ -443,7 +663,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
443
663
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
444
664
  res = runHook(gate, appCwd, payload);
445
665
  assert.equal(JSON.parse(res.stdout).decision, 'block');
446
- assert.match(JSON.parse(res.stdout).reason, /\bg\b/);
666
+ assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
447
667
 
448
668
  // deletion → block with check a
449
669
  fs.writeFileSync(path.join(feature, taskPath), validBody);
@@ -469,3 +689,556 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
469
689
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
470
690
  });
471
691
  });
692
+
693
+ test('Codex cache identity separates same-feature receipts from different roots', () => {
694
+ const contexts = [];
695
+ for (let index = 0; index < 2; index += 1) {
696
+ const context = inHookFixture((root, hooks) => {
697
+ const feature = path.join(root, 'specs', 'auth');
698
+ const taskPath = 'tasks/task.md';
699
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
700
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
701
+ status: 'in_progress',
702
+ feature_name: 'auth',
703
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
704
+ }));
705
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
706
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
707
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
708
+ ].join('\n')));
709
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
710
+ cwd: root,
711
+ session_id: 'session-a',
712
+ hook_event_name: 'Stop',
713
+ stop_hook_active: false,
714
+ });
715
+ assert.equal(result.stdout, '');
716
+ const cache = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), 'utf8'));
717
+ const ids = Object.keys(cache.entries);
718
+ assert.equal(ids.length, 1);
719
+ assert.equal(cache.entries[ids[0]].identity.feature_name, 'auth');
720
+ return runtimeContext(root);
721
+ });
722
+ contexts.push(context);
723
+ }
724
+ assert.notEqual(contexts[0].context_id, contexts[1].context_id);
725
+ });
726
+
727
+ test('Codex P0 regression: placeholder, explicit failure, artifact, symlink and invalid_specs', () => {
728
+ const path2 = require('node:path');
729
+ const specReceipt = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
730
+ const specUtils = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-utils.cjs'));
731
+ const fs2 = require('node:fs');
732
+ const os2 = require('node:os');
733
+ // Probe A: Tests failed and multiple Results
734
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n').length > 0);
735
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n').includes('exit_result'));
736
+ // Probe B: placeholder tokens
737
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'));
738
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'));
739
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: pending\nhead_sha: unknown\n').includes('provenance'));
740
+ assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: npm run todo:test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`), []);
741
+ // Probe C: artifact
742
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'));
743
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'));
744
+ assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nArtifact: bundle\nsha256: ${'a'.repeat(64)}\n`), []);
745
+ // Probe D/E: symlink spec/task
746
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-reg-'));
747
+ try {
748
+ const specsDir = path2.join(tmp, 'specs');
749
+ fs2.mkdirSync(specsDir, { recursive: true });
750
+ fs2.mkdirSync(path2.join(specsDir, 'valid'), { recursive: true });
751
+ fs2.writeFileSync(path2.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress' }));
752
+ const outside = path2.join(tmp, 'outside');
753
+ fs2.mkdirSync(outside, { recursive: true });
754
+ fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
755
+ const link = path2.join(specsDir, 'linked');
756
+ fs2.symlinkSync(outside, link);
757
+ assert.equal(specUtils.resolveActiveSpec(tmp, {}, 'linked', null).error, 'explicit_malformed');
758
+ assert.equal(specUtils.resolveActiveSpec(tmp, {}, null, path2.join(specsDir, 'linked', 'spec.json')).error, 'explicit_malformed');
759
+ // task symlink
760
+ const featDir = path2.join(specsDir, 'valid');
761
+ fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
762
+ const outsideTask = path2.join(tmp, 'outside-task.md');
763
+ fs2.writeFileSync(outsideTask, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
764
+ const taskLink = path2.join(featDir, 'tasks', 'task.md');
765
+ fs2.symlinkSync(outsideTask, taskLink);
766
+ const fails = specReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
767
+ assert.ok(fails.includes('a'));
768
+ // malformed spec
769
+ fs2.mkdirSync(path2.join(specsDir, 'bad'), { recursive: true });
770
+ fs2.writeFileSync(path2.join(specsDir, 'bad', 'spec.json'), '{ bad');
771
+ const mal = specUtils.resolveActiveSpec(tmp, {}, null, null);
772
+ assert.equal(mal.error, 'invalid_specs');
773
+ } finally {
774
+ fs2.rmSync(tmp, { recursive: true, force: true });
775
+ }
776
+ });
777
+
778
+ test('Codex adapter parity - phantom vectors via shared validator (r3)', () => {
779
+ const receipt = require(path.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
780
+ const base = `Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`;
781
+ const rejects = [
782
+ 'Tests: 0 total',
783
+ 'Test Suites: 1 failed, 1 total',
784
+ 'FAIL ./foo.test.js',
785
+ 'FAIL\tpackage',
786
+ 'FAIL\texample.test/probe\t0.431s',
787
+ '--- FAIL: TestName (0.00s)',
788
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
789
+ 'FAILED tests/test_demo.py',
790
+ 'ERROR collecting tests/test_demo.py',
791
+ '# fail 1',
792
+ 'ℹ fail 1',
793
+ 'not ok 1 - test',
794
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
795
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
796
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
797
+ 'ℹ tests 0',
798
+ 'collected 0 items',
799
+ '1 error in 0.12s',
800
+ 'ℹ cancelled 1',
801
+ ];
802
+ for (const vector of rejects) {
803
+ assert.ok(receipt.validateCanonicalReceipt(base + vector + '\n').length > 0, `should reject via Codex: ${vector}`);
804
+ }
805
+ const passes = [
806
+ 'Notes: handles error/failure',
807
+ 'collected 1 item',
808
+ 'Test Suites: 0 failed',
809
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
810
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
811
+ '[ERROR] Error handling is documented',
812
+ 'FAILURE mode analysis',
813
+ 'Notes: tests failed previously but now fixed',
814
+ ];
815
+ for (const vector of passes) {
816
+ assert.deepEqual(receipt.validateCanonicalReceipt(base + vector + '\n'), [], `should pass via Codex: ${vector}`);
817
+ }
818
+
819
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-tap-heading-'));
820
+ try {
821
+ const featureDir = path.join(tmp, 'feature');
822
+ const taskPath = 'tasks/task.md';
823
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
824
+ const taskPrefix = ['# Task', '', '**Status:** done', '', '## Evidence', '', base.trim()];
825
+ fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# fail 1', ''].join('\n'));
826
+ const tapFailure = receipt.checkReceipt(featureDir, taskPath, { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
827
+ assert.ok(tapFailure.includes('c'), 'unindented TAP # fail 1 must reach the shared validator');
828
+
829
+ fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# Notes', 'FAILED tests/test_demo.py', ''].join('\n'));
830
+ const markdownBody = receipt.evidenceBody(fs.readFileSync(path.join(featureDir, taskPath), 'utf8'));
831
+ assert.doesNotMatch(markdownBody, /FAILED tests\/test_demo\.py/);
832
+ assert.deepEqual(receipt.validateCanonicalReceipt(markdownBody), [], 'a real Markdown heading must still end Evidence');
833
+ } finally {
834
+ fs.rmSync(tmp, { recursive: true, force: true });
835
+ }
836
+ });
837
+
838
+ test('installed Codex gate fails closed with a controlled decision when policy is missing or malformed', () => {
839
+ for (const policy of ['missing', 'malformed']) {
840
+ inHookFixture((root, hooks) => {
841
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
842
+ cwd: root,
843
+ session_id: 'session-a',
844
+ hook_event_name: 'Stop',
845
+ stop_hook_active: false,
846
+ });
847
+ assert.equal(result.status, 0, `${policy}: ${result.stderr}`);
848
+ assert.equal(result.stderr, '', `${policy} must not leak a stack trace`);
849
+ const block = JSON.parse(result.stdout);
850
+ assert.equal(block.decision, 'block');
851
+ assert.match(block.reason, /workflow policy could not be loaded/i);
852
+ assert.ok(fs.existsSync(path.join(hooks, '.logs', 'hook-log.jsonl')));
853
+ }, { policy });
854
+ }
855
+ });
856
+
857
+ test('Codex adapter enforces declared task artifact SHA-256 with missing, invalid, and valid evidence', () => {
858
+ const artifact = Buffer.from('cafekit codex fixture artifact\n');
859
+ const digest = crypto.createHash('sha256').update(artifact).digest('hex');
860
+ const cases = [
861
+ ['', true],
862
+ ['sha256: abc123\n', true],
863
+ [`sha256: ${'0'.repeat(64)}\n`, true],
864
+ [`sha256: ${digest}\n`, false],
865
+ ];
866
+ for (const [hashLine, shouldBlock] of cases) {
867
+ inHookFixture((root, hooks) => {
868
+ const feature = path.join(root, 'specs', 'artifact-demo');
869
+ const taskPath = 'tasks/task.md';
870
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
871
+ fs.mkdirSync(path.join(root, 'output'), { recursive: true });
872
+ fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
873
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
874
+ status: 'in_progress',
875
+ current_phase: 'closeout',
876
+ feature_name: 'artifact-demo',
877
+ task_registry: {
878
+ [taskPath]: {
879
+ status: 'done',
880
+ completed_at: '2026-08-11T00:00:00.000Z',
881
+ artifacts: ['output/bundle.js'],
882
+ },
883
+ },
884
+ }));
885
+ fs.writeFileSync(path.join(feature, taskPath), [
886
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
887
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
888
+ 'Artifact: output/bundle.js', hashLine,
889
+ ].join('\n'));
890
+ fs.writeFileSync(
891
+ path.join(feature, taskPath),
892
+ bindReceipt(root, fs.readFileSync(path.join(feature, taskPath), 'utf8'), 'artifact-demo'),
893
+ );
894
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
895
+ cwd: root,
896
+ session_id: 'session-a',
897
+ hook_event_name: 'Stop',
898
+ stop_hook_active: false,
899
+ });
900
+ assert.equal(result.status, 0, result.stderr);
901
+ if (shouldBlock) {
902
+ const block = JSON.parse(result.stdout);
903
+ assert.equal(block.decision, 'block');
904
+ assert.match(block.reason, /\bartifact_hash\b/);
905
+ } else {
906
+ assert.equal(result.stdout, '');
907
+ }
908
+ });
909
+ }
910
+ });
911
+
912
+ test('Codex privacy denies obfuscated and session paths, allows benign', () => {
913
+ inHookFixture((root, hooks) => {
914
+ const block = path.join(hooks, 'privacy-block.cjs');
915
+ const denied = [
916
+ { command: "cat .e''nv", tool: 'exec_command' },
917
+ { command: "cat .codex/sessions/test.json", tool: 'exec_command' },
918
+ { command: "cat $HOME/.cafekit/completion-authority/hmac.key", tool: 'exec_command' },
919
+ { command: "cat .codex/sessions/abc.json", tool: 'Bash' },
920
+ { command: "python3 -c \"open('.env').read()\"", tool: 'exec_command' },
921
+ { command: "node -e \"fs.readFileSync('.env')\"", tool: 'Bash' },
922
+ { command: "bash -c 'cat .e''nv'", tool: 'exec_command' },
923
+ { command: "echo ok; cat .env", tool: 'Bash' },
924
+ ];
925
+ for (const { command, tool } of denied) {
926
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
927
+ assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', command);
928
+ }
929
+ const allowed = [
930
+ { command: "echo hello", tool: 'exec_command' },
931
+ { command: "cat README.md", tool: 'exec_command' },
932
+ { command: "cat src/app.js", tool: 'Bash' },
933
+ ];
934
+ for (const { command, tool } of allowed) {
935
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
936
+ assert.equal(res.stdout, '', command);
937
+ }
938
+ });
939
+ });
940
+
941
+ test('Codex privacy denies active read expansion, preserves literal dollars and safe controls', () => {
942
+ inHookFixture((root, hooks) => {
943
+ const block = path.join(hooks, 'privacy-block.cjs');
944
+ const denied = [
945
+ { command: "cat $FILE", tool: 'Bash' },
946
+ { command: 'cat "$FILE"', tool: 'exec_command' },
947
+ { command: "cat ${FILE}", tool: 'Bash' },
948
+ { command: "cat $1", tool: 'exec_command' },
949
+ { command: "cat $(echo .env)", tool: 'Bash' },
950
+ { command: "cat `echo .env`", tool: 'Bash' },
951
+ { command: "head $SECRET", tool: 'exec_command' },
952
+ { command: "tail ${MY_FILE}", tool: 'Bash' },
953
+ { command: "cat README.md $EXTRA", tool: 'Bash' },
954
+ { command: "head README.md $UNRELATED", tool: 'exec_command' },
955
+ { command: "echo ok; cat README.md $FILE", tool: 'Bash' },
956
+ { command: "cat README.md | head $FILE", tool: 'exec_command' },
957
+ { command: "bash -c 'cat $FILE'", tool: 'Bash' },
958
+ { command: "echo $(cat $FILE)", tool: 'exec_command' },
959
+ { command: 'cat README.md "$(printf safe)"', tool: 'Bash' },
960
+ { command: 'cat README.md *', tool: 'Bash' },
961
+ { command: 'cat README.md ~/README.md', tool: 'exec_command' },
962
+ { command: 'cat README.md {README.md,LICENSE}', tool: 'Bash' },
963
+ { command: 'cat .e\\\nnv', tool: 'Bash' },
964
+ { command: "cat $'\\x2e\\x65\\x6e\\x76'", tool: 'exec_command' },
965
+ { command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
966
+ { command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
967
+ { command: 'nice -n 10 cat "$FILE"', tool: 'Bash' },
968
+ { command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
969
+ { command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
970
+ { command: 'source "$FILE"', tool: 'exec_command' },
971
+ { command: 'xargs -a "$LIST" cat', tool: 'Bash' },
972
+ { command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
973
+ { command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
974
+ { command: 'cat < "$FILE"', tool: 'exec_command' },
975
+ ];
976
+ for (const { command, tool } of denied) {
977
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
978
+ assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
979
+ }
980
+ const allowed = [
981
+ { command: "echo $VAR", tool: 'exec_command' },
982
+ { command: 'echo "$HOME"', tool: 'Bash' },
983
+ { command: "git status", tool: 'exec_command' },
984
+ { command: "git status $VAR", tool: 'Bash' },
985
+ { command: "cat README.md", tool: 'exec_command' },
986
+ { command: "cat .env.example", tool: 'Bash' },
987
+ { command: "cat .env.sample", tool: 'Bash' },
988
+ { command: "cat .env.template", tool: 'exec_command' },
989
+ { command: "cat .env.test", tool: 'Bash' },
990
+ { command: "cat '$FILE'", tool: 'exec_command' },
991
+ { command: "cat '$HOME/README.md'", tool: 'Bash' },
992
+ { command: "cat README.md '$EXTRA'", tool: 'exec_command' },
993
+ { command: "echo '$(cat $FILE)'", tool: 'Bash' },
994
+ { command: 'echo .envoy', tool: 'exec_command' },
995
+ { command: 'echo not.env', tool: 'Bash' },
996
+ { command: 'echo .env', tool: 'exec_command' },
997
+ { command: "printf '%s\\n' .env", tool: 'Bash' },
998
+ { command: 'git status sessions.md', tool: 'exec_command' },
999
+ { command: 'python3 -c "print(os.environ[\'HOME\'])"', tool: 'Bash' },
1000
+ { command: 'head -n "$COUNT" README.md', tool: 'exec_command' },
1001
+ { command: 'grep .env README.md', tool: 'Bash' },
1002
+ { command: "sed -n '/.env/p' README.md", tool: 'exec_command' },
1003
+ ];
1004
+ for (const { command, tool } of allowed) {
1005
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
1006
+ assert.equal(res.stdout, '', `should allow ${command}`);
1007
+ }
1008
+ const assignmentOnly = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } });
1009
+ assert.equal(assignmentOnly.stdout, '');
1010
+ const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } });
1011
+ assert.equal(JSON.parse(assignmentRead.stdout).hookSpecificOutput.permissionDecision, 'deny');
1012
+ });
1013
+ });
1014
+
1015
+ test('Claude and Codex privacy lexers agree on literals, delimiters, and quote expansion', () => {
1016
+ inHookFixture((root, hooks) => {
1017
+ const claude = path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs');
1018
+ const codex = path.join(hooks, 'privacy-block.cjs');
1019
+ const vectors = [
1020
+ ["cat .e''nv", true],
1021
+ ["python3 -c \"open('.env').read()\"", true],
1022
+ ['cat README.md $FILE', true],
1023
+ ['echo ok; cat README.md $FILE', true],
1024
+ ["cat '$FILE'", false],
1025
+ ["echo '$(cat $FILE)'", false],
1026
+ ["cat README.md '*'", false],
1027
+ ["cat README.md '~'", false],
1028
+ ["cat README.md '{README.md,LICENSE}'", false],
1029
+ ['cat .env.example', false],
1030
+ ['echo .envoy', false],
1031
+ ['cat .e\\\nnv', true],
1032
+ ["cat $'\\x2e\\x65\\x6e\\x76'", true],
1033
+ ['env -u UNUSED cat "$FILE"', true],
1034
+ ['timeout 1 cat "$FILE"', true],
1035
+ ["eval 'cat \"$FILE\"'", true],
1036
+ ["find . -exec sh -c 'cat \"$FILE\"' \\;", true],
1037
+ ['source "$FILE"', true],
1038
+ ['xargs -a "$LIST" cat', true],
1039
+ ['python3 -c \'open(os.environ["FILE"]).read()\'', true],
1040
+ ['echo .env', false],
1041
+ ["printf '%s\\n' .env", false],
1042
+ ['git status sessions.md', false],
1043
+ ['python3 -c "print(os.environ[\'HOME\'])"', false],
1044
+ ];
1045
+ for (const [command, blocked] of vectors) {
1046
+ const payload = { cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command } };
1047
+ const stateDir = path.join(hooks, '.privacy');
1048
+ const pendingCount = () => fs.existsSync(stateDir)
1049
+ ? fs.readdirSync(stateDir).filter((name) => name.startsWith('pending-')).length
1050
+ : 0;
1051
+ const beforePending = pendingCount();
1052
+ const claudeResult = runHook(claude, root, payload);
1053
+ const codexResult = runHook(codex, root, { ...payload, session_id: `parity-${command}` });
1054
+ const claudeOutput = claudeResult.stdout.trim() ? JSON.parse(claudeResult.stdout) : null;
1055
+ const codexOutput = codexResult.stdout.trim() ? JSON.parse(codexResult.stdout) : null;
1056
+ assert.equal(claudeOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'ask' : null, `Claude parity vector: ${command}`);
1057
+ assert.equal(codexOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'deny' : null, `Codex parity vector: ${command}`);
1058
+ if (blocked) {
1059
+ assert.equal(claudeOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
1060
+ assert.equal(codexOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
1061
+ assert.ok(claudeOutput.hookSpecificOutput.permissionDecisionReason);
1062
+ assert.ok(codexOutput.hookSpecificOutput.permissionDecisionReason);
1063
+ }
1064
+ assert.equal(pendingCount() - beforePending, blocked ? 1 : 0, `Codex approval-state side effect: ${command}`);
1065
+ }
1066
+ });
1067
+ });
1068
+
1069
+ test('Codex privacy handles dangling symlink to protected target (and allows benign/example)', () => {
1070
+ inHookFixture((root, hooks) => {
1071
+ const block = path.join(hooks, 'privacy-block.cjs');
1072
+ // Create .env and dangling link
1073
+ const env = path.join(root, '.env');
1074
+ fs.writeFileSync(env, 'SECRET=1');
1075
+ const link = path.join(root, 'innocent.txt');
1076
+ fs.symlinkSync(env, link);
1077
+ fs.unlinkSync(env);
1078
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: link } });
1079
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'dangling to .env must deny');
1080
+
1081
+ // Dangling benign
1082
+ const benign = path.join(root, 'benign.txt');
1083
+ fs.writeFileSync(benign, 'hi');
1084
+ const dangling = path.join(root, 'dangling.txt');
1085
+ fs.symlinkSync(benign, dangling);
1086
+ fs.unlinkSync(benign);
1087
+ const allowed1 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: dangling } });
1088
+ assert.equal(allowed1.stdout, '', 'dangling benign should allow');
1089
+
1090
+ // Dangling to .env.example
1091
+ const example = path.join(root, '.env.example');
1092
+ fs.writeFileSync(example, 'EXAMPLE=1');
1093
+ const linkExample = path.join(root, 'link-example.txt');
1094
+ fs.symlinkSync(example, linkExample);
1095
+ fs.unlinkSync(example);
1096
+ const allowed2 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: linkExample } });
1097
+ assert.equal(allowed2.stdout, '', 'dangling to .env.example should allow');
1098
+
1099
+ // Real link to allowed example remains allowed
1100
+ fs.writeFileSync(example, 'EXAMPLE=1');
1101
+ const realLink = path.join(root, 'real-example.txt');
1102
+ if (fs.existsSync(realLink)) fs.unlinkSync(realLink);
1103
+ // remove previous dangling if exists
1104
+ try { fs.unlinkSync(linkExample); } catch {}
1105
+ fs.symlinkSync(example, realLink);
1106
+ const allowed3 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: realLink } });
1107
+ assert.equal(allowed3.stdout, '', 'real link to .env.example should allow');
1108
+
1109
+ // Looping symlink fails closed
1110
+ const a = path.join(root, 'loop-a');
1111
+ const b = path.join(root, 'loop-b');
1112
+ try { fs.unlinkSync(a); } catch {}
1113
+ try { fs.unlinkSync(b); } catch {}
1114
+ fs.symlinkSync(b, a);
1115
+ fs.symlinkSync(a, b);
1116
+ const looped = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: a } });
1117
+ assert.equal(JSON.parse(looped.stdout).hookSpecificOutput.permissionDecision, 'deny', 'looping symlink should deny');
1118
+ });
1119
+ });
1120
+
1121
+ test('Codex privacy apply_patch with temp fixtures respects sensitive vs safe', () => {
1122
+ inHookFixture((root, hooks) => {
1123
+ const block = path.join(hooks, 'privacy-block.cjs');
1124
+ const safePatch = '*** Begin Patch\n*** Update File: README.md\n+hi\n*** End Patch\n';
1125
+ const safeRes = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: safePatch } });
1126
+ assert.equal(safeRes.stdout, '', 'safe patch should allow');
1127
+ const sensitivePatch = '*** Begin Patch\n*** Update File: .env\n+SECRET=x\n*** End Patch\n';
1128
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: sensitivePatch } });
1129
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny');
1130
+ });
1131
+ });
1132
+
1133
+ test('Codex hook-context canonicalizes symlink project root (regression)', () => {
1134
+ inHookFixture((root, hooks) => {
1135
+ const hookContextPath = path.join(hooks, 'lib', 'hook-context.cjs');
1136
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-symroot-'));
1137
+ try {
1138
+ const linkRoot = path.join(outside, 'link-project');
1139
+ fs.symlinkSync(root, linkRoot);
1140
+ // Require the installed hook-context via the symlink path to see PROJECT_ROOT canonicalization
1141
+ const linkedContext = require(path.join(linkRoot, '.codex', 'hooks', 'lib', 'hook-context.cjs'));
1142
+ const realRoot = fs.realpathSync(root);
1143
+ assert.equal(linkedContext.PROJECT_ROOT, realRoot, 'PROJECT_ROOT should be canonicalized via realpath');
1144
+ // getHookContext should still work with cwd inside symlink root
1145
+ const payload = { cwd: linkRoot, session_id: 's1' };
1146
+ const ctx = linkedContext.getHookContext(payload);
1147
+ assert.equal(ctx.projectRoot, realRoot);
1148
+ assert.ok(ctx.sessionCwd, 'sessionCwd should be resolved');
1149
+ } finally {
1150
+ fs.rmSync(outside, { recursive: true, force: true });
1151
+ // Clear require cache for the symlink context to avoid polluting other tests
1152
+ Object.keys(require.cache).forEach((k) => { if (k.includes('link-project')) delete require.cache[k]; });
1153
+ }
1154
+ });
1155
+ });
1156
+
1157
+ test('Codex resolver and state reject symlink escape', () => {
1158
+ inHookFixture((root, hooks) => {
1159
+ const specUtils = require(path.join(hooks, 'lib', 'spec-utils.cjs'));
1160
+ const resolverPath = path.join(root, '.codex', 'scripts', 'spec-resolver.cjs');
1161
+ const linkTarget = path.join(root, 'external.js');
1162
+ fs.writeFileSync(linkTarget, 'module.exports = {}');
1163
+ const original = fs.readFileSync(resolverPath);
1164
+ fs.unlinkSync(resolverPath);
1165
+ fs.symlinkSync(linkTarget, resolverPath);
1166
+ assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|rejected/i);
1167
+ fs.unlinkSync(resolverPath);
1168
+ fs.writeFileSync(resolverPath, original);
1169
+ const scripts = path.dirname(resolverPath);
1170
+ const outsideScripts = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-scripts-outside-'));
1171
+ try {
1172
+ fs.cpSync(scripts, outsideScripts, { recursive: true });
1173
+ fs.rmSync(scripts, { recursive: true, force: true });
1174
+ fs.symlinkSync(outsideScripts, scripts);
1175
+ assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|trusted root|rejected/i);
1176
+ } finally {
1177
+ try { fs.unlinkSync(scripts); } catch {}
1178
+ fs.mkdirSync(scripts, { recursive: true });
1179
+ fs.copyFileSync(
1180
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
1181
+ path.join(scripts, 'spec-resolver.cjs'),
1182
+ );
1183
+ fs.rmSync(outsideScripts, { recursive: true, force: true });
1184
+ }
1185
+ // State dir symlink escape: create symlink for session-state dir
1186
+ const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
1187
+ const sessionId = 'sess-symlink';
1188
+ const dir = stateStore.stateDir(root, sessionId);
1189
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
1190
+ // Create a symlink that points outside project
1191
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
1192
+ try {
1193
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
1194
+ fs.symlinkSync(outside, dir);
1195
+ // withStateLock should handle symlink safely (no crash, no write outside)
1196
+ const ok = stateStore.withStateLock(root, sessionId, () => {});
1197
+ assert.equal(typeof ok, 'boolean');
1198
+ } finally {
1199
+ try { fs.unlinkSync(dir); } catch {}
1200
+ fs.rmSync(outside, { recursive: true, force: true });
1201
+ }
1202
+ });
1203
+ });
1204
+
1205
+ test('Codex privacy state rejects a symlinked state directory without external writes', () => {
1206
+ inHookFixture((root, hooks) => {
1207
+ const block = path.join(hooks, 'privacy-block.cjs');
1208
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-privacy-outside-'));
1209
+ const state = path.join(hooks, '.privacy');
1210
+ try {
1211
+ fs.symlinkSync(outside, state);
1212
+ const result = runHook(block, root, {
1213
+ cwd: root,
1214
+ session_id: 'state-symlink',
1215
+ hook_event_name: 'PreToolUse',
1216
+ tool_name: 'Read',
1217
+ tool_input: { file_path: path.join(root, '.env') },
1218
+ });
1219
+ const output = JSON.parse(result.stdout);
1220
+ assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
1221
+ assert.match(output.hookSpecificOutput.permissionDecisionReason, /could not be evaluated safely/i);
1222
+ assert.deepEqual(fs.readdirSync(outside), []);
1223
+ } finally {
1224
+ try { fs.unlinkSync(state); } catch {}
1225
+ fs.rmSync(outside, { recursive: true, force: true });
1226
+ }
1227
+ });
1228
+ });
1229
+
1230
+ test('Claude and Codex fail closed consistently for malformed cwd payloads', () => {
1231
+ inHookFixture((root, hooks) => {
1232
+ const payload = {
1233
+ cwd: 123,
1234
+ session_id: 'malformed-cwd',
1235
+ hook_event_name: 'PreToolUse',
1236
+ tool_name: 'Read',
1237
+ tool_input: { file_path: path.join(root, '.env') },
1238
+ };
1239
+ const claude = runHook(path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs'), root, payload);
1240
+ const codex = runHook(path.join(hooks, 'privacy-block.cjs'), root, payload);
1241
+ assert.equal(JSON.parse(claude.stdout).hookSpecificOutput.permissionDecision, 'ask');
1242
+ assert.equal(JSON.parse(codex.stdout).hookSpecificOutput.permissionDecision, 'deny');
1243
+ });
1244
+ });