@haposoft/cafekit 0.18.1 → 0.18.2

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 (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -5,9 +5,84 @@ const crypto = require('node:crypto');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
- const { spawnSync } = require('node:child_process');
8
+ const { spawnSync, spawn } = require('node:child_process');
9
9
  const { test } = require('node:test');
10
10
 
11
+ const OLD_INSPECTION_MATCHER = 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell';
12
+ const NEW_INSPECTION_MATCHER = 'Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent';
13
+ function matcherFixture(run) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-matcher-'));
15
+ const previous = process.cwd();
16
+ const config = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../src/codex/hooks.json'), 'utf8'));
17
+ const messages = [], warnings = [];
18
+ const ctx = { dryRun: false, results: { updated: 0, unchanged: 0, errors: 0 },
19
+ manifest: require('../../src/claude/migration-manifest.json'),
20
+ ui: { detail: value => messages.push(value), warn: value => warnings.push(value) } };
21
+ const file = path.join(root, '.codex/hooks.json');
22
+ try {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true }); process.chdir(root);
24
+ const merge = require('../phases/codex-hooks.js').mergeCodexHooks;
25
+ run({ root, config, ctx, file, messages, warnings, merge: () => merge(ctx, 'codex', root),
26
+ save: () => fs.writeFileSync(file, JSON.stringify(config)), read: () => JSON.parse(fs.readFileSync(file, 'utf8')) });
27
+ } finally { process.chdir(previous); fs.rmSync(root, { recursive: true, force: true }); }
28
+ }
29
+ test('upgrade refreshes a former managed inspection matcher and reports it', () => matcherFixture(f => {
30
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER; f.save();
31
+ const before = fs.readFileSync(f.file, 'utf8');
32
+ f.ctx.dryRun = true; f.merge();
33
+ assert.equal(fs.readFileSync(f.file, 'utf8'), before);
34
+ assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
35
+ f.ctx.dryRun = false; f.messages.length = 0; f.merge();
36
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
37
+ assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
38
+ assert.ok(!f.messages.some(line => /already registered/.test(line)));
39
+ }));
40
+ test('upgrade preserves a mixed user group and warns', () => matcherFixture(f => {
41
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
42
+ f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'echo user-owned' }); f.save(); f.merge();
43
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, OLD_INSPECTION_MATCHER);
44
+ assert.ok(f.read().hooks.PreToolUse[0].hooks.some(h => h.command === 'echo user-owned'));
45
+ assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
46
+ }));
47
+ test('reinstall keeps purely user groups unchanged without matcher warnings', () => matcherFixture(f => {
48
+ const userPre = { matcher: 'Bash', hooks: [{ command: 'my-linter' }] };
49
+ const userPost = { hooks: [{ command: 'my-logger' }] };
50
+ f.config.hooks = { PreToolUse: [userPre], PostToolUse: [userPost] }; f.save();
51
+ const installs = Array.from({ length: 2 }, () => {
52
+ const result = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
53
+ cwd: f.root, encoding: 'utf8',
54
+ });
55
+ return { result, config: f.read() };
56
+ });
57
+ for (const { result, config } of installs) {
58
+ assert.equal(result.status, 0, result.stderr);
59
+ for (const [event, original] of [['PreToolUse', userPre], ['PostToolUse', userPost]]) {
60
+ const group = config.hooks[event].find(g => g.hooks.some(h => h.command === original.hooks[0].command));
61
+ assert.equal(JSON.stringify(group), JSON.stringify(original));
62
+ }
63
+ assert.doesNotMatch(result.stdout + result.stderr, /matcher preserved/);
64
+ }
65
+ }));
66
+ test('fresh hook registration uses every template matcher', () => matcherFixture(f => {
67
+ f.merge();
68
+ for (const [event, groups] of Object.entries(f.config.hooks)) {
69
+ assert.deepEqual(f.read().hooks[event].map(g => g.matcher), groups.map(g => g.matcher));
70
+ }
71
+ }));
72
+ test('upgrade preserves a user-selected managed matcher and warns', () => matcherFixture(f => {
73
+ f.config.hooks.PreToolUse[0].matcher = 'Read|Grep'; f.save(); f.merge();
74
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, 'Read|Grep');
75
+ assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
76
+ }));
77
+ test('upgrade refreshes the surviving group after retired hooks are pruned', () => matcherFixture(f => {
78
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
79
+ f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'node ".codex/hooks/task-scaffold-guard.cjs"' });
80
+ f.save(); f.merge();
81
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
82
+ assert.ok(!JSON.stringify(f.read()).includes('task-scaffold-guard'));
83
+ assert.ok(f.messages.some(line => /matcher updated/.test(line)));
84
+ }));
85
+
11
86
  const PACKAGE_ROOT = path.join(__dirname, '../..');
12
87
  const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
13
88
  const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
@@ -68,12 +143,24 @@ function writeProcessTask(featureDir, status, receiptLines = null, command = 'pn
68
143
  function inHookFixture(run, options = {}) {
69
144
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
70
145
  const hooks = path.join(root, '.codex', 'hooks');
146
+ let deferred = false;
71
147
  try {
72
148
  fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
149
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
150
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
151
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
152
+ fs.copyFileSync(
153
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/inspect-paths.cjs'),
154
+ path.join(hooks, 'lib/inspect-paths.cjs'),
155
+ );
73
156
  fs.copyFileSync(
74
157
  path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
75
158
  path.join(hooks, 'lib/privacy-command-analysis.cjs'),
76
159
  );
160
+ fs.copyFileSync(
161
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-paths.cjs'),
162
+ path.join(hooks, 'lib/privacy-paths.cjs'),
163
+ );
77
164
  fs.copyFileSync(
78
165
  path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
79
166
  path.join(hooks, 'lib/runtime-path-safety.cjs'),
@@ -106,9 +193,14 @@ function inHookFixture(run, options = {}) {
106
193
  const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
107
194
  assert.equal(result.status, 0, result.stderr);
108
195
  }
109
- return run(root, hooks);
196
+ const result = run(root, hooks);
197
+ if (result && typeof result.then === 'function') {
198
+ deferred = true;
199
+ return result.finally(() => fs.rmSync(root, { recursive: true, force: true }));
200
+ }
201
+ return result;
110
202
  } finally {
111
- fs.rmSync(root, { recursive: true, force: true });
203
+ if (!deferred) fs.rmSync(root, { recursive: true, force: true });
112
204
  }
113
205
  }
114
206
 
@@ -120,6 +212,116 @@ function processTaskContent(title, status, dependency = 'none') {
120
212
  ].join('\n');
121
213
  }
122
214
 
215
+ function inNoCommitCodexFixture(run) {
216
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-no-commit-'));
217
+ const hooks = path.join(root, '.codex/hooks');
218
+ try {
219
+ fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
220
+ for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
221
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib', name), path.join(hooks, 'lib', name));
222
+ }
223
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
224
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
225
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
226
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
227
+ path.join(hooks, 'lib/runtime-path-safety.cjs'));
228
+ const scripts = path.join(root, '.codex/scripts');
229
+ fs.mkdirSync(scripts, { recursive: true });
230
+ for (const name of ['workflow-policy.cjs', 'provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs']) {
231
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts', name), path.join(scripts, name));
232
+ }
233
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
234
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
235
+ assert.equal(result.status, 0, result.stderr);
236
+ }
237
+ const writePacket = (feature, status) => {
238
+ const dir = path.join(root, 'specs', feature);
239
+ fs.mkdirSync(dir, { recursive: true });
240
+ fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
241
+ fs.writeFileSync(path.join(dir, 'task-01-work.md'), processTaskContent('Task 01: work', status)
242
+ + (status === 'done' ? '\n## Receipt\n\nVerification: PASS\n' : ''));
243
+ };
244
+ const invoke = (hook, event) => runHook(path.join(hooks, `${hook}.cjs`), root, {
245
+ cwd: root, session_id: 'no-commit-codex', hook_event_name: event, prompt: 'Continue specs/auth',
246
+ });
247
+ run({ writePacket, gate: () => invoke('spec-gate', 'Stop'), state: () => invoke('spec-state', 'UserPromptSubmit') });
248
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
249
+ }
250
+
251
+ test('Codex collaborationspawn_agent appears as the latest tracked tool on resume', () => {
252
+ inHookFixture((root, hooks) => {
253
+ const state = path.join(hooks, 'state.cjs');
254
+ const session = { cwd: root, session_id: 'spawn-tracking' };
255
+ const tracked = runHook(state, root, {
256
+ ...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
257
+ tool_input: { message: 'Inspect the parser behavior' },
258
+ });
259
+ assert.equal(tracked.status, 0, tracked.stderr);
260
+ const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
261
+ assert.equal(resumed.status, 0, resumed.stderr);
262
+ assert.match(resumed.stdout, /Latest Tracked Tool/);
263
+ assert.match(resumed.stdout, /collaborationspawn_agent/);
264
+ });
265
+ });
266
+
267
+ test('Codex collaborationspawn_agent input does not create a pending todo', () => {
268
+ inHookFixture((root, hooks) => {
269
+ const state = path.join(hooks, 'state.cjs');
270
+ const session = { cwd: root, session_id: 'spawn-no-todo' };
271
+ const title = 'Inspect parser without creating a todo';
272
+ const tracked = runHook(state, root, {
273
+ ...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
274
+ tool_input: { id: 'spawn-child', title },
275
+ });
276
+ assert.equal(tracked.status, 0, tracked.stderr);
277
+ const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
278
+ assert.equal(resumed.status, 0, resumed.stderr);
279
+ assert.match(resumed.stdout, /Latest Tracked Tool/);
280
+ assert.match(resumed.stdout, /collaborationspawn_agent/);
281
+ const pending = resumed.stdout.match(/## What's Left\n([\s\S]*?)(?=\n## |$)/);
282
+ assert.ok(pending, 'the pending task section must be present');
283
+ assert.ok(!pending[1].includes(title), 'spawn metadata must not become a pending task');
284
+ });
285
+ });
286
+
287
+ test('Codex no commits allows Stop without done tasks and prints one reminder', () => {
288
+ inNoCommitCodexFixture(({ writePacket, gate, state }) => {
289
+ writePacket('auth', 'in_progress');
290
+ const stopped = gate();
291
+ assert.equal(stopped.status, 0, stopped.stderr);
292
+ assert.equal(stopped.stdout.trim(), '');
293
+ const prompted = state();
294
+ assert.equal(prompted.status, 0, prompted.stderr);
295
+ assert.match(prompted.stdout, /no commits/);
296
+ assert.equal(prompted.stdout.trim().split('\n').length, 1);
297
+ assert.doesNotMatch(prompted.stdout, /Spec tollgate unavailable/);
298
+ });
299
+ });
300
+
301
+ test('Codex no commits blocks a done task with first commit guidance', () => {
302
+ inNoCommitCodexFixture(({ writePacket, gate }) => {
303
+ writePacket('auth', 'done');
304
+ const result = gate();
305
+ assert.equal(result.status, 0, result.stderr);
306
+ const body = JSON.parse(result.stdout);
307
+ assert.equal(body.decision, 'block');
308
+ assert.match(body.reason, /first commit/);
309
+ assert.doesNotMatch(body.reason, /controlled failure/);
310
+ });
311
+ });
312
+
313
+ test('Codex no commits blocks two completed packets with first commit guidance', () => {
314
+ inNoCommitCodexFixture(({ writePacket, gate }) => {
315
+ for (const feature of ['auth', 'billing']) writePacket(feature, 'done');
316
+ const result = gate();
317
+ assert.equal(result.status, 0, result.stderr);
318
+ const body = JSON.parse(result.stdout);
319
+ assert.equal(body.decision, 'block');
320
+ assert.match(body.reason, /first commit/);
321
+ assert.doesNotMatch(body.reason, /controlled failure/);
322
+ });
323
+ });
324
+
123
325
  function canonicalProcessTask(root, title, dependency = 'none') {
124
326
  const context = workflowRuntimeContext(root, 'auth', 'session-auth-state');
125
327
  return [
@@ -300,6 +502,57 @@ test('Codex Stop still blocks a recorded missing packet and reads a plan.md + sp
300
502
  });
301
503
  });
302
504
 
505
+ test('Codex installed spec-state uses one snapshot for done task receipts', (t) => {
506
+ inInstalledProcessSpecStateFixture(({ feature, hooks, root, writeTask }) => {
507
+ const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-snapshot-count-'));
508
+ try {
509
+ const tasks = ['task-01-work.md', 'task-02-work.md'];
510
+ for (const name of tasks) writeTask(name, name, 'done');
511
+ writeTask('task-03-work.md', 'Task 03: work', 'in_progress');
512
+ const scripts = path.join(root, '.codex/scripts');
513
+ const provenance = require(path.join(scripts, 'provenance.cjs'));
514
+ const policy = require(path.join(scripts, 'workflow-policy.cjs'));
515
+ const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
516
+ const context = provenance.deriveRuntimeContext({ projectRoot: root,
517
+ specsRoot: path.join(root, 'specs'), specFile: path.join(feature, 'plan.md'),
518
+ featureName: 'auth', runtimeSession: 'one-snapshot' });
519
+ for (const name of tasks) {
520
+ fs.appendFileSync(path.join(feature, name), [
521
+ '\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
522
+ `Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
523
+ ].join('\n'));
524
+ assert.deepEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
525
+ }
526
+ const countFile = path.join(trace, 'git-status.txt');
527
+ const preload = path.join(trace, 'count.cjs');
528
+ fs.writeFileSync(preload, `
529
+ const cp = require('node:child_process');
530
+ const fs = require('node:fs');
531
+ const path = require('node:path');
532
+ for (const method of ['spawnSync', 'execFileSync']) {
533
+ const original = cp[method];
534
+ cp[method] = function(command, args, ...rest) {
535
+ if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
536
+ fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
537
+ return original.call(this, command, args, ...rest);
538
+ };
539
+ }
540
+ `);
541
+ const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
542
+ cwd: root, encoding: 'utf8',
543
+ env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
544
+ input: JSON.stringify({ cwd: root, featureName: 'auth', hook_event_name: 'UserPromptSubmit',
545
+ session_id: 'one-snapshot', prompt: 'Continue specs/auth' }),
546
+ });
547
+ assert.equal(result.status, 0, result.stderr);
548
+ assert.match(result.stdout, /Spec state changed/);
549
+ const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
550
+ t.diagnostic(`Codex git status calls: ${count}`);
551
+ assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
552
+ } finally { fs.rmSync(trace, { recursive: true, force: true }); }
553
+ });
554
+ });
555
+
303
556
  test('Codex installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
304
557
  inInstalledProcessSpecStateFixture(({ findCacheFile, hooks, runState, writeTask }) => {
305
558
  writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
@@ -456,6 +709,503 @@ test('Codex resolver accepts unique legacy task numbers, rejects ambiguity, and
456
709
  });
457
710
  });
458
711
 
712
+ test('Codex inspect still denies blocked reads when the configured specs path escapes', () => {
713
+ inHookFixture((root, hooks) => {
714
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
715
+ const result = runHook(path.join(hooks, 'inspect-block.cjs'), root, {
716
+ cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'dist/index.html' },
717
+ });
718
+ assert.equal(result.status, 2);
719
+ assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
720
+ });
721
+ });
722
+
723
+ test('Codex rules still inject when the configured specs path escapes', () => {
724
+ inHookFixture((root, hooks) => {
725
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
726
+ const result = runHook(path.join(hooks, 'rules.cjs'), root, {
727
+ cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'invalid-specs-rules',
728
+ });
729
+ assert.equal(result.status, 0);
730
+ assert.match(result.stdout, /## Rules/);
731
+ assert.ok(result.stdout.includes(`${fs.realpathSync(root)}/specs/`));
732
+ });
733
+ });
734
+
735
+ test('Codex rules wait for runtime before reserving the same session', () => {
736
+ inHookFixture((root, hooks) => {
737
+ const file = path.join(root, '.codex/runtime.json');
738
+ const payload = { cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'waiting-for-runtime' };
739
+ const hook = path.join(hooks, 'rules.cjs');
740
+ assert.equal(fs.existsSync(file), false);
741
+ const absent = runHook(hook, root, payload);
742
+ assert.equal(absent.status, 0);
743
+ assert.equal(absent.stdout, '');
744
+ fs.writeFileSync(file, JSON.stringify({ paths: { specs: 'specs' } }));
745
+ const present = runHook(hook, root, payload);
746
+ assert.equal(present.status, 0);
747
+ assert.match(present.stdout, /## Rules/);
748
+ assert.equal(runHook(hook, root, payload).stdout, '');
749
+ });
750
+ });
751
+
752
+ test('Codex secret guardrail runs from source with its shared keyword shim', () => {
753
+ const env = { ...process.env };
754
+ delete env.CLAUDE_PROJECT_DIR;
755
+ delete env.PROJECT_ROOT;
756
+ const result = spawnSync(process.execPath, [path.join(CODEX_HOOKS, 'secret-output-guardrail.cjs')], {
757
+ cwd: PACKAGE_ROOT, env, encoding: 'utf8', input: JSON.stringify({ prompt: 'Explain secret handling.' }),
758
+ });
759
+ assert.equal(result.status, 0);
760
+ assert.equal(result.stderr, '');
761
+ assert.match(result.stdout, /## Secret handling/);
762
+ });
763
+
764
+ test('Codex secret guardrail fails open without a keyword library or a stack', () => {
765
+ inHookFixture((root, hooks) => {
766
+ fs.unlinkSync(path.join(hooks, 'lib/secret-keywords.cjs'));
767
+ const result = runHook(path.join(hooks, 'secret-output-guardrail.cjs'), root, { prompt: 'Explain secret handling.' });
768
+ assert.equal(result.status, 0);
769
+ assert.equal(result.stdout, '');
770
+ assert.equal(result.stderr, '');
771
+ const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
772
+ assert.equal(rows.length, 1);
773
+ assert.equal(rows[0].hook, 'secret-output-guardrail');
774
+ });
775
+ });
776
+
777
+ for (const invalidSpecs of [false, true]) {
778
+ test(`Codex compact warns about pending confirmations${invalidSpecs ? ' with an invalid specs path' : ''}`, () => {
779
+ inHookFixture((root, hooks) => {
780
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: invalidSpecs ? '../outside' : 'specs' } }));
781
+ const result = runHook(path.join(hooks, 'session.cjs'), root, {
782
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
783
+ });
784
+ assert.equal(result.status, 0);
785
+ assert.match(result.stdout, /Pending.*(?:confirmation|approval)/i);
786
+ assert.match(result.stdout, /(?:request|ask).*?(?:fresh|again|user)/i);
787
+ });
788
+ });
789
+ }
790
+
791
+ test('Codex compact clears its pending privacy request even with an invalid specs path', () => {
792
+ inHookFixture((root, hooks) => {
793
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
794
+ privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'compact-session', filePaths: ['.env'] });
795
+ const dir = path.join(hooks, '.privacy');
796
+ const pending = () => fs.readdirSync(dir).filter(name => name.startsWith('pending-'));
797
+ assert.equal(pending().length, 1);
798
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
799
+ const result = runHook(path.join(hooks, 'session.cjs'), root, {
800
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
801
+ });
802
+ assert.equal(result.status, 0);
803
+ assert.equal(pending().length, 0);
804
+ });
805
+ });
806
+
807
+ test('Codex compact releases only its own rules reservation', () => {
808
+ inHookFixture((root, hooks) => {
809
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
810
+ const prompt = session_id => runHook(path.join(hooks, 'rules.cjs'), root, { cwd: root, hook_event_name: 'UserPromptSubmit', session_id });
811
+ for (const id of ['session-s', 'session-t']) {
812
+ assert.match(prompt(id).stdout, /## Rules/);
813
+ assert.equal(prompt(id).stdout, '');
814
+ }
815
+ const compact = runHook(path.join(hooks, 'session.cjs'), root, {
816
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-s',
817
+ });
818
+ assert.equal(compact.status, 0);
819
+ assert.match(prompt('session-s').stdout, /## Rules/);
820
+ assert.equal(prompt('session-t').stdout, '');
821
+ });
822
+ });
823
+
824
+ test('Codex precompact ignores empty stdin without recording a crash', () => {
825
+ inHookFixture((root, hooks) => {
826
+ const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
827
+ cwd: root, input: '', encoding: 'utf8',
828
+ });
829
+ assert.equal(result.status, 0);
830
+ assert.equal(result.stderr, '');
831
+ const file = path.join(hooks, '.logs/hook-log.jsonl');
832
+ const rows = fs.existsSync(file)
833
+ ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
834
+ : [];
835
+ assert.equal(rows.filter(row => row.hook === 'precompact' && row.status === 'crash').length, 0);
836
+ });
837
+ });
838
+
839
+ test('Codex precompact anchors are restored only to the matching session', () => {
840
+ inHookFixture((root, hooks) => {
841
+ const head = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
842
+ const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
843
+ assert.equal(status.status, 0);
844
+ const dirty = status.stdout.trim().split('\n').filter(Boolean).length;
845
+ const capture = runHook(path.join(hooks, 'precompact.cjs'), root, {
846
+ cwd: root, hook_event_name: 'PreCompact', trigger: 'auto', session_id: 'session-s',
847
+ });
848
+ assert.equal(capture.status, 0, capture.stderr);
849
+ const restart = session_id => runHook(path.join(hooks, 'session.cjs'), root, {
850
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id,
851
+ });
852
+ const own = restart('session-s');
853
+ assert.equal(own.status, 0);
854
+ assert.match(own.stdout, /State before compaction/);
855
+ assert.ok(own.stdout.includes(`HEAD ${head}`));
856
+ assert.ok(own.stdout.includes(`${dirty} uncommitted path(s)`));
857
+ const other = restart('session-t');
858
+ assert.equal(other.status, 0);
859
+ assert.match(other.stdout, /Pending/i);
860
+ assert.doesNotMatch(other.stdout, /State before compaction/);
861
+ });
862
+ });
863
+
864
+ function sessionWithoutUpdateNetwork(root, hooks, session_id) {
865
+ const marker = path.join(root, 'update-network-attempted');
866
+ const preload = path.join(root, 'block-update-network.cjs');
867
+ fs.writeFileSync(preload, `require('https').get = () => {
868
+ require('fs').writeFileSync(${JSON.stringify(marker)}, 'attempted');
869
+ throw new Error('network disabled in update test');
870
+ };\n`);
871
+ const result = spawnSync(process.execPath, ['--require', preload, path.join(hooks, 'session.cjs')], {
872
+ cwd: root, encoding: 'utf8', input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
873
+ });
874
+ assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
875
+ return result;
876
+ }
877
+
878
+ test('Codex SessionStart prints a newer cached CafeKit version', () => {
879
+ inHookFixture((root, hooks) => {
880
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
881
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.3.0' }));
882
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'cached-update');
883
+ assert.equal(result.status, 0);
884
+ assert.ok(result.stdout.includes('\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n'));
885
+ });
886
+ });
887
+
888
+ test('Codex SessionStart stays silent about an equal cached CafeKit version', () => {
889
+ inHookFixture((root, hooks) => {
890
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
891
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.2.3' }));
892
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'equal-update');
893
+ assert.equal(result.status, 0);
894
+ assert.doesNotMatch(result.stdout, /CafeKit update available/);
895
+ });
896
+ });
897
+
898
+ test('Codex update failure still clears pending privacy for the session', () => {
899
+ inHookFixture((root, hooks) => {
900
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
901
+ privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'failed-update', filePaths: ['.env'] });
902
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
903
+ fs.writeFileSync(path.join(hooks, 'lib/update-check.cjs'), "module.exports = { checkCafeKitUpdate() { throw new Error('synthetic update failure'); } };\n");
904
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'failed-update');
905
+ assert.equal(result.status, 0);
906
+ assert.equal(result.stderr, '');
907
+ const pending = fs.readdirSync(path.join(hooks, '.privacy')).filter(name => name.startsWith('pending-'));
908
+ assert.equal(pending.length, 0);
909
+ });
910
+ });
911
+
912
+ test('Codex Stop describes an escaping specs root as an invalid workflow packet', () => {
913
+ inHookFixture((root, hooks) => {
914
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
915
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
916
+ cwd: root, session_id: 'invalid-root-message', hook_event_name: 'Stop', stop_hook_active: false,
917
+ });
918
+ assert.equal(result.status, 0);
919
+ const output = JSON.parse(result.stdout);
920
+ assert.equal(output.decision, 'block');
921
+ assert.match(output.reason, /^Completion gate: invalid workflow packet detected \(<specs>\):/);
922
+ assert.doesNotMatch(output.reason, /controlled failure/);
923
+ });
924
+ });
925
+
926
+ test('Codex spec-state shows detailed task status counts on its Phase line', () => {
927
+ inInstalledProcessSpecStateFixture(({ writeTask, runState }) => {
928
+ for (const [name, status] of [['task-01-active.md', 'in_progress'], ['task-02-next.md', 'pending'],
929
+ ['task-03-blocked.md', 'blocked'], ['task-04-later.md', 'pending']]) {
930
+ writeTask(name, name, status);
931
+ }
932
+ const result = runState();
933
+ assert.equal(result.status, 0, result.stderr);
934
+ assert.match(result.stdout, /^- Phase: .* \| Tasks: 0 done \/ 4 total \(1 in_progress, 2 pending, 1 blocked\)$/m);
935
+ });
936
+ });
937
+
938
+ function shortPrivacyFixture(check) {
939
+ return inHookFixture((root, hooks) => {
940
+ fs.writeFileSync(path.join(root, '.env'), 'fixture\n');
941
+ fs.writeFileSync(path.join(root, 'credentials.json'), '{}\n');
942
+ const dir = path.join(hooks, '.privacy');
943
+ const session = 'short-approval-session';
944
+ const payload = (file = '.env', session_id = session) => ({ cwd: root, session_id,
945
+ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
946
+ const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
947
+ const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
948
+ [path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
949
+ input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
950
+ const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
951
+ const id = result => {
952
+ assert.equal(result.status, 0, result.stderr);
953
+ const reason = JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason;
954
+ const match = reason.match(/APPROVE CAFEKIT PRIVACY ([a-f0-9]{24})/);
955
+ assert.ok(match, reason);
956
+ return match[1];
957
+ };
958
+ return check({ root, hooks, dir, session, payload, block, submit, files, id });
959
+ });
960
+ }
961
+
962
+ function asyncPrivacyBlock(fixture, file) {
963
+ return new Promise((resolve, reject) => {
964
+ const child = spawn(process.execPath, [path.join(fixture.hooks, 'privacy-block.cjs')], { cwd: fixture.root });
965
+ let stdout = '', stderr = '';
966
+ child.stdout.on('data', chunk => { stdout += chunk; });
967
+ child.stderr.on('data', chunk => { stderr += chunk; });
968
+ child.on('error', reject);
969
+ child.on('close', status => resolve({ status, stdout, stderr }));
970
+ child.stdin.end(JSON.stringify(fixture.payload(file)));
971
+ });
972
+ }
973
+
974
+ for (const [label, prompt] of [['y', 'y'], ['yes', 'yes'], ['title-case Vietnamese', 'Có'],
975
+ ['padded y', ' y '], ['decomposed Vietnamese', 'co\u0301'], ['uppercase Vietnamese', 'CÓ'], ['terminal newline', 'Y\r\n']]) {
976
+ test(`Codex short privacy approval accepts ${label} and reports granted paths`, () => shortPrivacyFixture(f => {
977
+ const denied = f.block();
978
+ const requestId = f.id(denied);
979
+ const sessionHash = crypto.createHash('sha256').update(f.session).digest('hex');
980
+ assert.deepEqual(f.files('short-'), [`short-${sessionHash}-${requestId}.json`]);
981
+ assert.match(denied.stdout, /y \(or yes \/ có\)/);
982
+ const result = f.submit(prompt);
983
+ assert.equal(result.status, 0, result.stderr);
984
+ assert.match(result.stdout, /User approved/);
985
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
986
+ assert.equal(f.files('token-').length, 1);
987
+ assert.equal(f.files('short-').length, 0);
988
+ assert.equal(f.block().stdout, '');
989
+ assert.equal(f.block().stdout, '', 'the one-hour grant remains reusable');
990
+ }));
991
+ }
992
+
993
+ test('Codex short privacy approval is cancelled by an intervening prompt', () => shortPrivacyFixture(f => {
994
+ f.id(f.block());
995
+ assert.equal(f.submit('ok làm đi').status, 0);
996
+ const result = f.submit('y');
997
+ assert.equal(result.status, 0, result.stderr);
998
+ assert.doesNotMatch(result.stdout, /User approved/);
999
+ assert.equal(f.files('token-').length, 0);
1000
+ assert.equal(f.files('short-').length, 0);
1001
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1002
+ }));
1003
+
1004
+ test('Codex short privacy approval stays in its session and requires pending state', () => shortPrivacyFixture(f => {
1005
+ assert.equal(f.submit('y').status, 0);
1006
+ assert.equal(f.files('token-').length, 0);
1007
+ f.id(f.block());
1008
+ assert.doesNotMatch(f.submit('y', 'another-session').stdout, /User approved/);
1009
+ assert.equal(f.files('token-').length, 0);
1010
+ assert.match(f.submit('y').stdout, /User approved/);
1011
+ }));
1012
+
1013
+ for (const [label, prompt] of [['extra Vietnamese text', 'y nhé'], ['extra English text', 'yes please'],
1014
+ ['repeated letter', 'yy'], ['fullwidth letter', 'y'], ['zero-width suffix', 'y\u200b']]) {
1015
+ test(`Codex short privacy approval rejects ${label}`, () => shortPrivacyFixture(f => {
1016
+ f.id(f.block());
1017
+ const result = f.submit(prompt);
1018
+ assert.equal(result.status, 0, result.stderr);
1019
+ assert.doesNotMatch(result.stdout, /User approved/);
1020
+ assert.equal(f.files('token-').length, 0);
1021
+ assert.equal(f.files('short-').length, 0);
1022
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1023
+ }));
1024
+ }
1025
+
1026
+ test('Codex short privacy approval refuses two requests while the long phrase selects one', () => shortPrivacyFixture(f => {
1027
+ const first = f.id(f.block());
1028
+ f.id(f.block('credentials.json'));
1029
+ const result = f.submit('y');
1030
+ assert.equal(result.status, 0, result.stderr);
1031
+ assert.doesNotMatch(result.stdout, /User approved/);
1032
+ assert.equal(f.files('token-').length, 0);
1033
+ assert.equal(f.files('short-').length, 0);
1034
+ const exact = f.submit(`APPROVE CAFEKIT PRIVACY ${first}`);
1035
+ assert.match(exact.stdout, /User approved/);
1036
+ assert.match(JSON.parse(exact.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1037
+ assert.equal(f.block().stdout, '');
1038
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1039
+ }));
1040
+
1041
+ test('parallel Codex privacy blocks preserve both markers and cannot be approved by y', () => shortPrivacyFixture(async f => {
1042
+ const results = await Promise.all([asyncPrivacyBlock(f, '.env'), asyncPrivacyBlock(f, 'credentials.json')]);
1043
+ assert.notEqual(f.id(results[0]), f.id(results[1]));
1044
+ assert.equal(f.files('short-').length, 2);
1045
+ assert.equal(f.submit('y').status, 0);
1046
+ assert.equal(f.files('token-').length, 0);
1047
+ assert.equal(f.files('short-').length, 0);
1048
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1049
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1050
+ }));
1051
+
1052
+ test('repeated Codex blocks for the same path set reuse the request and short marker', () => shortPrivacyFixture(f => {
1053
+ const first = f.id(f.block('.env credentials.json'));
1054
+ assert.equal(f.id(f.block('credentials.json .env')), first);
1055
+ assert.equal(f.files('pending-').length, 1);
1056
+ assert.equal(f.files('short-').length, 1);
1057
+ assert.match(f.submit('y').stdout, /User approved/);
1058
+ assert.equal(f.block('.env credentials.json').stdout, '');
1059
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1060
+ }));
1061
+
1062
+ test('a later Codex block restores the short marker for the same pending request', () => shortPrivacyFixture(f => {
1063
+ const requestId = f.id(f.block());
1064
+ assert.equal(f.submit('ok làm đi').status, 0);
1065
+ assert.equal(f.files('short-').length, 0);
1066
+ assert.equal(f.id(f.block()), requestId);
1067
+ assert.equal(f.files('pending-').length, 1);
1068
+ assert.equal(f.files('short-').length, 1);
1069
+ assert.match(f.submit('y').stdout, /User approved/);
1070
+ assert.equal(f.block().stdout, '');
1071
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1072
+ }));
1073
+
1074
+ test('an immediate repeated Codex block keeps one short marker without an exclusive-write error', () => shortPrivacyFixture(f => {
1075
+ const requestId = f.id(f.block());
1076
+ assert.equal(f.id(f.block()), requestId);
1077
+ assert.equal(f.files('pending-').length, 1);
1078
+ assert.equal(f.files('short-').length, 1);
1079
+ assert.match(f.submit('y').stdout, /User approved/);
1080
+ assert.equal(f.block().stdout, '');
1081
+ }));
1082
+
1083
+ test('a Codex prompt error after claiming markers cancels the short approval', () => shortPrivacyFixture(f => {
1084
+ f.id(f.block());
1085
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-fault-'));
1086
+ try {
1087
+ const preload = path.join(outside, 'fault.cjs');
1088
+ fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.readdirSync; let calls = 0;\nfs.readdirSync = function(dir, ...args) { if (String(dir) === ${JSON.stringify(fs.realpathSync(f.dir))} && ++calls === 2) throw new Error('injected state read error'); return original.call(this, dir, ...args); };\n`);
1089
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1090
+ assert.equal(failed.status, 0, failed.stderr);
1091
+ assert.match(failed.stdout, /failed safely/);
1092
+ assert.equal(f.files('short-').length, 0);
1093
+ assert.equal(f.files('token-').length, 0);
1094
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1095
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1096
+ }));
1097
+
1098
+ test('a Codex post-claim error cancels a marker created during prompt processing', () => shortPrivacyFixture(f => {
1099
+ f.id(f.block());
1100
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-post-claim-fault-'));
1101
+ try {
1102
+ const preload = path.join(outside, 'fault.cjs');
1103
+ fs.writeFileSync(preload, `const assert = require('node:assert/strict');
1104
+ const state = require(${JSON.stringify(path.join(f.hooks, 'lib/privacy-state.cjs'))});
1105
+ const claim = state.claimShortMarkers;
1106
+ let claimed = false;
1107
+ state.claimShortMarkers = (...args) => {
1108
+ const result = claim(...args);
1109
+ if (!claimed) { assert.equal(result.ok, true); assert.equal(result.requestIds.length, 1); claimed = true; }
1110
+ return result;
1111
+ };
1112
+ state.cleanup = () => {
1113
+ assert.equal(claimed, true);
1114
+ state.createPending(${JSON.stringify({ projectRoot: f.root, sessionCwd: f.root, sessionId: f.session, filePath: 'credentials.json' })});
1115
+ throw new Error('injected processing error after marker claim');
1116
+ };\n`);
1117
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1118
+ assert.equal(failed.status, 0, failed.stderr);
1119
+ assert.match(failed.stdout, /failed safely/);
1120
+ assert.equal(f.files('pending-').length, 2);
1121
+ assert.equal(f.files('short-').length, 0);
1122
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1123
+ assert.equal(f.files('token-').length, 0);
1124
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1125
+ }));
1126
+
1127
+ test('a corrupt Codex short marker cannot grant permission even with valid pending state', () => shortPrivacyFixture(f => {
1128
+ f.id(f.block());
1129
+ const markers = f.files('short-');
1130
+ assert.equal(markers.length, 1);
1131
+ fs.writeFileSync(path.join(f.dir, markers[0]), '{');
1132
+ const result = f.submit('y');
1133
+ assert.equal(result.status, 0, result.stderr);
1134
+ assert.doesNotMatch(result.stdout, /User approved/);
1135
+ assert.equal(f.files('token-').length, 0);
1136
+ assert.equal(f.files('short-').length, 0);
1137
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1138
+ }));
1139
+
1140
+ for (const unlinkFailure of [false, true]) {
1141
+ test(`an unclaimable Codex short marker is cancelled after ${unlinkFailure ? 'rename and unlink errors' : 'a rename error'}`, () => shortPrivacyFixture(f => {
1142
+ f.id(f.block());
1143
+ assert.equal(f.files('short-').length, 1);
1144
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-rename-fault-'));
1145
+ try {
1146
+ const preload = path.join(outside, 'fault.cjs');
1147
+ const prefix = JSON.stringify(path.join(fs.realpathSync(f.dir), 'short-'));
1148
+ fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;\nfs.renameSync = function(from, to) { if (String(from).startsWith(${prefix})) throw new Error('injected rename failure'); return original.call(this, from, to); };\n`
1149
+ + (unlinkFailure ? `const unlink = fs.unlinkSync; fs.unlinkSync = function(file) { if (String(file).startsWith(${prefix})) throw new Error('injected unlink failure'); return unlink.call(this, file); };\n` : ''));
1150
+ const result = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1151
+ assert.equal(result.status, 0, result.stderr);
1152
+ assert.doesNotMatch(result.stdout, /User approved/);
1153
+ assert.equal(f.files('token-').length, 0);
1154
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1155
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1156
+ assert.equal(f.files('token-').length, 0);
1157
+ assert.equal(f.files('short-').length, 0);
1158
+ }));
1159
+ }
1160
+
1161
+ test('Codex compact clears pending requests and short markers only for its session', () => shortPrivacyFixture(f => {
1162
+ f.id(f.block());
1163
+ f.id(f.block('credentials.json', 'other-session'));
1164
+ const result = runHook(path.join(f.hooks, 'session.cjs'), f.root,
1165
+ { source: 'compact', session_id: f.session, cwd: f.root, hook_event_name: 'SessionStart' });
1166
+ assert.equal(result.status, 0, result.stderr);
1167
+ const prefix = `short-${crypto.createHash('sha256').update(f.session).digest('hex')}-`;
1168
+ assert.equal(f.files(prefix).length, 0);
1169
+ assert.equal(f.files('short-').length, 1);
1170
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1171
+ assert.equal(f.files('token-').length, 0);
1172
+ assert.match(f.submit('y', 'other-session').stdout, /User approved/);
1173
+ }));
1174
+
1175
+ test('Codex short approval cannot grant an expired pending request', () => shortPrivacyFixture(f => {
1176
+ const id = f.id(f.block());
1177
+ const file = path.join(f.dir, `pending-${id}.json`);
1178
+ const pending = JSON.parse(fs.readFileSync(file, 'utf8'));
1179
+ pending.expiresAt = Date.now() - 1;
1180
+ fs.writeFileSync(file, JSON.stringify(pending));
1181
+ assert.equal(f.submit('y').status, 0);
1182
+ assert.equal(f.files('token-').length, 0);
1183
+ assert.equal(f.files('short-').length, 0);
1184
+ }));
1185
+
1186
+ test('Codex cleanup removes expired short markers', () => shortPrivacyFixture(f => {
1187
+ f.id(f.block());
1188
+ const markers = f.files('short-');
1189
+ assert.equal(markers.length, 1);
1190
+ const file = path.join(f.dir, markers[0]);
1191
+ const marker = JSON.parse(fs.readFileSync(file, 'utf8'));
1192
+ marker.expiresAt = Date.now() - 1;
1193
+ fs.writeFileSync(file, JSON.stringify(marker));
1194
+ require(path.join(f.hooks, 'lib/privacy-state.cjs')).cleanup(f.root);
1195
+ assert.equal(f.files('short-').length, 0);
1196
+ }));
1197
+
1198
+ test('Codex long privacy approval still works after an intervening prompt and reports paths', () => shortPrivacyFixture(f => {
1199
+ const id = f.id(f.block());
1200
+ assert.equal(f.submit('ok làm đi').status, 0);
1201
+ assert.equal(f.files('short-').length, 0);
1202
+ const result = f.submit(`APPROVE CAFEKIT PRIVACY ${id}`);
1203
+ assert.equal(result.status, 0, result.stderr);
1204
+ assert.match(result.stdout, /User approved/);
1205
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1206
+ assert.equal(f.block().stdout, '');
1207
+ }));
1208
+
459
1209
  test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
460
1210
  inHookFixture((root, hooks) => {
461
1211
  const nested = path.join(root, 'packages', 'app');
@@ -647,15 +1397,17 @@ test('Codex inspect hook scans native shell commands and structured paths', () =
647
1397
  for (const command of [
648
1398
  'rg secret node_modules',
649
1399
  "sed -n '1,20p' dist/bundle.js",
650
- "rg --glob '**/*' symbol src"
1400
+ "rg --glob '**/*' symbol ."
651
1401
  ]) {
652
1402
  const result = runHook(inspect, root, payload(command));
653
1403
  assert.equal(result.status, 2, command);
654
1404
  assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
655
1405
  }
656
1406
 
657
- const normal = runHook(inspect, root, payload('rg symbol src packages'));
658
- assert.equal(normal.status, 0);
1407
+ for (const command of ['rg symbol src packages', "rg --glob '**/*' symbol src"]) {
1408
+ const normal = runHook(inspect, root, payload(command));
1409
+ assert.equal(normal.status, 0);
1410
+ }
659
1411
 
660
1412
  const structured = runHook(inspect, root, {
661
1413
  ...payload(''),
@@ -929,6 +1681,22 @@ test('empty or forged Codex hook authority fails closed', () => {
929
1681
  });
930
1682
  });
931
1683
 
1684
+ test('Codex Stop omits secret-related assistant messages from saved state', () => {
1685
+ inHookFixture((root, hooks) => {
1686
+ const message = 'API key handling was discussed in this synthetic test.';
1687
+ const result = runHook(path.join(hooks, 'state.cjs'), root, {
1688
+ hook_event_name: 'Stop', session_id: 'secret-summary', last_assistant_message: message,
1689
+ });
1690
+ assert.equal(result.status, 0);
1691
+ const dir = codexStateDir(root, 'secret-summary');
1692
+ for (const name of ['data.json', 'latest.md']) {
1693
+ const content = fs.readFileSync(path.join(dir, name), 'utf8');
1694
+ assert.ok(content.includes('[omitted: possible secret]'));
1695
+ assert.ok(!content.includes(message));
1696
+ }
1697
+ });
1698
+ });
1699
+
932
1700
  test('Codex state hook persists direct native event fields at project root', () => {
933
1701
  inHookFixture((root, hooks) => {
934
1702
  const nested = path.join(root, 'packages', 'app');