@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
@@ -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 vibe and adv', () => {
59
+ assert.deepEqual(explicitOnly.sort(), ['adv', '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
+ });
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { test } = require('node:test');
9
+ const SOURCE = path.resolve(__dirname, '../../src');
10
+
11
+ function fixture(run) {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-native-boundaries-'));
13
+ const hooks = path.join(root, '.codex/hooks');
14
+ 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
+ }
19
+ fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
20
+ fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
21
+ for (const args of [['init', '-q'], ['config', 'user.email', 'probe@example.invalid'], ['config', 'user.name', 'Probe'], ['add', '.gitignore'], ['commit', '-qm', 'base']]) {
22
+ assert.equal(spawnSync('git', ['-C', root, ...args]).status, 0);
23
+ }
24
+ const hook = (name, payload = {}) => spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
25
+ cwd: root, input: JSON.stringify({ cwd: root, session_id: 'a', ...payload }), encoding: 'utf8',
26
+ });
27
+ run({ root, hooks, hook });
28
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
29
+ }
30
+ function task(root, name, status = 'pending', receipt = '') {
31
+ const dir = path.join(root, 'specs/demo'); fs.mkdirSync(dir, { recursive: true });
32
+ fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
33
+ fs.writeFileSync(path.join(dir, name), `# Task\nStatus: ${status}\n## Dependencies\n- none\n## Verification Plan\n- Command: node --test\n${receipt}`);
34
+ }
35
+
36
+ test('privacy walks nested operation arrays but ignores prose', () => fixture(({ hook }) => {
37
+ for (const tool_input of [{ operations: [{ path: 'credentials.json' }] }, { operations: [[{ file_path: 'credentials.json' }]] }, { paths: ['credentials.json'] }]) {
38
+ const result = hook('privacy-block', { tool_name: 'mcp__files__read', tool_input });
39
+ assert.match(result.stdout, /APPROVE CAFEKIT PRIVACY/);
40
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason, /y \(or yes \/ có\)/);
41
+ }
42
+ assert.equal(hook('privacy-block', { tool_name: 'mcp__files__read', tool_input: { messages: ['credentials.json', { text: 'credentials.json' }] } }).stdout, '');
43
+ }));
44
+
45
+ test('privacy-block allows ls when configured specs escapes the project', () => fixture(({ root, hook }) => {
46
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
47
+ const result = hook('privacy-block', { tool_name: 'Bash', tool_input: { command: 'ls' } });
48
+ assert.equal(result.status, 0);
49
+ assert.equal(result.stdout, '');
50
+ }));
51
+
52
+ test('privacy-block issues approval for a sensitive read when configured specs escapes the project', () => fixture(({ root, hook }) => {
53
+ fs.writeFileSync(path.join(root, '.env'), '');
54
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
55
+ const result = hook('privacy-block', { tool_name: 'Bash', tool_input: { command: 'cat .env' } });
56
+ assert.equal(result.status, 0);
57
+ const output = JSON.parse(result.stdout).hookSpecificOutput;
58
+ assert.equal(output.permissionDecision, 'deny');
59
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/);
60
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
61
+ }));
62
+
63
+ test('privacy-approval grants a pending read when configured specs escapes the project', () => fixture(({ root, hook }) => {
64
+ fs.writeFileSync(path.join(root, '.env'), '');
65
+ const payload = { tool_name: 'Bash', tool_input: { command: 'cat .env' } };
66
+ const pending = hook('privacy-block', payload);
67
+ assert.equal(pending.status, 0);
68
+ const approval = pending.stdout.match(/APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/);
69
+ assert.ok(approval, 'the sensitive read must issue a pending approval');
70
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
71
+ const granted = hook('privacy-approval', { prompt: approval[0] });
72
+ assert.equal(granted.status, 0);
73
+ assert.match(granted.stdout, /User approved the pending sensitive access/);
74
+ const allowed = hook('privacy-block', payload);
75
+ assert.equal(allowed.status, 0);
76
+ assert.equal(allowed.stdout, '');
77
+ }));
78
+
79
+ test('session startup revokes only its own pending requests and grants', () => fixture(({ root, hooks, hook }) => {
80
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
81
+ const request = { projectRoot: root, sessionCwd: root, sessionId: 'a', filePath: 'credentials.json' };
82
+ const grant = privacy.createPending(request);
83
+ assert.equal(privacy.approvePending({ ...request, requestId: grant.requestId }).ok, true);
84
+ const pending = privacy.createPending(request);
85
+ for (const session_id of ['b', undefined]) {
86
+ assert.equal(hook('session', { session_id, hook_event_name: 'SessionStart', source: 'compact' }).status, 0);
87
+ assert.equal(privacy.hasGrant(request), true);
88
+ assert.ok(fs.existsSync(path.join(hooks, `.privacy/pending-${pending.requestId}.json`)));
89
+ }
90
+ assert.equal(hook('session', { hook_event_name: 'SessionStart', source: 'resume' }).status, 0);
91
+ assert.equal(privacy.hasGrant(request), false);
92
+ assert.equal(privacy.approvePending({ ...request, requestId: pending.requestId }).ok, false);
93
+ }));
94
+
95
+ test('cache persistence failure does not replace receipt authority', () => fixture(({ root, hooks, hook }) => {
96
+ task(root, 'task-01-work.md');
97
+ fs.mkdirSync(path.join(hooks, '.logs/spec-gate-last.json'), { recursive: true });
98
+ assert.equal(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, '');
99
+ task(root, 'task-01-work.md', 'done');
100
+ const result = hook('spec-gate', { hook_event_name: 'Stop', explicitFeature: 'demo' });
101
+ assert.match(JSON.parse(result.stdout).reason, /lack a verification receipt/);
102
+ assert.doesNotMatch(result.stdout, /EISDIR|controlled failure/);
103
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{"spec":{"completion_gate":false}}');
104
+ assert.match(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, /no completion-gate bypass/);
105
+ }));
106
+
107
+ test('build output directories are allowed while inspection commands stay blocked', () => fixture(({ hook }) => {
108
+ for (const command of ['vite build --outDir ./dist', 'tsc --outDir ./build', 'npm run build -- --outDir ./dist', 'cargo build --target-dir ./target']) {
109
+ assert.equal(hook('inspect-block', { tool_name: 'exec_command', tool_input: { command } }).status, 0, command);
110
+ }
111
+ for (const command of ['cat dist/index.js', 'find ./dist', 'grep x dist/index.js', 'vite build --outDir ./dist && cat dist/index.js', 'npm exec cat ./dist/index.js', 'vite build --outDir ./dist $(cat dist/index.js)']) {
112
+ assert.equal(hook('inspect-block', { tool_name: 'exec_command', tool_input: { command } }).status, 2, command);
113
+ }
114
+ assert.equal(hook('inspect-block', { tool_input: { path: './dist/index.js' } }).status, 2);
115
+ }));
116
+
117
+ test('one gate invocation captures one stable snapshot for multiple receipts and rechecks next run', () => fixture(({ root, hook }) => {
118
+ task(root, 'task-06-pending.md');
119
+ const provenanceFile = path.join(root, '.codex/scripts/provenance.cjs');
120
+ const trace = path.join(root, '.codex/manifest-count');
121
+ fs.writeFileSync(provenanceFile, fs.readFileSync(provenanceFile, 'utf8').replace('function manifest(root, specsRoot) {', `function manifest(root, specsRoot) { fs.appendFileSync(${JSON.stringify(trace)}, 'x');`));
122
+ const provenance = require(provenanceFile);
123
+ const context = provenance.deriveRuntimeContext({ projectRoot: root, specsRoot: path.join(root, 'specs'), specFile: path.join(root, 'specs/demo/plan.md'), featureName: 'demo', runtimeSession: 'a' });
124
+ const receipt = `## Receipt\nVerification: PASS\nCommand: node --test\nExit: 0\nBase: ${context.base}\nHead: ${context.head}\n\`\`\`text\nnode --test\n1 test passed\n\`\`\`\n`;
125
+ for (let n = 1; n <= 5; n += 1) task(root, `task-0${n}-work.md`, 'done', receipt);
126
+ for (const args of [['add', 'specs'], ['commit', '-qm', 'packet']]) assert.equal(spawnSync('git', ['-C', root, ...args]).status, 0);
127
+ fs.writeFileSync(trace, '');
128
+ assert.equal(hook('spec-gate', { hook_event_name: 'Stop' }).stdout, '');
129
+ assert.equal(fs.readFileSync(trace, 'utf8').length, 2);
130
+ fs.writeFileSync(path.join(root, 'changed.js'), 'changed');
131
+ fs.appendFileSync(path.join(root, 'specs/demo/task-01-work.md'), '\n');
132
+ const blocked = hook('spec-gate', { hook_event_name: 'Stop' }).stdout;
133
+ assert.match(blocked, /provenance/);
134
+ assert.match(blocked, /lines are present but are not the pair/);
135
+ assert.equal(fs.readFileSync(trace, 'utf8').length, 4);
136
+ }));