@haposoft/cafekit 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +370 -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__/codex-hook-runtime-fixture.cjs +26 -0
  7. package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
  8. package/bin/__tests__/codex-hooks.test.js +87 -85
  9. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  10. package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
  11. package/bin/__tests__/codex-native.test.js +136 -2298
  12. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  13. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  14. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  15. package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
  16. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  17. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  18. package/bin/__tests__/codex-skill-projections.test.js +101 -0
  19. package/bin/__tests__/develop-contract.test.js +100 -874
  20. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  21. package/bin/__tests__/migrate-apply.test.js +373 -0
  22. package/bin/__tests__/migrate-cli.test.js +152 -0
  23. package/bin/__tests__/migrate-merged.test.js +269 -0
  24. package/bin/__tests__/migrate-plan.test.js +271 -0
  25. package/bin/__tests__/omp-bridge.test.js +81 -5
  26. package/bin/__tests__/omp-hooks.test.js +24 -0
  27. package/bin/__tests__/orca-session.test.js +2 -16
  28. package/bin/__tests__/package-inventory.test.js +228 -2044
  29. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  30. package/bin/__tests__/privacy-paths.test.js +184 -0
  31. package/bin/__tests__/secret-scanner.test.js +73 -0
  32. package/bin/__tests__/skill-routing-source.test.js +6 -0
  33. package/bin/__tests__/toaa-skill.test.js +195 -0
  34. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  35. package/bin/__tests__/validator-grounding.test.js +0 -198
  36. package/bin/__tests__/vibe-run.test.js +215 -0
  37. package/bin/__tests__/vibe-skill.test.js +548 -0
  38. package/bin/__tests__/website-docs.test.js +58 -0
  39. package/bin/install.js +99 -1
  40. package/bin/lib/backup.js +43 -24
  41. package/bin/lib/codex-install.js +12 -3
  42. package/bin/lib/context.js +5 -0
  43. package/bin/lib/lock.js +98 -18
  44. package/bin/lib/managed-writer.js +16 -10
  45. package/bin/lib/migrate-apply.js +202 -0
  46. package/bin/lib/migrate-merged.js +342 -0
  47. package/bin/lib/migrate-plan.js +419 -0
  48. package/bin/lib/plugin-inventory.js +33 -0
  49. package/bin/phases/claude-runtime.js +5 -3
  50. package/bin/phases/codex-hooks.js +38 -0
  51. package/bin/phases/codex-runtime.js +106 -74
  52. package/bin/phases/copy-payload.js +49 -1
  53. package/bin/phases/omp-runtime.js +4 -26
  54. package/bin/phases/root-config.js +5 -2
  55. package/package.json +1 -1
  56. package/src/claude/CLAUDE.md +1 -1
  57. package/src/claude/agents/advisor.md +55 -0
  58. package/src/claude/agents/debugger.md +2 -3
  59. package/src/claude/agents/deployer.md +2 -1
  60. package/src/claude/agents/docs-keeper.md +12 -0
  61. package/src/claude/agents/inspector.md +1 -1
  62. package/src/claude/agents/project-manager.md +2 -2
  63. package/src/claude/agents/researcher.md +0 -1
  64. package/src/claude/agents/spec-maker.md +4 -1
  65. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  66. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  67. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  68. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  69. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
  70. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  71. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
  72. package/src/claude/hooks/__tests__/state.test.js +21 -0
  73. package/src/claude/hooks/agent.cjs +5 -2
  74. package/src/claude/hooks/inspect-block.cjs +3 -1
  75. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  76. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  77. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  78. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  79. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  80. package/src/claude/hooks/plugin-context.cjs +80 -0
  81. package/src/claude/hooks/privacy-block.cjs +2 -1
  82. package/src/claude/hooks/rules.cjs +5 -2
  83. package/src/claude/hooks/session.cjs +4 -1
  84. package/src/claude/hooks/spec-state.cjs +5 -4
  85. package/src/claude/hooks/state.cjs +27 -1
  86. package/src/claude/migration-manifest.json +7 -1
  87. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  88. package/src/claude/plugin/core.md +71 -0
  89. package/src/claude/rules/manage-docs.md +1 -2
  90. package/src/claude/rules/review-audit-self-decision.md +1 -1
  91. package/src/claude/rules/skill-workflow-routing.md +1 -1
  92. package/src/claude/rules/state-sync.md +14 -0
  93. package/src/claude/rules/workflow.md +1 -1
  94. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  95. package/src/claude/scripts/provenance.cjs +40 -16
  96. package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
  97. package/src/claude/scripts/setup-statusline.cjs +88 -0
  98. package/src/claude/skills/adv/SKILL.md +139 -0
  99. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  100. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  101. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  102. package/src/claude/skills/code-review/SKILL.md +4 -1
  103. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  104. package/src/claude/skills/develop/SKILL.md +11 -3
  105. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  106. package/src/claude/skills/fix/SKILL.md +2 -2
  107. package/src/claude/skills/git/SKILL.md +4 -2
  108. package/src/claude/skills/git/references/merge-pr.md +125 -0
  109. package/src/claude/skills/specs/SKILL.md +2 -2
  110. package/src/claude/skills/specs/references/review.md +1 -1
  111. package/src/claude/skills/specs/references/templates.md +2 -0
  112. package/src/claude/skills/sync/SKILL.md +4 -0
  113. package/src/claude/skills/toaa/SKILL.md +82 -0
  114. package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
  115. package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
  116. package/src/claude/skills/vibe/SKILL.md +69 -0
  117. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  118. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  119. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  120. package/src/codex/AGENTS.md +1 -1
  121. package/src/codex/hooks/inspect-block.cjs +7 -4
  122. package/src/codex/hooks/lib/hook-context.cjs +119 -4
  123. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  124. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  125. package/src/codex/hooks/lib/state-store.cjs +31 -6
  126. package/src/codex/hooks/precompact.cjs +9 -8
  127. package/src/codex/hooks/privacy-approval.cjs +2 -2
  128. package/src/codex/hooks/privacy-block.cjs +5 -4
  129. package/src/codex/hooks/rules.cjs +12 -6
  130. package/src/codex/hooks/session.cjs +22 -10
  131. package/src/codex/plugin/core.md +76 -0
  132. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  133. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  134. package/src/codex/scripts/setup-agents.cjs +124 -0
  135. package/src/common/AGENTS.md +1 -1
  136. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  137. package/src/omp/hooks/privacy-block.cjs +2 -1
  138. package/bin/__tests__/codex-research-projection.test.js +0 -70
  139. package/bin/__tests__/codex-specs-projection.test.js +0 -76
  140. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
  141. package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
@@ -79,17 +79,6 @@ function addUserHooks(root) {
79
79
  fs.writeFileSync(hooksPath(root), `${JSON.stringify(config, null, 2)}\n`);
80
80
  }
81
81
 
82
- test('a fresh install registers the precompact capture hook', () => {
83
- inTempProject((root) => {
84
- assert.ok(managedScripts().PreCompact?.includes('precompact.cjs'));
85
- const result = install(root);
86
- assert.equal(result.status, 0, result.stderr);
87
- const scripts = (readHooks(root).hooks.PreCompact || []).flatMap(group => group.hooks || []).map(handler => hookScript(handler.command));
88
- assert.ok(scripts.includes('precompact.cjs'));
89
- assert.ok(fs.existsSync(path.join(root, '.codex/hooks/precompact.cjs')));
90
- });
91
- });
92
-
93
82
  test('a fresh install registers every CafeKit hook under its own event', () => {
94
83
  inTempProject((root) => {
95
84
  const result = install(root);
@@ -103,7 +92,10 @@ test('a fresh install registers every CafeKit hook under its own event', () => {
103
92
  .filter(Boolean);
104
93
  for (const script of scripts) {
105
94
  assert.ok(registered.includes(script), `${script} missing from ${eventName}`);
95
+ assert.ok(fs.existsSync(path.join(root, '.codex/hooks', script)), `${script} not installed`);
106
96
  }
97
+ const template = JSON.parse(fs.readFileSync(TEMPLATE, 'utf8')).hooks[eventName];
98
+ assert.deepEqual(installed.hooks[eventName].map((group) => group.matcher), template.map((group) => group.matcher));
107
99
  }
108
100
  });
109
101
  });
@@ -119,6 +111,11 @@ test('a reinstall preserves user-added hooks instead of overwriting them', () =>
119
111
  const commands = allCommands(readHooks(root));
120
112
  assert.ok(commands.includes(USER_STOP_COMMAND), 'user Stop hook was erased by the reinstall');
121
113
  assert.ok(commands.includes(USER_PRETOOL_COMMAND), 'user PreToolUse hook was erased by the reinstall');
114
+ const after = readHooks(root).hooks;
115
+ const userGroup = (event, command) => after[event].find((group) => group.hooks.some((handler) => handler.command === command));
116
+ assert.deepEqual(userGroup('Stop', USER_STOP_COMMAND), { matcher: '*', hooks: [{ type: 'command', command: USER_STOP_COMMAND }] });
117
+ assert.deepEqual(userGroup('PreToolUse', USER_PRETOOL_COMMAND), { matcher: 'Bash', hooks: [{ type: 'command', command: USER_PRETOOL_COMMAND }] });
118
+ assert.doesNotMatch(second.stdout + second.stderr, /matcher preserved/);
122
119
  });
123
120
  });
124
121
 
@@ -7,6 +7,7 @@ const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const { spawnSync, spawn } = require('node:child_process');
9
9
  const { test } = require('node:test');
10
+ const { installCodexHookRuntime } = require('./codex-hook-runtime-fixture.cjs');
10
11
 
11
12
  const OLD_INSPECTION_MATCHER = 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell';
12
13
  const NEW_INSPECTION_MATCHER = 'Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent';
@@ -44,31 +45,6 @@ test('upgrade preserves a mixed user group and warns', () => matcherFixture(f =>
44
45
  assert.ok(f.read().hooks.PreToolUse[0].hooks.some(h => h.command === 'echo user-owned'));
45
46
  assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
46
47
  }));
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
48
  test('upgrade preserves a user-selected managed matcher and warns', () => matcherFixture(f => {
73
49
  f.config.hooks.PreToolUse[0].matcher = 'Read|Grep'; f.save(); f.merge();
74
50
  assert.equal(f.read().hooks.PreToolUse[0].matcher, 'Read|Grep');
@@ -93,12 +69,18 @@ const { stateDir: codexStateDir } = require(
93
69
  path.join(CODEX_HOOKS, 'lib', 'state-store.cjs')
94
70
  );
95
71
 
72
+ // A hook child that never exits must fail its test, not hang the whole suite.
73
+ const HOOK_TIMEOUT_MS = 30000;
74
+
96
75
  function runHook(file, cwd, payload) {
97
- return spawnSync(process.execPath, [file], {
76
+ const result = spawnSync(process.execPath, [file], {
98
77
  cwd,
99
78
  input: JSON.stringify(payload),
100
- encoding: 'utf8'
79
+ encoding: 'utf8',
80
+ timeout: HOOK_TIMEOUT_MS
101
81
  });
82
+ assert.equal(result.error, undefined, `${path.basename(file)} did not exit: ${result.error?.message}`);
83
+ return result;
102
84
  }
103
85
 
104
86
  function runtimeContext(root, feature = 'auth', session = 'session-a') {
@@ -145,26 +127,7 @@ function inHookFixture(run, options = {}) {
145
127
  const hooks = path.join(root, '.codex', 'hooks');
146
128
  let deferred = false;
147
129
  try {
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
- );
156
- fs.copyFileSync(
157
- path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
158
- path.join(hooks, 'lib/privacy-command-analysis.cjs'),
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
- );
164
- fs.copyFileSync(
165
- path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
166
- path.join(hooks, 'lib/runtime-path-safety.cjs'),
167
- );
130
+ installCodexHookRuntime(root);
168
131
  fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
169
132
  if (options.policy !== 'missing') {
170
133
  if (options.policy === 'malformed') {
@@ -216,15 +179,7 @@ function inNoCommitCodexFixture(run) {
216
179
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-no-commit-'));
217
180
  const hooks = path.join(root, '.codex/hooks');
218
181
  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'));
182
+ installCodexHookRuntime(root);
228
183
  const scripts = path.join(root, '.codex/scripts');
229
184
  fs.mkdirSync(scripts, { recursive: true });
230
185
  for (const name of ['workflow-policy.cjs', 'provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs']) {
@@ -539,7 +494,7 @@ for (const method of ['spawnSync', 'execFileSync']) {
539
494
  }
540
495
  `);
541
496
  const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
542
- cwd: root, encoding: 'utf8',
497
+ cwd: root, encoding: 'utf8', timeout: HOOK_TIMEOUT_MS,
543
498
  env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
544
499
  input: JSON.stringify({ cwd: root, featureName: 'auth', hook_event_name: 'UserPromptSubmit',
545
500
  session_id: 'one-snapshot', prompt: 'Continue specs/auth' }),
@@ -754,7 +709,7 @@ test('Codex secret guardrail runs from source with its shared keyword shim', ()
754
709
  delete env.CLAUDE_PROJECT_DIR;
755
710
  delete env.PROJECT_ROOT;
756
711
  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.' }),
712
+ cwd: PACKAGE_ROOT, env, encoding: 'utf8', timeout: HOOK_TIMEOUT_MS, input: JSON.stringify({ prompt: 'Explain secret handling.' }),
758
713
  });
759
714
  assert.equal(result.status, 0);
760
715
  assert.equal(result.stderr, '');
@@ -804,6 +759,37 @@ test('Codex compact clears its pending privacy request even with an invalid spec
804
759
  });
805
760
  });
806
761
 
762
+ // Two privacy blocks racing on a fresh project both see no `.privacy` directory;
763
+ // the second mkdir then meets the directory the first one made.
764
+ test('Codex privacy records a request when another hook creates its state directory first', () => {
765
+ inHookFixture((root, hooks) => {
766
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
767
+ fs.mkdirSync(path.join(hooks, '.privacy'), { mode: 0o700 });
768
+ const existsSync = fs.existsSync;
769
+ fs.existsSync = target => (path.basename(String(target)) === '.privacy' ? false : existsSync(target));
770
+ let request;
771
+ try {
772
+ request = privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'race-session', filePaths: ['.env'] });
773
+ } finally {
774
+ fs.existsSync = existsSync;
775
+ }
776
+ assert.match(request.requestId, /^[a-f0-9]{24}$/);
777
+ });
778
+ });
779
+
780
+ test('Codex privacy denies when its state path is a regular file', () => {
781
+ inHookFixture((root, hooks) => {
782
+ fs.writeFileSync(path.join(hooks, '.privacy'), '');
783
+ const result = runHook(path.join(hooks, 'privacy-block.cjs'), root, {
784
+ cwd: root, session_id: 'file-state', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'cat .env' },
785
+ });
786
+ assert.equal(result.status, 0);
787
+ const output = JSON.parse(result.stdout).hookSpecificOutput;
788
+ assert.equal(output.permissionDecision, 'deny');
789
+ assert.match(output.permissionDecisionReason, /could not be evaluated safely/);
790
+ });
791
+ });
792
+
807
793
  test('Codex compact releases only its own rules reservation', () => {
808
794
  inHookFixture((root, hooks) => {
809
795
  fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
@@ -824,7 +810,7 @@ test('Codex compact releases only its own rules reservation', () => {
824
810
  test('Codex precompact ignores empty stdin without recording a crash', () => {
825
811
  inHookFixture((root, hooks) => {
826
812
  const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
827
- cwd: root, input: '', encoding: 'utf8',
813
+ cwd: root, input: '', encoding: 'utf8', timeout: HOOK_TIMEOUT_MS,
828
814
  });
829
815
  assert.equal(result.status, 0);
830
816
  assert.equal(result.stderr, '');
@@ -869,7 +855,8 @@ function sessionWithoutUpdateNetwork(root, hooks, session_id) {
869
855
  throw new Error('network disabled in update test');
870
856
  };\n`);
871
857
  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 }),
858
+ cwd: root, encoding: 'utf8', timeout: HOOK_TIMEOUT_MS,
859
+ input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
873
860
  });
874
861
  assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
875
862
  return result;
@@ -945,7 +932,7 @@ function shortPrivacyFixture(check) {
945
932
  hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
946
933
  const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
947
934
  const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
948
- [path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
935
+ [path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env, timeout: HOOK_TIMEOUT_MS,
949
936
  input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
950
937
  const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
951
938
  const id = result => {
@@ -1651,6 +1638,7 @@ test('empty or forged Codex hook authority fails closed', () => {
1651
1638
  env: { ...process.env },
1652
1639
  input,
1653
1640
  encoding: 'utf8',
1641
+ timeout: HOOK_TIMEOUT_MS,
1654
1642
  });
1655
1643
  assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
1656
1644
  assert.equal(result.stderr, '');
@@ -2130,14 +2118,18 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
2130
2118
  { command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
2131
2119
  { command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
2132
2120
  { command: 'openssl rsa -in server.key', tool: 'Bash' },
2121
+ { command: 'less "$HOME/.env"', tool: 'Bash' },
2122
+ { command: 'cat $(echo .env)', tool: 'Bash' },
2123
+ { command: 'cat `echo .env`', tool: 'Bash' },
2133
2124
  ];
2134
2125
  for (const { command, tool } of denied) {
2135
2126
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
2136
2127
  assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
2137
2128
  }
2138
- // Accepted gap: the target of `$FILE` is unknowable without running the
2139
- // shell, and every denial here costs a round trip through a one-shot
2140
- // approval prompt. `less "$HOME/.env"` is the edge of that decision.
2129
+ // Accepted gap: the target of a bare `$FILE` is unknowable without running
2130
+ // the shell, and every denial here costs a round trip through a one-shot
2131
+ // approval prompt. A sensitive name spelled out next to the expansion
2132
+ // (`"$HOME/.env"`, `$(echo .env)`) is not indirection and is denied above.
2141
2133
  const allowed = [
2142
2134
  { command: "echo $VAR", tool: 'exec_command' },
2143
2135
  { command: 'echo "$HOME"', tool: 'Bash' },
@@ -2166,11 +2158,8 @@ test('Codex privacy denies sensitive names it can resolve, allows pure indirecti
2166
2158
  { command: 'cat "$FILE"', tool: 'exec_command' },
2167
2159
  { command: 'cat ${FILE}', tool: 'Bash' },
2168
2160
  { command: 'cat $1', tool: 'exec_command' },
2169
- { command: 'cat $(echo .env)', tool: 'Bash' },
2170
- { command: 'cat `echo .env`', tool: 'Bash' },
2171
2161
  { command: 'head $SECRET', tool: 'exec_command' },
2172
2162
  { command: 'tail ${MY_FILE}', tool: 'Bash' },
2173
- { command: 'less "$HOME/.env"', tool: 'Bash' },
2174
2163
  { command: 'cat README.md $EXTRA', tool: 'Bash' },
2175
2164
  { command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
2176
2165
  { command: 'cat README.md | head $FILE', tool: 'exec_command' },
@@ -2358,7 +2347,37 @@ test('Codex hook-context canonicalizes symlink project root (regression)', () =>
2358
2347
  });
2359
2348
  });
2360
2349
 
2361
- test('Codex resolver and state reject symlink escape', () => {
2350
+ test('Codex session state refuses a symlinked state path and writes nothing outside the project', () => {
2351
+ inHookFixture((root, hooks) => {
2352
+ const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
2353
+ const sessionId = 'sess-symlink';
2354
+ const dir = stateStore.stateDir(root, sessionId);
2355
+ const sessionState = path.dirname(dir);
2356
+ // A repository can commit either the shared state folder or one session folder as a link.
2357
+ for (const link of [sessionState, dir]) {
2358
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-state-outside-'));
2359
+ try {
2360
+ fs.rmSync(sessionState, { recursive: true, force: true });
2361
+ fs.mkdirSync(path.dirname(link), { recursive: true });
2362
+ fs.symlinkSync(outside, link);
2363
+ let called = false;
2364
+ const ok = stateStore.withStateLock(root, sessionId, (stateDir) => {
2365
+ called = true;
2366
+ stateStore.writeData(stateDir, { todos: [] });
2367
+ });
2368
+ assert.equal(ok, false, `${path.relative(root, link)} link must be refused`);
2369
+ assert.equal(called, false);
2370
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing may be written outside the project');
2371
+ } finally {
2372
+ fs.rmSync(sessionState, { recursive: true, force: true });
2373
+ fs.rmSync(outside, { recursive: true, force: true });
2374
+ }
2375
+ }
2376
+ assert.equal(stateStore.withStateLock(root, sessionId, () => {}), true, 'a plain state folder still locks');
2377
+ });
2378
+ });
2379
+
2380
+ test('Codex resolver rejects symlink escape', () => {
2362
2381
  inHookFixture((root, hooks) => {
2363
2382
  const specUtils = require(path.join(hooks, 'lib', 'spec-utils.cjs'));
2364
2383
  const resolverPath = path.join(root, '.codex', 'scripts', 'spec-resolver.cjs');
@@ -2386,23 +2405,6 @@ test('Codex resolver and state reject symlink escape', () => {
2386
2405
  );
2387
2406
  fs.rmSync(outsideScripts, { recursive: true, force: true });
2388
2407
  }
2389
- // State dir symlink escape: create symlink for session-state dir
2390
- const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
2391
- const sessionId = 'sess-symlink';
2392
- const dir = stateStore.stateDir(root, sessionId);
2393
- fs.mkdirSync(path.dirname(dir), { recursive: true });
2394
- // Create a symlink that points outside project
2395
- const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
2396
- try {
2397
- if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
2398
- fs.symlinkSync(outside, dir);
2399
- // withStateLock should handle symlink safely (no crash, no write outside)
2400
- const ok = stateStore.withStateLock(root, sessionId, () => {});
2401
- assert.equal(typeof ok, 'boolean');
2402
- } finally {
2403
- try { fs.unlinkSync(dir); } catch {}
2404
- fs.rmSync(outside, { recursive: true, force: true });
2405
- }
2406
2408
  });
2407
2409
  });
2408
2410
 
@@ -0,0 +1,160 @@
1
+ // Codex ignores the `disable-model-invocation` key that keeps a skill explicit-only on Claude Code. Its own
2
+ // mechanism is `agents/openai.yaml` beside SKILL.md with `policy.allow_implicit_invocation: false`. These cases
3
+ // pin that the Codex install writes that file for every explicit-only skill through the ownership-aware writer
4
+ // (so upgrades and removals stay clean), writes it for no other skill, and that the catalog never lists such a skill.
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const test = require('node:test');
10
+ const { spawnSync } = require('node:child_process');
11
+
12
+ const { PLATFORMS } = require('../lib/context');
13
+ const manifestLib = require('../lib/manifest');
14
+ const { removeSkill } = require('../phases/skill-inventory');
15
+
16
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
17
+ const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
18
+ const SOURCE_SKILLS = path.join(PACKAGE_ROOT, 'src/claude/skills');
19
+ const POLICY = 'policy:\n allow_implicit_invocation: false\n';
20
+
21
+ const explicitOnly = fs.readdirSync(SOURCE_SKILLS)
22
+ .filter((name) => fs.existsSync(path.join(SOURCE_SKILLS, name, 'SKILL.md')))
23
+ .filter((name) => /^disable-model-invocation:\s*(["']?)true\1\s*$/m.test(
24
+ fs.readFileSync(path.join(SOURCE_SKILLS, name, 'SKILL.md'), 'utf8').split(/^---\s*$/m)[1] || ''));
25
+
26
+ function install(root, platform) {
27
+ const result = spawnSync(process.execPath, [INSTALLER, '--platform', platform, '--yes'],
28
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
29
+ assert.equal(result.status, 0, result.stderr || result.stdout);
30
+ return result;
31
+ }
32
+
33
+ function withTempProject(run) {
34
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-policy-'));
35
+ const previous = process.cwd();
36
+ try {
37
+ assert.equal(spawnSync('git', ['-C', root, 'init', '-q']).status, 0);
38
+ process.chdir(root);
39
+ return run(root);
40
+ } finally {
41
+ process.chdir(previous);
42
+ fs.rmSync(root, { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ const policyFile = (root, skill) => path.join(root, '.agents/skills', skill, 'agents/openai.yaml');
47
+ const ownershipKey = (root, file) => path.relative(path.join(root, PLATFORMS.codex.ownership?.recordRoot || PLATFORMS.codex.folder), file).replace(/\\/g, '/');
48
+ const readOwnership = (root) => JSON.parse(fs.readFileSync(path.join(root, PLATFORMS.codex.folder, manifestLib.MANIFEST_FILE), 'utf8'));
49
+ const writeOwnership = (root, data) => fs.writeFileSync(path.join(root, PLATFORMS.codex.folder, manifestLib.MANIFEST_FILE), JSON.stringify(data, null, 2));
50
+
51
+ function catalog(root, script, skillsRoot) {
52
+ const result = spawnSync(process.execPath, [path.join(root, script), '--json', '--root', path.join(root, skillsRoot)],
53
+ { cwd: root, encoding: 'utf8' });
54
+ assert.equal(result.status, 0, result.stderr);
55
+ return JSON.parse(result.stdout);
56
+ }
57
+
58
+ test('the explicit-only skills are adv, toaa and vibe', () => {
59
+ assert.deepEqual(explicitOnly.sort(), ['adv', 'toaa', 'vibe']);
60
+ });
61
+
62
+ test('a Codex install writes the policy file for every explicit-only skill, owned, and for no other skill', () => {
63
+ withTempProject((root) => {
64
+ install(root, 'codex');
65
+ const ownership = readOwnership(root);
66
+ for (const skill of explicitOnly) {
67
+ const file = policyFile(root, skill);
68
+ assert.equal(fs.readFileSync(file, 'utf8'), POLICY, skill);
69
+ assert.equal(ownership.files[ownershipKey(root, file)]?.sha256, manifestLib.hashFile(file), `${skill} policy is not owned`);
70
+ }
71
+ const others = fs.readdirSync(path.join(root, '.agents/skills')).filter((name) => !explicitOnly.includes(name));
72
+ assert.ok(others.length > 10);
73
+ for (const skill of others) assert.equal(fs.existsSync(policyFile(root, skill)), false, skill);
74
+ });
75
+ });
76
+
77
+ test('reinstalling keeps the policy bytes and its ownership record unchanged', () => {
78
+ withTempProject((root) => {
79
+ install(root, 'codex');
80
+ const before = explicitOnly.map((skill) => readOwnership(root).files[ownershipKey(root, policyFile(root, skill))]?.sha256);
81
+ install(root, 'codex');
82
+ const after = explicitOnly.map((skill) => readOwnership(root).files[ownershipKey(root, policyFile(root, skill))]?.sha256);
83
+ assert.deepEqual(after, before);
84
+ for (const skill of explicitOnly) assert.equal(fs.readFileSync(policyFile(root, skill), 'utf8'), POLICY);
85
+ });
86
+ });
87
+
88
+ test('an owned policy file under a skill that dropped the key is removed on upgrade; a user-created one stays', () => {
89
+ withTempProject((root) => {
90
+ install(root, 'codex');
91
+ // An earlier release that marked ask explicit-only would have written and owned this file.
92
+ const owned = policyFile(root, 'ask');
93
+ fs.mkdirSync(path.dirname(owned), { recursive: true });
94
+ fs.writeFileSync(owned, POLICY);
95
+ const data = readOwnership(root);
96
+ data.files[ownershipKey(root, owned)] = { sha256: manifestLib.hashFile(owned), version: 'old' };
97
+ writeOwnership(root, data);
98
+ // A file the user wrote is never ours to delete.
99
+ const userFile = policyFile(root, 'debug');
100
+ fs.mkdirSync(path.dirname(userFile), { recursive: true });
101
+ fs.writeFileSync(userFile, POLICY);
102
+
103
+ install(root, 'codex');
104
+ assert.equal(fs.existsSync(owned), false, 'owned stale policy survived the upgrade');
105
+ assert.equal(readOwnership(root).files[ownershipKey(root, owned)], undefined, 'stale policy stayed in the manifest');
106
+ assert.equal(fs.readFileSync(userFile, 'utf8'), POLICY, 'user-created policy was removed');
107
+ });
108
+ });
109
+
110
+ test('a user edit to the policy file is preserved on upgrade', () => {
111
+ withTempProject((root) => {
112
+ install(root, 'codex');
113
+ const file = policyFile(root, 'vibe');
114
+ const edited = 'policy:\n allow_implicit_invocation: true\n';
115
+ fs.writeFileSync(file, edited);
116
+ install(root, 'codex');
117
+ assert.equal(fs.readFileSync(file, 'utf8'), edited);
118
+ });
119
+ });
120
+
121
+ test('removing an explicit-only skill removes its whole tree, the policy file included', () => {
122
+ withTempProject((root) => {
123
+ install(root, 'codex');
124
+ const ctx = {
125
+ ownership: { [PLATFORMS.codex.folder]: readOwnership(root) },
126
+ trackers: { codex: manifestLib.createTracker(PLATFORMS.codex.folder, 'next', PLATFORMS.codex.ownership) },
127
+ dryRun: false,
128
+ ui: { warn() {}, detail() {} },
129
+ results: { updated: 0, preserved: 0, preservedFiles: [] }
130
+ };
131
+ removeSkill(ctx, 'codex', 'vibe');
132
+ assert.deepEqual(ctx.results.preservedFiles, []);
133
+ assert.equal(fs.existsSync(path.join(root, '.agents/skills/vibe')), false);
134
+ });
135
+ });
136
+
137
+ test('a Claude install writes no policy file', () => {
138
+ withTempProject((root) => {
139
+ install(root, 'claude');
140
+ for (const skill of fs.readdirSync(path.join(root, '.claude/skills'))) {
141
+ assert.equal(fs.existsSync(path.join(root, '.claude/skills', skill, 'agents/openai.yaml')), false, skill);
142
+ }
143
+ });
144
+ });
145
+
146
+ test('neither runtime catalog lists an explicit-only skill, and both still list the others', () => {
147
+ withTempProject((root) => {
148
+ install(root, 'claude,codex');
149
+ for (const [script, skillsRoot] of [['.codex/scripts/generate-skill-catalog.cjs', '.agents/skills'],
150
+ ['.claude/scripts/generate-skill-catalog.cjs', '.claude/skills']]) {
151
+ const parsed = catalog(root, script, skillsRoot);
152
+ const ids = parsed.skills.map((skill) => skill.public_id);
153
+ for (const skill of explicitOnly) {
154
+ assert.equal(ids.includes(`cf:${skill}`), false, `${script} lists cf:${skill}`);
155
+ assert.ok(parsed.diagnostics.some((item) => item.directory === skill && item.code === 'explicit_only'), `${script}: ${skill}`);
156
+ }
157
+ assert.ok(ids.includes('cf:ask') && ids.includes('cf:specs'), script);
158
+ }
159
+ });
160
+ });
@@ -6,16 +6,14 @@ const os = require('node:os');
6
6
  const path = require('node:path');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { test } = require('node:test');
9
+ const { installCodexHookRuntime } = require('./codex-hook-runtime-fixture.cjs');
9
10
  const SOURCE = path.resolve(__dirname, '../../src');
10
11
 
11
12
  function fixture(run) {
12
13
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-native-boundaries-'));
13
14
  const hooks = path.join(root, '.codex/hooks');
14
15
  try {
15
- fs.cpSync(path.join(SOURCE, 'codex/hooks'), hooks, { recursive: true });
16
- for (const name of ['runtime-path-safety', 'privacy-command-analysis', 'privacy-paths', 'secret-keywords', 'inspect-paths', 'update-check']) {
17
- fs.copyFileSync(path.join(SOURCE, `claude/hooks/lib/${name}.cjs`), path.join(hooks, `lib/${name}.cjs`));
18
- }
16
+ installCodexHookRuntime(root);
19
17
  fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
20
18
  fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
21
19
  for (const args of [['init', '-q'], ['config', 'user.email', 'probe@example.invalid'], ['config', 'user.name', 'Probe'], ['add', '.gitignore'], ['commit', '-qm', 'base']]) {