@haposoft/cafekit 0.16.0-rc.3 → 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 (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  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 +1914 -221
  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 +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  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 +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  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/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. package/src/common/AGENTS.md +1 -1
@@ -19,6 +19,10 @@ const { stateDir: codexStateDir } = require(
19
19
  );
20
20
 
21
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
+ }
22
26
  return spawnSync(process.execPath, [file], {
23
27
  cwd,
24
28
  input: JSON.stringify(payload),
@@ -41,11 +45,49 @@ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
41
45
  return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
42
46
  }
43
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
+
44
78
  function inHookFixture(run, options = {}) {
45
79
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
46
80
  const hooks = path.join(root, '.codex', 'hooks');
47
81
  try {
48
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
+ );
49
91
  fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
50
92
  if (options.policy !== 'missing') {
51
93
  if (options.policy === 'malformed') {
@@ -59,12 +101,22 @@ function inHookFixture(run, options = {}) {
59
101
  path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
60
102
  path.join(root, '.codex', 'scripts', 'provenance.cjs'),
61
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
+ );
62
108
  }
63
109
  }
64
110
  fs.copyFileSync(
65
111
  path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
66
112
  path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
67
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
+ }
68
120
  fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
69
121
  for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
70
122
  const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
@@ -121,6 +173,14 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
121
173
  });
122
174
  assert.match(wrongSession.stdout, /rejected/);
123
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
+
124
184
  const approved = runHook(approve, nested, {
125
185
  cwd: nested,
126
186
  session_id: 'session-a',
@@ -293,10 +353,25 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
293
353
  fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
294
354
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
295
355
 
296
- const blocked = runHook(gate, nested, payload);
297
- assert.equal(blocked.status, 0);
298
- assert.equal(JSON.parse(blocked.stdout).decision, 'block');
299
- 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, '');
300
375
  });
301
376
  });
302
377
 
@@ -322,6 +397,7 @@ test('empty or forged Codex hook authority fails closed', () => {
322
397
  fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
323
398
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
324
399
  status: 'in_progress',
400
+ current_phase: 'closeout',
325
401
  feature_name: 'auth',
326
402
  task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
327
403
  }));
@@ -347,15 +423,16 @@ test('empty or forged Codex hook authority fails closed', () => {
347
423
  });
348
424
  });
349
425
 
350
- test('Critical Codex completion ignores worker-writable proof strings and remains blocked', () => {
426
+ test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
351
427
  inHookFixture((root, hooks) => {
352
428
  const feature = path.join(root, 'specs', 'auth');
353
429
  const taskPath = 'tasks/task.md';
354
430
  fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
355
431
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
356
432
  status: 'in_progress',
433
+ current_phase: 'closeout',
357
434
  feature_name: 'auth',
358
- workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
435
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
359
436
  proofs: {
360
437
  needsInspection: 'inspection completed',
361
438
  needsIndependentAudit: 'PASS',
@@ -453,6 +530,24 @@ test('Codex state hook persists direct native event fields at project root', ()
453
530
  });
454
531
  });
455
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
+
456
551
  test('Codex canonical receipt provenance requires both Base and Head', () => {
457
552
  const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
458
553
  const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
@@ -494,12 +589,12 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
494
589
  const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
495
590
 
496
591
  // only Base -> block
497
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', 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' } } }));
498
593
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
499
594
  fs.mkdirSync(payload.cwd, { recursive: true });
500
595
  const blocked = runHook(gate, payload.cwd, payload);
501
596
  assert.equal(JSON.parse(blocked.stdout).decision, 'block');
502
- assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
597
+ assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
503
598
 
504
599
  // both -> no block
505
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`));
@@ -511,14 +606,14 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
511
606
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
512
607
  const blockedEmpty = runHook(gate, payload.cwd, payload);
513
608
  assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
514
- assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bg\b/);
609
+ assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
515
610
 
516
611
  // bare base_sha without colon should block — reset cache again
517
612
  try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
518
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');
519
614
  const blockedBare = runHook(gate, payload.cwd, payload);
520
615
  assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
521
- assert.match(JSON.parse(blockedBare.stdout).reason, /\bg\b/);
616
+ assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
522
617
  });
523
618
  });
524
619
 
@@ -540,7 +635,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
540
635
 
541
636
  // first-run (no cache) with valid receipt → no block, cache seeded
542
637
  try { fs.rmSync(cacheFile, { force: true }); } catch {}
543
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', 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' } } }));
544
639
  const validBody = bindReceipt(root, validBodyTemplate);
545
640
  fs.writeFileSync(path.join(feature, taskPath), validBody);
546
641
  let res = runHook(gate, appCwd, payload);
@@ -557,7 +652,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
557
652
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
558
653
  res = runHook(gate, appCwd, payload);
559
654
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
560
- assert.match(JSON.parse(res.stdout).reason, /\bc\b|\be\b/);
655
+ assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
561
656
  // second hit still blocks
562
657
  res = runHook(gate, appCwd, payload);
563
658
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
@@ -568,7 +663,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
568
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');
569
664
  res = runHook(gate, appCwd, payload);
570
665
  assert.equal(JSON.parse(res.stdout).decision, 'block');
571
- assert.match(JSON.parse(res.stdout).reason, /\bg\b/);
666
+ assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
572
667
 
573
668
  // deletion → block with check a
574
669
  fs.writeFileSync(path.join(feature, taskPath), validBody);
@@ -777,6 +872,7 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
777
872
  fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
778
873
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
779
874
  status: 'in_progress',
875
+ current_phase: 'closeout',
780
876
  feature_name: 'artifact-demo',
781
877
  task_registry: {
782
878
  [taskPath]: {
@@ -805,10 +901,344 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
805
901
  if (shouldBlock) {
806
902
  const block = JSON.parse(result.stdout);
807
903
  assert.equal(block.decision, 'block');
808
- assert.match(block.reason, /\bh\b/);
904
+ assert.match(block.reason, /\bartifact_hash\b/);
809
905
  } else {
810
906
  assert.equal(result.stdout, '');
811
907
  }
812
908
  });
813
909
  }
814
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
+ });