@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5

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__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. 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' });
@@ -76,7 +128,7 @@ function inHookFixture(run, options = {}) {
76
128
  }
77
129
  }
78
130
 
79
- test('Codex privacy approval is exact, session-bound, and one-time', () => {
131
+ test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
80
132
  inHookFixture((root, hooks) => {
81
133
  const nested = path.join(root, 'packages', 'app');
82
134
  fs.mkdirSync(nested, { recursive: true });
@@ -121,23 +173,36 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
121
173
  });
122
174
  assert.match(wrongSession.stdout, /rejected/);
123
175
 
124
- const approved = runHook(approve, nested, {
176
+ // A pasted prompt carries the terminal's surrounding whitespace. The
177
+ // exactness that matters is the request id, not the padding.
178
+ const padded = runHook(approve, nested, {
125
179
  cwd: nested,
126
180
  session_id: 'session-a',
127
181
  hook_event_name: 'UserPromptSubmit',
128
- prompt
182
+ prompt: ` ${prompt}\n`
129
183
  });
130
- assert.match(approved.stdout, /approved one retry/);
184
+ assert.match(padded.stdout, /User approved/, 'whitespace padding must not void the approval');
131
185
 
186
+ // The grant now covers repeated access to the same paths, and is not bound
187
+ // to the tool that first tripped the gate.
132
188
  const allowedOnce = runHook(block, nested, request);
133
189
  assert.equal(allowedOnce.stdout, '');
134
190
 
135
- const deniedAgain = runHook(block, nested, request);
136
- assert.equal(JSON.parse(deniedAgain.stdout).hookSpecificOutput.permissionDecision, 'deny');
191
+ const allowedAgain = runHook(block, nested, request);
192
+ assert.equal(allowedAgain.stdout, '', 'one approval covers a re-read');
193
+
194
+ const allowedOtherTool = runHook(block, nested, {
195
+ ...request,
196
+ tool_name: 'exec_command'
197
+ });
198
+ assert.equal(allowedOtherTool.stdout, '', 'grant is not bound to a tool name');
137
199
 
200
+ // A fresh session, so the single-path grant above cannot mask the exact-set
201
+ // matching under test.
138
202
  fs.writeFileSync(path.join(nested, 'credentials.json'), '{"token":"test"}\n');
139
203
  const multiRequest = {
140
204
  ...request,
205
+ session_id: 'session-c',
141
206
  tool_input: { command: 'cat .env credentials.json' }
142
207
  };
143
208
  const multiDenied = runHook(block, nested, multiRequest);
@@ -150,31 +215,65 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
150
215
 
151
216
  const multiApproved = runHook(approve, nested, {
152
217
  cwd: nested,
153
- session_id: 'session-a',
218
+ session_id: 'session-c',
154
219
  hook_event_name: 'UserPromptSubmit',
155
220
  prompt: multiPrompt
156
221
  });
157
- assert.match(multiApproved.stdout, /approved one retry/);
222
+ assert.match(multiApproved.stdout, /User approved/);
158
223
 
159
- // A token for two paths cannot authorize a subset, and the failed subset
160
- // attempt must not consume the exact-set token.
161
- const subsetDenied = runHook(block, nested, request);
224
+ // A grant for two paths authorizes exactly that set: neither a subset nor a
225
+ // superset, in either order.
226
+ const subsetDenied = runHook(block, nested, { ...request, session_id: 'session-c' });
162
227
  assert.equal(JSON.parse(subsetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
163
228
  fs.writeFileSync(path.join(nested, 'tokens.json'), '{"token":"test"}\n');
164
229
  const supersetDenied = runHook(block, nested, {
165
230
  ...request,
231
+ session_id: 'session-c',
166
232
  tool_input: { command: 'cat .env credentials.json tokens.json' }
167
233
  });
168
234
  assert.equal(JSON.parse(supersetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
169
235
  const reordered = {
170
236
  ...request,
237
+ session_id: 'session-c',
171
238
  tool_input: { command: 'cat credentials.json .env' }
172
239
  };
173
240
  assert.equal(runHook(block, nested, reordered).stdout, '');
174
- assert.equal(
175
- JSON.parse(runHook(block, nested, multiRequest).stdout).hookSpecificOutput.permissionDecision,
176
- 'deny'
177
- );
241
+ assert.equal(runHook(block, nested, multiRequest).stdout, '', 'the set stays granted');
242
+
243
+ // Session binding survives the change: another session sees no grant.
244
+ const otherSession = runHook(block, nested, { ...request, session_id: 'session-d' });
245
+ assert.equal(JSON.parse(otherSession.stdout).hookSpecificOutput.permissionDecision, 'deny');
246
+ });
247
+ });
248
+
249
+ test('Codex privacy grant window is one hour and restarts at approval', () => {
250
+ inHookFixture((root, hooks) => {
251
+ const state = require(path.join(hooks, 'lib', 'privacy-state.cjs'));
252
+ const HOUR = 60 * 60 * 1000;
253
+ assert.equal(state.TTL_MS, HOUR, 'a five-minute window expired mid-conversation');
254
+
255
+ fs.writeFileSync(path.join(root, '.env'), 'SECRET=1\n');
256
+ const pending = state.createPending({
257
+ projectRoot: root,
258
+ sessionCwd: root,
259
+ sessionId: 'session-ttl',
260
+ filePaths: ['.env'],
261
+ });
262
+ assert.ok(pending.expiresAt - Date.now() > HOUR - 5000, 'pending must live an hour');
263
+
264
+ const approved = state.approvePending({
265
+ projectRoot: root,
266
+ sessionId: 'session-ttl',
267
+ requestId: pending.requestId,
268
+ });
269
+ assert.equal(approved.ok, true);
270
+
271
+ const stateDir = path.join(root, '.codex', 'hooks', '.privacy');
272
+ const tokenFile = fs.readdirSync(stateDir).find((name) => name.startsWith('token-'));
273
+ assert.ok(tokenFile, 'approval must write a grant');
274
+ const grant = JSON.parse(fs.readFileSync(path.join(stateDir, tokenFile), 'utf8'));
275
+ assert.ok(grant.expiresAt - Date.now() > HOUR - 5000, 'the grant window starts at approval');
276
+ assert.equal(grant.toolName, undefined, 'a grant is not bound to a tool name');
178
277
  });
179
278
  });
180
279
 
@@ -293,10 +392,25 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
293
392
  fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
294
393
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
295
394
 
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/);
395
+ const blockedBeforeCloseout = runHook(gate, nested, payload);
396
+ assert.equal(blockedBeforeCloseout.status, 0);
397
+ assert.equal(JSON.parse(blockedBeforeCloseout.stdout).decision, 'block');
398
+ assert.match(JSON.parse(blockedBeforeCloseout.stdout).reason, /verification receipt/);
399
+
400
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
401
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
402
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
403
+ ].join('\n'), 'auth'));
404
+ const validBeforeCloseout = runHook(gate, nested, payload);
405
+ assert.equal(validBeforeCloseout.status, 0);
406
+ assert.equal(validBeforeCloseout.stdout, '');
407
+ assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
408
+
409
+ spec.current_phase = 'closeout';
410
+ fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
411
+ const closeout = runHook(gate, nested, payload);
412
+ assert.equal(closeout.status, 0);
413
+ assert.equal(closeout.stdout, '');
300
414
  });
301
415
  });
302
416
 
@@ -322,6 +436,7 @@ test('empty or forged Codex hook authority fails closed', () => {
322
436
  fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
323
437
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
324
438
  status: 'in_progress',
439
+ current_phase: 'closeout',
325
440
  feature_name: 'auth',
326
441
  task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
327
442
  }));
@@ -347,15 +462,16 @@ test('empty or forged Codex hook authority fails closed', () => {
347
462
  });
348
463
  });
349
464
 
350
- test('Critical Codex completion ignores worker-writable proof strings and remains blocked', () => {
465
+ test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
351
466
  inHookFixture((root, hooks) => {
352
467
  const feature = path.join(root, 'specs', 'auth');
353
468
  const taskPath = 'tasks/task.md';
354
469
  fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
355
470
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
356
471
  status: 'in_progress',
472
+ current_phase: 'closeout',
357
473
  feature_name: 'auth',
358
- workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
474
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
359
475
  proofs: {
360
476
  needsInspection: 'inspection completed',
361
477
  needsIndependentAudit: 'PASS',
@@ -453,6 +569,24 @@ test('Codex state hook persists direct native event fields at project root', ()
453
569
  });
454
570
  });
455
571
 
572
+ test('semantic review authority hook is registered only for SubagentStop on Claude and Codex', () => {
573
+ const configurations = [
574
+ JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'), 'utf8')).hooks,
575
+ JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks.json'), 'utf8')).hooks,
576
+ ];
577
+ for (const hooks of configurations) {
578
+ const registrations = [];
579
+ for (const [event, groups] of Object.entries(hooks)) {
580
+ for (const group of groups) {
581
+ for (const hook of group.hooks || []) {
582
+ if (String(hook.command || '').includes('semantic-review-authority.cjs')) registrations.push(event);
583
+ }
584
+ }
585
+ }
586
+ assert.deepEqual(registrations, ['SubagentStop']);
587
+ }
588
+ });
589
+
456
590
  test('Codex canonical receipt provenance requires both Base and Head', () => {
457
591
  const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
458
592
  const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
@@ -494,12 +628,12 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
494
628
  const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
495
629
 
496
630
  // 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' } } }));
631
+ 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
632
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
499
633
  fs.mkdirSync(payload.cwd, { recursive: true });
500
634
  const blocked = runHook(gate, payload.cwd, payload);
501
635
  assert.equal(JSON.parse(blocked.stdout).decision, 'block');
502
- assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
636
+ assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
503
637
 
504
638
  // both -> no block
505
639
  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 +645,14 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
511
645
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
512
646
  const blockedEmpty = runHook(gate, payload.cwd, payload);
513
647
  assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
514
- assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bg\b/);
648
+ assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
515
649
 
516
650
  // bare base_sha without colon should block — reset cache again
517
651
  try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
518
652
  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
653
  const blockedBare = runHook(gate, payload.cwd, payload);
520
654
  assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
521
- assert.match(JSON.parse(blockedBare.stdout).reason, /\bg\b/);
655
+ assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
522
656
  });
523
657
  });
524
658
 
@@ -540,7 +674,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
540
674
 
541
675
  // first-run (no cache) with valid receipt → no block, cache seeded
542
676
  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' } } }));
677
+ 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
678
  const validBody = bindReceipt(root, validBodyTemplate);
545
679
  fs.writeFileSync(path.join(feature, taskPath), validBody);
546
680
  let res = runHook(gate, appCwd, payload);
@@ -557,7 +691,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
557
691
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
558
692
  res = runHook(gate, appCwd, payload);
559
693
  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/);
694
+ assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
561
695
  // second hit still blocks
562
696
  res = runHook(gate, appCwd, payload);
563
697
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
@@ -568,7 +702,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
568
702
  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
703
  res = runHook(gate, appCwd, payload);
570
704
  assert.equal(JSON.parse(res.stdout).decision, 'block');
571
- assert.match(JSON.parse(res.stdout).reason, /\bg\b/);
705
+ assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
572
706
 
573
707
  // deletion → block with check a
574
708
  fs.writeFileSync(path.join(feature, taskPath), validBody);
@@ -777,6 +911,7 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
777
911
  fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
778
912
  fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
779
913
  status: 'in_progress',
914
+ current_phase: 'closeout',
780
915
  feature_name: 'artifact-demo',
781
916
  task_registry: {
782
917
  [taskPath]: {
@@ -805,10 +940,377 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
805
940
  if (shouldBlock) {
806
941
  const block = JSON.parse(result.stdout);
807
942
  assert.equal(block.decision, 'block');
808
- assert.match(block.reason, /\bh\b/);
943
+ assert.match(block.reason, /\bartifact_hash\b/);
809
944
  } else {
810
945
  assert.equal(result.stdout, '');
811
946
  }
812
947
  });
813
948
  }
814
949
  });
950
+
951
+ test('Codex privacy denies obfuscated secret reads, allows benign and runtime state', () => {
952
+ inHookFixture((root, hooks) => {
953
+ const block = path.join(hooks, 'privacy-block.cjs');
954
+ const denied = [
955
+ { command: "cat .e''nv", tool: 'exec_command' },
956
+ { command: 'cat ~/.cafekit/completion-authority/hmac.key', tool: 'exec_command' },
957
+ { command: 'cat ~/.ssh/id_rsa', tool: 'Bash' },
958
+ { command: 'docker compose --env-file .env up', tool: 'exec_command' },
959
+ { command: "python3 -c \"open('.env').read()\"", tool: 'exec_command' },
960
+ { command: "node -e \"fs.readFileSync('.env')\"", tool: 'Bash' },
961
+ { command: "bash -c 'cat .e''nv'", tool: 'exec_command' },
962
+ { command: "echo ok; cat .env", tool: 'Bash' },
963
+ ];
964
+ for (const { command, tool } of denied) {
965
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
966
+ assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', command);
967
+ }
968
+ // Runtime state and transcripts are CafeKit's own bookkeeping, not secrets.
969
+ // Guarding them by substring also matched every source file and doc that
970
+ // merely spelled `sessions` or `session-state`, so the gate fired on the
971
+ // repo it was installed in. The one real secret there, hmac.key, is still
972
+ // covered above by the `.key` rule.
973
+ const allowed = [
974
+ { command: "echo hello", tool: 'exec_command' },
975
+ { command: "cat README.md", tool: 'exec_command' },
976
+ { command: "cat src/app.js", tool: 'Bash' },
977
+ { command: 'cat .codex/sessions/test.json', tool: 'exec_command' },
978
+ { command: 'cat .codex/session-state/latest.md', tool: 'Bash' },
979
+ { command: 'cat docs/sessions-guide.md', tool: 'exec_command' },
980
+ ];
981
+ for (const { command, tool } of allowed) {
982
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
983
+ assert.equal(res.stdout, '', command);
984
+ }
985
+ });
986
+ });
987
+
988
+ test('Codex privacy denies sensitive names it can resolve, allows pure indirection', () => {
989
+ inHookFixture((root, hooks) => {
990
+ const block = path.join(hooks, 'privacy-block.cjs');
991
+ const denied = [
992
+ { command: 'cat .e\\\nnv', tool: 'Bash' },
993
+ { command: "cat $'\\x2e\\x65\\x6e\\x76'", tool: 'exec_command' },
994
+ { command: 'cat .env*', tool: 'Bash' },
995
+ { command: 'cat certs/*.pem', tool: 'exec_command' },
996
+ { command: 'cat ~/.aws/credentials', tool: 'Bash' },
997
+ { command: 'ssh -i ~/.ssh/id_ed25519 host', tool: 'exec_command' },
998
+ { command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
999
+ { command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
1000
+ { command: 'openssl rsa -in server.key', tool: 'Bash' },
1001
+ ];
1002
+ for (const { command, tool } of denied) {
1003
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
1004
+ assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
1005
+ }
1006
+ // Accepted gap: the target of `$FILE` is unknowable without running the
1007
+ // shell, and every denial here costs a round trip through a one-shot
1008
+ // approval prompt. `less "$HOME/.env"` is the edge of that decision.
1009
+ const allowed = [
1010
+ { command: "echo $VAR", tool: 'exec_command' },
1011
+ { command: 'echo "$HOME"', tool: 'Bash' },
1012
+ { command: "git status", tool: 'exec_command' },
1013
+ { command: "git status $VAR", tool: 'Bash' },
1014
+ { command: "cat README.md", tool: 'exec_command' },
1015
+ { command: "cat .env.example", tool: 'Bash' },
1016
+ { command: "cat .env.sample", tool: 'Bash' },
1017
+ { command: "cat .env.template", tool: 'exec_command' },
1018
+ { command: "cat .env.test", tool: 'Bash' },
1019
+ { command: "cat '$FILE'", tool: 'exec_command' },
1020
+ { command: "cat '$HOME/README.md'", tool: 'Bash' },
1021
+ { command: "cat README.md '$EXTRA'", tool: 'exec_command' },
1022
+ { command: "echo '$(cat $FILE)'", tool: 'Bash' },
1023
+ { command: 'echo .envoy', tool: 'exec_command' },
1024
+ { command: 'echo not.env', tool: 'Bash' },
1025
+ { command: 'echo .env', tool: 'exec_command' },
1026
+ { command: "printf '%s\\n' .env", tool: 'Bash' },
1027
+ { command: 'git log -- .env', tool: 'Bash' },
1028
+ { command: 'git status sessions.md', tool: 'exec_command' },
1029
+ { command: 'python3 -c "print(os.environ[\'HOME\'])"', tool: 'Bash' },
1030
+ { command: 'head -n "$COUNT" README.md', tool: 'exec_command' },
1031
+ { command: 'grep .env README.md', tool: 'Bash' },
1032
+ { command: "sed -n '/.env/p' README.md", tool: 'exec_command' },
1033
+ { command: 'cat $FILE', tool: 'Bash' },
1034
+ { command: 'cat "$FILE"', tool: 'exec_command' },
1035
+ { command: 'cat ${FILE}', tool: 'Bash' },
1036
+ { command: 'cat $1', tool: 'exec_command' },
1037
+ { command: 'cat $(echo .env)', tool: 'Bash' },
1038
+ { command: 'cat `echo .env`', tool: 'Bash' },
1039
+ { command: 'head $SECRET', tool: 'exec_command' },
1040
+ { command: 'tail ${MY_FILE}', tool: 'Bash' },
1041
+ { command: 'less "$HOME/.env"', tool: 'Bash' },
1042
+ { command: 'cat README.md $EXTRA', tool: 'Bash' },
1043
+ { command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
1044
+ { command: 'cat README.md | head $FILE', tool: 'exec_command' },
1045
+ { command: "bash -c 'cat $FILE'", tool: 'Bash' },
1046
+ { command: 'cat README.md *', tool: 'Bash' },
1047
+ { command: 'cat ~/.zshrc', tool: 'exec_command' },
1048
+ { command: 'du -sh */', tool: 'Bash' },
1049
+ { command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
1050
+ { command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
1051
+ { command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
1052
+ { command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
1053
+ { command: 'source "$FILE"', tool: 'exec_command' },
1054
+ { command: 'xargs -a "$LIST" cat', tool: 'Bash' },
1055
+ { command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
1056
+ { command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
1057
+ { command: 'cat < "$FILE"', tool: 'exec_command' },
1058
+ ];
1059
+ for (const { command, tool } of allowed) {
1060
+ const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
1061
+ assert.equal(res.stdout, '', `should allow ${command}`);
1062
+ }
1063
+ 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' } });
1064
+ assert.equal(assignmentOnly.stdout, '');
1065
+ const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } });
1066
+ assert.equal(JSON.parse(assignmentRead.stdout).hookSpecificOutput.permissionDecision, 'deny');
1067
+ });
1068
+ });
1069
+
1070
+ test('Codex privacy reads an inert heredoc body as prose, not shell', () => {
1071
+ inHookFixture((root, hooks) => {
1072
+ const block = path.join(hooks, 'privacy-block.cjs');
1073
+ const inert = "gh pr create --body \"$(cat <<'BODY'\nRun `cat .env` to inspect config\nBODY\n)\"";
1074
+ const allowed = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: inert } });
1075
+ assert.equal(allowed.stdout, '', 'quoted delimiter expands nothing');
1076
+
1077
+ const expanding = 'bash -c "cat <<BODY\n$(cat .env)\nBODY"';
1078
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: expanding } });
1079
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'unquoted delimiter expands');
1080
+ });
1081
+ });
1082
+
1083
+ test('Claude and Codex privacy lexers agree on literals, delimiters, and quote expansion', () => {
1084
+ inHookFixture((root, hooks) => {
1085
+ const claude = path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs');
1086
+ const codex = path.join(hooks, 'privacy-block.cjs');
1087
+ const vectors = [
1088
+ ["cat .e''nv", true],
1089
+ ["python3 -c \"open('.env').read()\"", true],
1090
+ ['cat README.md $FILE', false],
1091
+ ['echo ok; cat README.md $FILE', false],
1092
+ ["cat '$FILE'", false],
1093
+ ["echo '$(cat $FILE)'", false],
1094
+ ["cat README.md '*'", false],
1095
+ ["cat README.md '~'", false],
1096
+ ["cat README.md '{README.md,LICENSE}'", false],
1097
+ ['cat .env.example', false],
1098
+ ['echo .envoy', false],
1099
+ ['cat .e\\\nnv', true],
1100
+ ["cat $'\\x2e\\x65\\x6e\\x76'", true],
1101
+ ['env -u UNUSED cat "$FILE"', false],
1102
+ ['timeout 1 cat "$FILE"', false],
1103
+ ["eval 'cat \"$FILE\"'", false],
1104
+ ["find . -exec sh -c 'cat \"$FILE\"' \\;", false],
1105
+ ['source "$FILE"', false],
1106
+ ['xargs -a "$LIST" cat', false],
1107
+ ['python3 -c \'open(os.environ["FILE"]).read()\'', false],
1108
+ ['cat ~/.ssh/id_rsa', true],
1109
+ ['docker compose --env-file .env up', true],
1110
+ ['cat ~/.zshrc', false],
1111
+ ['git log -- .env', false],
1112
+ ['echo .env', false],
1113
+ ["printf '%s\\n' .env", false],
1114
+ ['git status sessions.md', false],
1115
+ ['python3 -c "print(os.environ[\'HOME\'])"', false],
1116
+ ];
1117
+ for (const [command, blocked] of vectors) {
1118
+ const payload = { cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command } };
1119
+ const stateDir = path.join(hooks, '.privacy');
1120
+ const pendingCount = () => fs.existsSync(stateDir)
1121
+ ? fs.readdirSync(stateDir).filter((name) => name.startsWith('pending-')).length
1122
+ : 0;
1123
+ const beforePending = pendingCount();
1124
+ const claudeResult = runHook(claude, root, payload);
1125
+ const codexResult = runHook(codex, root, { ...payload, session_id: `parity-${command}` });
1126
+ const claudeOutput = claudeResult.stdout.trim() ? JSON.parse(claudeResult.stdout) : null;
1127
+ const codexOutput = codexResult.stdout.trim() ? JSON.parse(codexResult.stdout) : null;
1128
+ assert.equal(claudeOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'ask' : null, `Claude parity vector: ${command}`);
1129
+ assert.equal(codexOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'deny' : null, `Codex parity vector: ${command}`);
1130
+ if (blocked) {
1131
+ assert.equal(claudeOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
1132
+ assert.equal(codexOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
1133
+ assert.ok(claudeOutput.hookSpecificOutput.permissionDecisionReason);
1134
+ assert.ok(codexOutput.hookSpecificOutput.permissionDecisionReason);
1135
+ }
1136
+ assert.equal(pendingCount() - beforePending, blocked ? 1 : 0, `Codex approval-state side effect: ${command}`);
1137
+ }
1138
+ });
1139
+ });
1140
+
1141
+ test('Codex privacy handles dangling symlink to protected target (and allows benign/example)', () => {
1142
+ inHookFixture((root, hooks) => {
1143
+ const block = path.join(hooks, 'privacy-block.cjs');
1144
+ // Create .env and dangling link
1145
+ const env = path.join(root, '.env');
1146
+ fs.writeFileSync(env, 'SECRET=1');
1147
+ const link = path.join(root, 'innocent.txt');
1148
+ fs.symlinkSync(env, link);
1149
+ fs.unlinkSync(env);
1150
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: link } });
1151
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'dangling to .env must deny');
1152
+
1153
+ // Dangling benign
1154
+ const benign = path.join(root, 'benign.txt');
1155
+ fs.writeFileSync(benign, 'hi');
1156
+ const dangling = path.join(root, 'dangling.txt');
1157
+ fs.symlinkSync(benign, dangling);
1158
+ fs.unlinkSync(benign);
1159
+ const allowed1 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: dangling } });
1160
+ assert.equal(allowed1.stdout, '', 'dangling benign should allow');
1161
+
1162
+ // Dangling to .env.example
1163
+ const example = path.join(root, '.env.example');
1164
+ fs.writeFileSync(example, 'EXAMPLE=1');
1165
+ const linkExample = path.join(root, 'link-example.txt');
1166
+ fs.symlinkSync(example, linkExample);
1167
+ fs.unlinkSync(example);
1168
+ const allowed2 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: linkExample } });
1169
+ assert.equal(allowed2.stdout, '', 'dangling to .env.example should allow');
1170
+
1171
+ // Real link to allowed example remains allowed
1172
+ fs.writeFileSync(example, 'EXAMPLE=1');
1173
+ const realLink = path.join(root, 'real-example.txt');
1174
+ if (fs.existsSync(realLink)) fs.unlinkSync(realLink);
1175
+ // remove previous dangling if exists
1176
+ try { fs.unlinkSync(linkExample); } catch {}
1177
+ fs.symlinkSync(example, realLink);
1178
+ const allowed3 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: realLink } });
1179
+ assert.equal(allowed3.stdout, '', 'real link to .env.example should allow');
1180
+
1181
+ // Looping symlink fails closed
1182
+ const a = path.join(root, 'loop-a');
1183
+ const b = path.join(root, 'loop-b');
1184
+ try { fs.unlinkSync(a); } catch {}
1185
+ try { fs.unlinkSync(b); } catch {}
1186
+ fs.symlinkSync(b, a);
1187
+ fs.symlinkSync(a, b);
1188
+ const looped = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: a } });
1189
+ assert.equal(JSON.parse(looped.stdout).hookSpecificOutput.permissionDecision, 'deny', 'looping symlink should deny');
1190
+ });
1191
+ });
1192
+
1193
+ test('Codex privacy apply_patch with temp fixtures respects sensitive vs safe', () => {
1194
+ inHookFixture((root, hooks) => {
1195
+ const block = path.join(hooks, 'privacy-block.cjs');
1196
+ const safePatch = '*** Begin Patch\n*** Update File: README.md\n+hi\n*** End Patch\n';
1197
+ const safeRes = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: safePatch } });
1198
+ assert.equal(safeRes.stdout, '', 'safe patch should allow');
1199
+ const sensitivePatch = '*** Begin Patch\n*** Update File: .env\n+SECRET=x\n*** End Patch\n';
1200
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: sensitivePatch } });
1201
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny');
1202
+ });
1203
+ });
1204
+
1205
+ test('Codex hook-context canonicalizes symlink project root (regression)', () => {
1206
+ inHookFixture((root, hooks) => {
1207
+ const hookContextPath = path.join(hooks, 'lib', 'hook-context.cjs');
1208
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-symroot-'));
1209
+ try {
1210
+ const linkRoot = path.join(outside, 'link-project');
1211
+ fs.symlinkSync(root, linkRoot);
1212
+ // Require the installed hook-context via the symlink path to see PROJECT_ROOT canonicalization
1213
+ const linkedContext = require(path.join(linkRoot, '.codex', 'hooks', 'lib', 'hook-context.cjs'));
1214
+ const realRoot = fs.realpathSync(root);
1215
+ assert.equal(linkedContext.PROJECT_ROOT, realRoot, 'PROJECT_ROOT should be canonicalized via realpath');
1216
+ // getHookContext should still work with cwd inside symlink root
1217
+ const payload = { cwd: linkRoot, session_id: 's1' };
1218
+ const ctx = linkedContext.getHookContext(payload);
1219
+ assert.equal(ctx.projectRoot, realRoot);
1220
+ assert.ok(ctx.sessionCwd, 'sessionCwd should be resolved');
1221
+ } finally {
1222
+ fs.rmSync(outside, { recursive: true, force: true });
1223
+ // Clear require cache for the symlink context to avoid polluting other tests
1224
+ Object.keys(require.cache).forEach((k) => { if (k.includes('link-project')) delete require.cache[k]; });
1225
+ }
1226
+ });
1227
+ });
1228
+
1229
+ test('Codex resolver and state reject symlink escape', () => {
1230
+ inHookFixture((root, hooks) => {
1231
+ const specUtils = require(path.join(hooks, 'lib', 'spec-utils.cjs'));
1232
+ const resolverPath = path.join(root, '.codex', 'scripts', 'spec-resolver.cjs');
1233
+ const linkTarget = path.join(root, 'external.js');
1234
+ fs.writeFileSync(linkTarget, 'module.exports = {}');
1235
+ const original = fs.readFileSync(resolverPath);
1236
+ fs.unlinkSync(resolverPath);
1237
+ fs.symlinkSync(linkTarget, resolverPath);
1238
+ assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|rejected/i);
1239
+ fs.unlinkSync(resolverPath);
1240
+ fs.writeFileSync(resolverPath, original);
1241
+ const scripts = path.dirname(resolverPath);
1242
+ const outsideScripts = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-scripts-outside-'));
1243
+ try {
1244
+ fs.cpSync(scripts, outsideScripts, { recursive: true });
1245
+ fs.rmSync(scripts, { recursive: true, force: true });
1246
+ fs.symlinkSync(outsideScripts, scripts);
1247
+ assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|trusted root|rejected/i);
1248
+ } finally {
1249
+ try { fs.unlinkSync(scripts); } catch {}
1250
+ fs.mkdirSync(scripts, { recursive: true });
1251
+ fs.copyFileSync(
1252
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
1253
+ path.join(scripts, 'spec-resolver.cjs'),
1254
+ );
1255
+ fs.rmSync(outsideScripts, { recursive: true, force: true });
1256
+ }
1257
+ // State dir symlink escape: create symlink for session-state dir
1258
+ const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
1259
+ const sessionId = 'sess-symlink';
1260
+ const dir = stateStore.stateDir(root, sessionId);
1261
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
1262
+ // Create a symlink that points outside project
1263
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
1264
+ try {
1265
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
1266
+ fs.symlinkSync(outside, dir);
1267
+ // withStateLock should handle symlink safely (no crash, no write outside)
1268
+ const ok = stateStore.withStateLock(root, sessionId, () => {});
1269
+ assert.equal(typeof ok, 'boolean');
1270
+ } finally {
1271
+ try { fs.unlinkSync(dir); } catch {}
1272
+ fs.rmSync(outside, { recursive: true, force: true });
1273
+ }
1274
+ });
1275
+ });
1276
+
1277
+ test('Codex privacy state rejects a symlinked state directory without external writes', () => {
1278
+ inHookFixture((root, hooks) => {
1279
+ const block = path.join(hooks, 'privacy-block.cjs');
1280
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-privacy-outside-'));
1281
+ const state = path.join(hooks, '.privacy');
1282
+ try {
1283
+ fs.symlinkSync(outside, state);
1284
+ const result = runHook(block, root, {
1285
+ cwd: root,
1286
+ session_id: 'state-symlink',
1287
+ hook_event_name: 'PreToolUse',
1288
+ tool_name: 'Read',
1289
+ tool_input: { file_path: path.join(root, '.env') },
1290
+ });
1291
+ const output = JSON.parse(result.stdout);
1292
+ assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
1293
+ assert.match(output.hookSpecificOutput.permissionDecisionReason, /could not be evaluated safely/i);
1294
+ assert.deepEqual(fs.readdirSync(outside), []);
1295
+ } finally {
1296
+ try { fs.unlinkSync(state); } catch {}
1297
+ fs.rmSync(outside, { recursive: true, force: true });
1298
+ }
1299
+ });
1300
+ });
1301
+
1302
+ test('Claude and Codex fail closed consistently for malformed cwd payloads', () => {
1303
+ inHookFixture((root, hooks) => {
1304
+ const payload = {
1305
+ cwd: 123,
1306
+ session_id: 'malformed-cwd',
1307
+ hook_event_name: 'PreToolUse',
1308
+ tool_name: 'Read',
1309
+ tool_input: { file_path: path.join(root, '.env') },
1310
+ };
1311
+ const claude = runHook(path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs'), root, payload);
1312
+ const codex = runHook(path.join(hooks, 'privacy-block.cjs'), root, payload);
1313
+ assert.equal(JSON.parse(claude.stdout).hookSpecificOutput.permissionDecision, 'ask');
1314
+ assert.equal(JSON.parse(codex.stdout).hookSpecificOutput.permissionDecision, 'deny');
1315
+ });
1316
+ });