@haposoft/cafekit 0.18.1 → 0.18.3

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 (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. 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,534 @@ 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
+ // Two privacy blocks racing on a fresh project both see no `.privacy` directory;
808
+ // the second mkdir then meets the directory the first one made.
809
+ test('Codex privacy records a request when another hook creates its state directory first', () => {
810
+ inHookFixture((root, hooks) => {
811
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
812
+ fs.mkdirSync(path.join(hooks, '.privacy'), { mode: 0o700 });
813
+ const existsSync = fs.existsSync;
814
+ fs.existsSync = target => (path.basename(String(target)) === '.privacy' ? false : existsSync(target));
815
+ let request;
816
+ try {
817
+ request = privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'race-session', filePaths: ['.env'] });
818
+ } finally {
819
+ fs.existsSync = existsSync;
820
+ }
821
+ assert.match(request.requestId, /^[a-f0-9]{24}$/);
822
+ });
823
+ });
824
+
825
+ test('Codex privacy denies when its state path is a regular file', () => {
826
+ inHookFixture((root, hooks) => {
827
+ fs.writeFileSync(path.join(hooks, '.privacy'), '');
828
+ const result = runHook(path.join(hooks, 'privacy-block.cjs'), root, {
829
+ cwd: root, session_id: 'file-state', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'cat .env' },
830
+ });
831
+ assert.equal(result.status, 0);
832
+ const output = JSON.parse(result.stdout).hookSpecificOutput;
833
+ assert.equal(output.permissionDecision, 'deny');
834
+ assert.match(output.permissionDecisionReason, /could not be evaluated safely/);
835
+ });
836
+ });
837
+
838
+ test('Codex compact releases only its own rules reservation', () => {
839
+ inHookFixture((root, hooks) => {
840
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
841
+ const prompt = session_id => runHook(path.join(hooks, 'rules.cjs'), root, { cwd: root, hook_event_name: 'UserPromptSubmit', session_id });
842
+ for (const id of ['session-s', 'session-t']) {
843
+ assert.match(prompt(id).stdout, /## Rules/);
844
+ assert.equal(prompt(id).stdout, '');
845
+ }
846
+ const compact = runHook(path.join(hooks, 'session.cjs'), root, {
847
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-s',
848
+ });
849
+ assert.equal(compact.status, 0);
850
+ assert.match(prompt('session-s').stdout, /## Rules/);
851
+ assert.equal(prompt('session-t').stdout, '');
852
+ });
853
+ });
854
+
855
+ test('Codex precompact ignores empty stdin without recording a crash', () => {
856
+ inHookFixture((root, hooks) => {
857
+ const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
858
+ cwd: root, input: '', encoding: 'utf8',
859
+ });
860
+ assert.equal(result.status, 0);
861
+ assert.equal(result.stderr, '');
862
+ const file = path.join(hooks, '.logs/hook-log.jsonl');
863
+ const rows = fs.existsSync(file)
864
+ ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
865
+ : [];
866
+ assert.equal(rows.filter(row => row.hook === 'precompact' && row.status === 'crash').length, 0);
867
+ });
868
+ });
869
+
870
+ test('Codex precompact anchors are restored only to the matching session', () => {
871
+ inHookFixture((root, hooks) => {
872
+ const head = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
873
+ const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
874
+ assert.equal(status.status, 0);
875
+ const dirty = status.stdout.trim().split('\n').filter(Boolean).length;
876
+ const capture = runHook(path.join(hooks, 'precompact.cjs'), root, {
877
+ cwd: root, hook_event_name: 'PreCompact', trigger: 'auto', session_id: 'session-s',
878
+ });
879
+ assert.equal(capture.status, 0, capture.stderr);
880
+ const restart = session_id => runHook(path.join(hooks, 'session.cjs'), root, {
881
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id,
882
+ });
883
+ const own = restart('session-s');
884
+ assert.equal(own.status, 0);
885
+ assert.match(own.stdout, /State before compaction/);
886
+ assert.ok(own.stdout.includes(`HEAD ${head}`));
887
+ assert.ok(own.stdout.includes(`${dirty} uncommitted path(s)`));
888
+ const other = restart('session-t');
889
+ assert.equal(other.status, 0);
890
+ assert.match(other.stdout, /Pending/i);
891
+ assert.doesNotMatch(other.stdout, /State before compaction/);
892
+ });
893
+ });
894
+
895
+ function sessionWithoutUpdateNetwork(root, hooks, session_id) {
896
+ const marker = path.join(root, 'update-network-attempted');
897
+ const preload = path.join(root, 'block-update-network.cjs');
898
+ fs.writeFileSync(preload, `require('https').get = () => {
899
+ require('fs').writeFileSync(${JSON.stringify(marker)}, 'attempted');
900
+ throw new Error('network disabled in update test');
901
+ };\n`);
902
+ const result = spawnSync(process.execPath, ['--require', preload, path.join(hooks, 'session.cjs')], {
903
+ cwd: root, encoding: 'utf8', input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
904
+ });
905
+ assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
906
+ return result;
907
+ }
908
+
909
+ test('Codex SessionStart prints a newer cached CafeKit version', () => {
910
+ inHookFixture((root, hooks) => {
911
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
912
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.3.0' }));
913
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'cached-update');
914
+ assert.equal(result.status, 0);
915
+ assert.ok(result.stdout.includes('\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n'));
916
+ });
917
+ });
918
+
919
+ test('Codex SessionStart stays silent about an equal cached CafeKit version', () => {
920
+ inHookFixture((root, hooks) => {
921
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
922
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.2.3' }));
923
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'equal-update');
924
+ assert.equal(result.status, 0);
925
+ assert.doesNotMatch(result.stdout, /CafeKit update available/);
926
+ });
927
+ });
928
+
929
+ test('Codex update failure still clears pending privacy for the session', () => {
930
+ inHookFixture((root, hooks) => {
931
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
932
+ privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'failed-update', filePaths: ['.env'] });
933
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
934
+ fs.writeFileSync(path.join(hooks, 'lib/update-check.cjs'), "module.exports = { checkCafeKitUpdate() { throw new Error('synthetic update failure'); } };\n");
935
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'failed-update');
936
+ assert.equal(result.status, 0);
937
+ assert.equal(result.stderr, '');
938
+ const pending = fs.readdirSync(path.join(hooks, '.privacy')).filter(name => name.startsWith('pending-'));
939
+ assert.equal(pending.length, 0);
940
+ });
941
+ });
942
+
943
+ test('Codex Stop describes an escaping specs root as an invalid workflow packet', () => {
944
+ inHookFixture((root, hooks) => {
945
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
946
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
947
+ cwd: root, session_id: 'invalid-root-message', hook_event_name: 'Stop', stop_hook_active: false,
948
+ });
949
+ assert.equal(result.status, 0);
950
+ const output = JSON.parse(result.stdout);
951
+ assert.equal(output.decision, 'block');
952
+ assert.match(output.reason, /^Completion gate: invalid workflow packet detected \(<specs>\):/);
953
+ assert.doesNotMatch(output.reason, /controlled failure/);
954
+ });
955
+ });
956
+
957
+ test('Codex spec-state shows detailed task status counts on its Phase line', () => {
958
+ inInstalledProcessSpecStateFixture(({ writeTask, runState }) => {
959
+ for (const [name, status] of [['task-01-active.md', 'in_progress'], ['task-02-next.md', 'pending'],
960
+ ['task-03-blocked.md', 'blocked'], ['task-04-later.md', 'pending']]) {
961
+ writeTask(name, name, status);
962
+ }
963
+ const result = runState();
964
+ assert.equal(result.status, 0, result.stderr);
965
+ assert.match(result.stdout, /^- Phase: .* \| Tasks: 0 done \/ 4 total \(1 in_progress, 2 pending, 1 blocked\)$/m);
966
+ });
967
+ });
968
+
969
+ function shortPrivacyFixture(check) {
970
+ return inHookFixture((root, hooks) => {
971
+ fs.writeFileSync(path.join(root, '.env'), 'fixture\n');
972
+ fs.writeFileSync(path.join(root, 'credentials.json'), '{}\n');
973
+ const dir = path.join(hooks, '.privacy');
974
+ const session = 'short-approval-session';
975
+ const payload = (file = '.env', session_id = session) => ({ cwd: root, session_id,
976
+ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
977
+ const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
978
+ const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
979
+ [path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
980
+ input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
981
+ const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
982
+ const id = result => {
983
+ assert.equal(result.status, 0, result.stderr);
984
+ const reason = JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason;
985
+ const match = reason.match(/APPROVE CAFEKIT PRIVACY ([a-f0-9]{24})/);
986
+ assert.ok(match, reason);
987
+ return match[1];
988
+ };
989
+ return check({ root, hooks, dir, session, payload, block, submit, files, id });
990
+ });
991
+ }
992
+
993
+ function asyncPrivacyBlock(fixture, file) {
994
+ return new Promise((resolve, reject) => {
995
+ const child = spawn(process.execPath, [path.join(fixture.hooks, 'privacy-block.cjs')], { cwd: fixture.root });
996
+ let stdout = '', stderr = '';
997
+ child.stdout.on('data', chunk => { stdout += chunk; });
998
+ child.stderr.on('data', chunk => { stderr += chunk; });
999
+ child.on('error', reject);
1000
+ child.on('close', status => resolve({ status, stdout, stderr }));
1001
+ child.stdin.end(JSON.stringify(fixture.payload(file)));
1002
+ });
1003
+ }
1004
+
1005
+ for (const [label, prompt] of [['y', 'y'], ['yes', 'yes'], ['title-case Vietnamese', 'Có'],
1006
+ ['padded y', ' y '], ['decomposed Vietnamese', 'co\u0301'], ['uppercase Vietnamese', 'CÓ'], ['terminal newline', 'Y\r\n']]) {
1007
+ test(`Codex short privacy approval accepts ${label} and reports granted paths`, () => shortPrivacyFixture(f => {
1008
+ const denied = f.block();
1009
+ const requestId = f.id(denied);
1010
+ const sessionHash = crypto.createHash('sha256').update(f.session).digest('hex');
1011
+ assert.deepEqual(f.files('short-'), [`short-${sessionHash}-${requestId}.json`]);
1012
+ assert.match(denied.stdout, /y \(or yes \/ có\)/);
1013
+ const result = f.submit(prompt);
1014
+ assert.equal(result.status, 0, result.stderr);
1015
+ assert.match(result.stdout, /User approved/);
1016
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1017
+ assert.equal(f.files('token-').length, 1);
1018
+ assert.equal(f.files('short-').length, 0);
1019
+ assert.equal(f.block().stdout, '');
1020
+ assert.equal(f.block().stdout, '', 'the one-hour grant remains reusable');
1021
+ }));
1022
+ }
1023
+
1024
+ test('Codex short privacy approval is cancelled by an intervening prompt', () => shortPrivacyFixture(f => {
1025
+ f.id(f.block());
1026
+ assert.equal(f.submit('ok làm đi').status, 0);
1027
+ const result = f.submit('y');
1028
+ assert.equal(result.status, 0, result.stderr);
1029
+ assert.doesNotMatch(result.stdout, /User approved/);
1030
+ assert.equal(f.files('token-').length, 0);
1031
+ assert.equal(f.files('short-').length, 0);
1032
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1033
+ }));
1034
+
1035
+ test('Codex short privacy approval stays in its session and requires pending state', () => shortPrivacyFixture(f => {
1036
+ assert.equal(f.submit('y').status, 0);
1037
+ assert.equal(f.files('token-').length, 0);
1038
+ f.id(f.block());
1039
+ assert.doesNotMatch(f.submit('y', 'another-session').stdout, /User approved/);
1040
+ assert.equal(f.files('token-').length, 0);
1041
+ assert.match(f.submit('y').stdout, /User approved/);
1042
+ }));
1043
+
1044
+ for (const [label, prompt] of [['extra Vietnamese text', 'y nhé'], ['extra English text', 'yes please'],
1045
+ ['repeated letter', 'yy'], ['fullwidth letter', 'y'], ['zero-width suffix', 'y\u200b']]) {
1046
+ test(`Codex short privacy approval rejects ${label}`, () => shortPrivacyFixture(f => {
1047
+ f.id(f.block());
1048
+ const result = f.submit(prompt);
1049
+ assert.equal(result.status, 0, result.stderr);
1050
+ assert.doesNotMatch(result.stdout, /User approved/);
1051
+ assert.equal(f.files('token-').length, 0);
1052
+ assert.equal(f.files('short-').length, 0);
1053
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1054
+ }));
1055
+ }
1056
+
1057
+ test('Codex short privacy approval refuses two requests while the long phrase selects one', () => shortPrivacyFixture(f => {
1058
+ const first = f.id(f.block());
1059
+ f.id(f.block('credentials.json'));
1060
+ const result = f.submit('y');
1061
+ assert.equal(result.status, 0, result.stderr);
1062
+ assert.doesNotMatch(result.stdout, /User approved/);
1063
+ assert.equal(f.files('token-').length, 0);
1064
+ assert.equal(f.files('short-').length, 0);
1065
+ const exact = f.submit(`APPROVE CAFEKIT PRIVACY ${first}`);
1066
+ assert.match(exact.stdout, /User approved/);
1067
+ assert.match(JSON.parse(exact.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1068
+ assert.equal(f.block().stdout, '');
1069
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1070
+ }));
1071
+
1072
+ test('parallel Codex privacy blocks preserve both markers and cannot be approved by y', () => shortPrivacyFixture(async f => {
1073
+ const results = await Promise.all([asyncPrivacyBlock(f, '.env'), asyncPrivacyBlock(f, 'credentials.json')]);
1074
+ assert.notEqual(f.id(results[0]), f.id(results[1]));
1075
+ assert.equal(f.files('short-').length, 2);
1076
+ assert.equal(f.submit('y').status, 0);
1077
+ assert.equal(f.files('token-').length, 0);
1078
+ assert.equal(f.files('short-').length, 0);
1079
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1080
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1081
+ }));
1082
+
1083
+ test('repeated Codex blocks for the same path set reuse the request and short marker', () => shortPrivacyFixture(f => {
1084
+ const first = f.id(f.block('.env credentials.json'));
1085
+ assert.equal(f.id(f.block('credentials.json .env')), first);
1086
+ assert.equal(f.files('pending-').length, 1);
1087
+ assert.equal(f.files('short-').length, 1);
1088
+ assert.match(f.submit('y').stdout, /User approved/);
1089
+ assert.equal(f.block('.env credentials.json').stdout, '');
1090
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1091
+ }));
1092
+
1093
+ test('a later Codex block restores the short marker for the same pending request', () => shortPrivacyFixture(f => {
1094
+ const requestId = f.id(f.block());
1095
+ assert.equal(f.submit('ok làm đi').status, 0);
1096
+ assert.equal(f.files('short-').length, 0);
1097
+ assert.equal(f.id(f.block()), requestId);
1098
+ assert.equal(f.files('pending-').length, 1);
1099
+ assert.equal(f.files('short-').length, 1);
1100
+ assert.match(f.submit('y').stdout, /User approved/);
1101
+ assert.equal(f.block().stdout, '');
1102
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1103
+ }));
1104
+
1105
+ test('an immediate repeated Codex block keeps one short marker without an exclusive-write error', () => shortPrivacyFixture(f => {
1106
+ const requestId = f.id(f.block());
1107
+ assert.equal(f.id(f.block()), requestId);
1108
+ assert.equal(f.files('pending-').length, 1);
1109
+ assert.equal(f.files('short-').length, 1);
1110
+ assert.match(f.submit('y').stdout, /User approved/);
1111
+ assert.equal(f.block().stdout, '');
1112
+ }));
1113
+
1114
+ test('a Codex prompt error after claiming markers cancels the short approval', () => shortPrivacyFixture(f => {
1115
+ f.id(f.block());
1116
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-fault-'));
1117
+ try {
1118
+ const preload = path.join(outside, 'fault.cjs');
1119
+ 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`);
1120
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1121
+ assert.equal(failed.status, 0, failed.stderr);
1122
+ assert.match(failed.stdout, /failed safely/);
1123
+ assert.equal(f.files('short-').length, 0);
1124
+ assert.equal(f.files('token-').length, 0);
1125
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1126
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1127
+ }));
1128
+
1129
+ test('a Codex post-claim error cancels a marker created during prompt processing', () => shortPrivacyFixture(f => {
1130
+ f.id(f.block());
1131
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-post-claim-fault-'));
1132
+ try {
1133
+ const preload = path.join(outside, 'fault.cjs');
1134
+ fs.writeFileSync(preload, `const assert = require('node:assert/strict');
1135
+ const state = require(${JSON.stringify(path.join(f.hooks, 'lib/privacy-state.cjs'))});
1136
+ const claim = state.claimShortMarkers;
1137
+ let claimed = false;
1138
+ state.claimShortMarkers = (...args) => {
1139
+ const result = claim(...args);
1140
+ if (!claimed) { assert.equal(result.ok, true); assert.equal(result.requestIds.length, 1); claimed = true; }
1141
+ return result;
1142
+ };
1143
+ state.cleanup = () => {
1144
+ assert.equal(claimed, true);
1145
+ state.createPending(${JSON.stringify({ projectRoot: f.root, sessionCwd: f.root, sessionId: f.session, filePath: 'credentials.json' })});
1146
+ throw new Error('injected processing error after marker claim');
1147
+ };\n`);
1148
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1149
+ assert.equal(failed.status, 0, failed.stderr);
1150
+ assert.match(failed.stdout, /failed safely/);
1151
+ assert.equal(f.files('pending-').length, 2);
1152
+ assert.equal(f.files('short-').length, 0);
1153
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1154
+ assert.equal(f.files('token-').length, 0);
1155
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1156
+ }));
1157
+
1158
+ test('a corrupt Codex short marker cannot grant permission even with valid pending state', () => shortPrivacyFixture(f => {
1159
+ f.id(f.block());
1160
+ const markers = f.files('short-');
1161
+ assert.equal(markers.length, 1);
1162
+ fs.writeFileSync(path.join(f.dir, markers[0]), '{');
1163
+ const result = f.submit('y');
1164
+ assert.equal(result.status, 0, result.stderr);
1165
+ assert.doesNotMatch(result.stdout, /User approved/);
1166
+ assert.equal(f.files('token-').length, 0);
1167
+ assert.equal(f.files('short-').length, 0);
1168
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1169
+ }));
1170
+
1171
+ for (const unlinkFailure of [false, true]) {
1172
+ test(`an unclaimable Codex short marker is cancelled after ${unlinkFailure ? 'rename and unlink errors' : 'a rename error'}`, () => shortPrivacyFixture(f => {
1173
+ f.id(f.block());
1174
+ assert.equal(f.files('short-').length, 1);
1175
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-rename-fault-'));
1176
+ try {
1177
+ const preload = path.join(outside, 'fault.cjs');
1178
+ const prefix = JSON.stringify(path.join(fs.realpathSync(f.dir), 'short-'));
1179
+ 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`
1180
+ + (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` : ''));
1181
+ const result = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1182
+ assert.equal(result.status, 0, result.stderr);
1183
+ assert.doesNotMatch(result.stdout, /User approved/);
1184
+ assert.equal(f.files('token-').length, 0);
1185
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1186
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1187
+ assert.equal(f.files('token-').length, 0);
1188
+ assert.equal(f.files('short-').length, 0);
1189
+ }));
1190
+ }
1191
+
1192
+ test('Codex compact clears pending requests and short markers only for its session', () => shortPrivacyFixture(f => {
1193
+ f.id(f.block());
1194
+ f.id(f.block('credentials.json', 'other-session'));
1195
+ const result = runHook(path.join(f.hooks, 'session.cjs'), f.root,
1196
+ { source: 'compact', session_id: f.session, cwd: f.root, hook_event_name: 'SessionStart' });
1197
+ assert.equal(result.status, 0, result.stderr);
1198
+ const prefix = `short-${crypto.createHash('sha256').update(f.session).digest('hex')}-`;
1199
+ assert.equal(f.files(prefix).length, 0);
1200
+ assert.equal(f.files('short-').length, 1);
1201
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1202
+ assert.equal(f.files('token-').length, 0);
1203
+ assert.match(f.submit('y', 'other-session').stdout, /User approved/);
1204
+ }));
1205
+
1206
+ test('Codex short approval cannot grant an expired pending request', () => shortPrivacyFixture(f => {
1207
+ const id = f.id(f.block());
1208
+ const file = path.join(f.dir, `pending-${id}.json`);
1209
+ const pending = JSON.parse(fs.readFileSync(file, 'utf8'));
1210
+ pending.expiresAt = Date.now() - 1;
1211
+ fs.writeFileSync(file, JSON.stringify(pending));
1212
+ assert.equal(f.submit('y').status, 0);
1213
+ assert.equal(f.files('token-').length, 0);
1214
+ assert.equal(f.files('short-').length, 0);
1215
+ }));
1216
+
1217
+ test('Codex cleanup removes expired short markers', () => shortPrivacyFixture(f => {
1218
+ f.id(f.block());
1219
+ const markers = f.files('short-');
1220
+ assert.equal(markers.length, 1);
1221
+ const file = path.join(f.dir, markers[0]);
1222
+ const marker = JSON.parse(fs.readFileSync(file, 'utf8'));
1223
+ marker.expiresAt = Date.now() - 1;
1224
+ fs.writeFileSync(file, JSON.stringify(marker));
1225
+ require(path.join(f.hooks, 'lib/privacy-state.cjs')).cleanup(f.root);
1226
+ assert.equal(f.files('short-').length, 0);
1227
+ }));
1228
+
1229
+ test('Codex long privacy approval still works after an intervening prompt and reports paths', () => shortPrivacyFixture(f => {
1230
+ const id = f.id(f.block());
1231
+ assert.equal(f.submit('ok làm đi').status, 0);
1232
+ assert.equal(f.files('short-').length, 0);
1233
+ const result = f.submit(`APPROVE CAFEKIT PRIVACY ${id}`);
1234
+ assert.equal(result.status, 0, result.stderr);
1235
+ assert.match(result.stdout, /User approved/);
1236
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1237
+ assert.equal(f.block().stdout, '');
1238
+ }));
1239
+
459
1240
  test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
460
1241
  inHookFixture((root, hooks) => {
461
1242
  const nested = path.join(root, 'packages', 'app');
@@ -647,15 +1428,17 @@ test('Codex inspect hook scans native shell commands and structured paths', () =
647
1428
  for (const command of [
648
1429
  'rg secret node_modules',
649
1430
  "sed -n '1,20p' dist/bundle.js",
650
- "rg --glob '**/*' symbol src"
1431
+ "rg --glob '**/*' symbol ."
651
1432
  ]) {
652
1433
  const result = runHook(inspect, root, payload(command));
653
1434
  assert.equal(result.status, 2, command);
654
1435
  assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
655
1436
  }
656
1437
 
657
- const normal = runHook(inspect, root, payload('rg symbol src packages'));
658
- assert.equal(normal.status, 0);
1438
+ for (const command of ['rg symbol src packages', "rg --glob '**/*' symbol src"]) {
1439
+ const normal = runHook(inspect, root, payload(command));
1440
+ assert.equal(normal.status, 0);
1441
+ }
659
1442
 
660
1443
  const structured = runHook(inspect, root, {
661
1444
  ...payload(''),
@@ -929,6 +1712,22 @@ test('empty or forged Codex hook authority fails closed', () => {
929
1712
  });
930
1713
  });
931
1714
 
1715
+ test('Codex Stop omits secret-related assistant messages from saved state', () => {
1716
+ inHookFixture((root, hooks) => {
1717
+ const message = 'API key handling was discussed in this synthetic test.';
1718
+ const result = runHook(path.join(hooks, 'state.cjs'), root, {
1719
+ hook_event_name: 'Stop', session_id: 'secret-summary', last_assistant_message: message,
1720
+ });
1721
+ assert.equal(result.status, 0);
1722
+ const dir = codexStateDir(root, 'secret-summary');
1723
+ for (const name of ['data.json', 'latest.md']) {
1724
+ const content = fs.readFileSync(path.join(dir, name), 'utf8');
1725
+ assert.ok(content.includes('[omitted: possible secret]'));
1726
+ assert.ok(!content.includes(message));
1727
+ }
1728
+ });
1729
+ });
1730
+
932
1731
  test('Codex state hook persists direct native event fields at project root', () => {
933
1732
  inHookFixture((root, hooks) => {
934
1733
  const nested = path.join(root, 'packages', 'app');
@@ -1362,14 +2161,18 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
1362
2161
  { command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
1363
2162
  { command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
1364
2163
  { command: 'openssl rsa -in server.key', tool: 'Bash' },
2164
+ { command: 'less "$HOME/.env"', tool: 'Bash' },
2165
+ { command: 'cat $(echo .env)', tool: 'Bash' },
2166
+ { command: 'cat `echo .env`', tool: 'Bash' },
1365
2167
  ];
1366
2168
  for (const { command, tool } of denied) {
1367
2169
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
1368
2170
  assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
1369
2171
  }
1370
- // Accepted gap: the target of `$FILE` is unknowable without running the
1371
- // shell, and every denial here costs a round trip through a one-shot
1372
- // approval prompt. `less "$HOME/.env"` is the edge of that decision.
2172
+ // Accepted gap: the target of a bare `$FILE` is unknowable without running
2173
+ // the shell, and every denial here costs a round trip through a one-shot
2174
+ // approval prompt. A sensitive name spelled out next to the expansion
2175
+ // (`"$HOME/.env"`, `$(echo .env)`) is not indirection and is denied above.
1373
2176
  const allowed = [
1374
2177
  { command: "echo $VAR", tool: 'exec_command' },
1375
2178
  { command: 'echo "$HOME"', tool: 'Bash' },
@@ -1398,11 +2201,8 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
1398
2201
  { command: 'cat "$FILE"', tool: 'exec_command' },
1399
2202
  { command: 'cat ${FILE}', tool: 'Bash' },
1400
2203
  { command: 'cat $1', tool: 'exec_command' },
1401
- { command: 'cat $(echo .env)', tool: 'Bash' },
1402
- { command: 'cat `echo .env`', tool: 'Bash' },
1403
2204
  { command: 'head $SECRET', tool: 'exec_command' },
1404
2205
  { command: 'tail ${MY_FILE}', tool: 'Bash' },
1405
- { command: 'less "$HOME/.env"', tool: 'Bash' },
1406
2206
  { command: 'cat README.md $EXTRA', tool: 'Bash' },
1407
2207
  { command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
1408
2208
  { command: 'cat README.md | head $FILE', tool: 'exec_command' },