@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
@@ -18,6 +18,88 @@ const PROJECT_ROOT = (() => {
18
18
  }
19
19
  })();
20
20
 
21
+ /**
22
+ * Plugin root when this hook runs from the Codex plugin tree, else null.
23
+ *
24
+ * Codex exports PLUGIN_ROOT (and CLAUDE_PLUGIN_ROOT) to plugin hooks. An npx-installed
25
+ * hook can inherit those variables from a parent process, so the plugin is recognized
26
+ * only when this file really lives under that root.
27
+ */
28
+ function pluginRoot() {
29
+ const declared = process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT;
30
+ if (!declared) return null;
31
+ try {
32
+ const root = fs.realpathSync(declared);
33
+ const self = fs.realpathSync(path.resolve(__dirname, '..', '..'));
34
+ return self === root ? root : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Where the plugin keeps state for every repository: PLUGIN_DATA, or a per-user temp
42
+ * directory. The temp directory must be a real 0700 directory this user owns; anything
43
+ * else throws, so privacy decisions fail closed instead of trusting a planted path.
44
+ */
45
+ function pluginStateRoot() {
46
+ const data = process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA;
47
+ if (data && path.isAbsolute(data)) {
48
+ fs.mkdirSync(data, { recursive: true, mode: 0o700 });
49
+ return fs.realpathSync(data);
50
+ }
51
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
52
+ const dir = path.join(require('os').tmpdir(), `cafekit-codex-plugin-${uid}`);
53
+ try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
54
+ const stat = fs.lstatSync(dir);
55
+ const owned = typeof process.getuid !== 'function' || stat.uid === process.getuid();
56
+ if (!stat.isDirectory() || stat.isSymbolicLink() || !owned || (stat.mode & 0o077) !== 0) {
57
+ throw new Error(`unsafe CafeKit plugin state directory: ${dir}`);
58
+ }
59
+ return dir;
60
+ }
61
+
62
+ /** Per-repository state directory under the plugin state root. */
63
+ function pluginProjectDir(projectRoot, name) {
64
+ const key = crypto.createHash('sha256').update(String(projectRoot)).digest('hex').slice(0, 16);
65
+ return path.join(pluginStateRoot(), 'projects', key, name);
66
+ }
67
+
68
+ /**
69
+ * True when the repository runs CafeKit's own npx-installed Codex hooks: its
70
+ * .codex/hooks.json parses, and every CafeKit launcher in it names a hook script that
71
+ * exists inside this repository. A committed hooks.json from another machine, or a
72
+ * planted file with no real hooks behind it, must not switch the plugin's gates off.
73
+ */
74
+ function hasProjectCafekitHooks(projectRoot) {
75
+ try {
76
+ const config = JSON.parse(fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8'));
77
+ const hooksDir = fs.realpathSync(path.join(projectRoot, '.codex', 'hooks'));
78
+ const commands = Object.values(config?.hooks || {}).flat()
79
+ .flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))
80
+ .map((handler) => String(handler?.command || ''));
81
+ const scripts = commands
82
+ .map((command) => command.match(/'([^']*[/\\]\.codex[/\\]hooks[/\\][a-z0-9-]+\.cjs)'\s*$/)?.[1])
83
+ .filter(Boolean);
84
+ if (scripts.length === 0) return false;
85
+ // The npx launcher names its script by the exact path <realpath(root)>/.codex/hooks.
86
+ // Anything else (a relative path, `..`, a symlinked alias, /proc/self/cwd) can
87
+ // resolve against whatever cwd the hook runs in, so it never counts.
88
+ const expectedDir = path.join(fs.realpathSync(projectRoot), '.codex', 'hooks');
89
+ return scripts.every((script) => {
90
+ if (!path.isAbsolute(script) || path.dirname(script) !== expectedDir) return false;
91
+ try {
92
+ const real = fs.realpathSync(script);
93
+ return path.dirname(real) === hooksDir && fs.statSync(real).isFile();
94
+ } catch {
95
+ return false;
96
+ }
97
+ });
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
21
103
  function readPayload() {
22
104
  const raw = fs.readFileSync(0, 'utf8').trim();
23
105
  if (!raw) throw new Error('hook payload is empty');
@@ -40,6 +122,33 @@ function resolveSessionCwd(payload) {
40
122
  }
41
123
  }
42
124
 
125
+ function gitTopLevel(cwd) {
126
+ try {
127
+ const out = require('child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], {
128
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore']
129
+ }).trim();
130
+ return out ? fs.realpathSync(out) : cwd;
131
+ } catch {
132
+ return cwd;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The repository a hook acts on. An npx install sits inside it (PROJECT_ROOT); the plugin
138
+ * lives in a shared cache, so it takes the repository from the payload's cwd. Beside an
139
+ * active npx install the plugin stands down, so one set of gates and one privacy store
140
+ * decide; `onStandDown` lets SessionStart say so once.
141
+ */
142
+ function resolveProjectRoot(payload, { onStandDown } = {}) {
143
+ if (!pluginRoot()) return PROJECT_ROOT;
144
+ const root = gitTopLevel(resolveSessionCwd(payload));
145
+ if (hasProjectCafekitHooks(root)) {
146
+ if (typeof onStandDown === 'function') onStandDown(root);
147
+ process.exit(0);
148
+ }
149
+ return root;
150
+ }
151
+
43
152
  function readRuntime(projectRoot = PROJECT_ROOT) {
44
153
  try {
45
154
  const file = path.join(projectRoot, '.codex', 'runtime.json');
@@ -84,10 +193,11 @@ function assertConfiguredSpecsPath(projectRoot, runtime) {
84
193
  }
85
194
 
86
195
  function getHookContext(payload) {
87
- const runtime = readRuntime(PROJECT_ROOT);
88
- assertConfiguredSpecsPath(PROJECT_ROOT, runtime);
196
+ const projectRoot = resolveProjectRoot(payload);
197
+ const runtime = readRuntime(projectRoot);
198
+ assertConfiguredSpecsPath(projectRoot, runtime);
89
199
  return {
90
- projectRoot: PROJECT_ROOT,
200
+ projectRoot,
91
201
  sessionCwd: resolveSessionCwd(payload),
92
202
  runtime
93
203
  };
@@ -123,6 +233,7 @@ function atomicWrite(file, content, mode = 0o600) {
123
233
  * `.codex` at any depth. Source-tree runs therefore write to a temp directory.
124
234
  */
125
235
  function hookStateDir(projectRoot) {
236
+ if (pluginRoot()) return projectRoot ? pluginProjectDir(projectRoot, 'logs') : path.join(pluginStateRoot(), 'logs');
126
237
  const root = projectRoot || PROJECT_ROOT;
127
238
  return String(root).includes(`${path.sep}packages${path.sep}spec${path.sep}src`)
128
239
  ? path.join(require('os').tmpdir(), 'cafekit-hook-logs', 'codex')
@@ -131,7 +242,7 @@ function hookStateDir(projectRoot) {
131
242
 
132
243
  function logCrash(hook, error) {
133
244
  try {
134
- const dir = hookStateDir(PROJECT_ROOT);
245
+ const dir = pluginRoot() ? hookStateDir(null) : hookStateDir(PROJECT_ROOT);
135
246
  fs.mkdirSync(dir, { recursive: true });
136
247
  fs.appendFileSync(
137
248
  path.join(dir, 'hook-log.jsonl'),
@@ -149,6 +260,9 @@ function logCrash(hook, error) {
149
260
 
150
261
  module.exports = {
151
262
  PROJECT_ROOT,
263
+ pluginRoot,
264
+ pluginProjectDir,
265
+ resolveProjectRoot,
152
266
  atomicWrite,
153
267
  getHookContext,
154
268
  hookStateDir,
@@ -1,199 +1 @@
1
- 'use strict';
2
-
3
- const BLOCKED_DIRS = new Set([
4
- 'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
5
- '__pycache__', '.venv', 'venv', 'vendor', 'target',
6
- '.git', 'coverage', '.nyc_output'
7
- ]);
8
- const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
9
- const PATH_KEYS = new RegExp(
10
- '^(?:file_?paths?|paths?|search_?paths?|'
11
- + '(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|'
12
- + 'folders?|cwd|root|locations?|uris?)$',
13
- 'i'
14
- );
15
- const GLOB_KEYS = /(?:^|_)(?:glob|globs|pattern|patterns)$/i;
16
- function clean(value) {
17
- return String(value || '').trim().replace(/^['"`]+|['"`,:]+$/g, '');
18
- }
19
- function blockedSegment(value) {
20
- return clean(value)
21
- .replace(/\\/g, '/')
22
- .split('/')
23
- .find((segment) => BLOCKED_DIRS.has(segment));
24
- }
25
- function isBroadGlob(value) {
26
- const candidate = clean(value);
27
- return BROAD_GLOBS.some((rule) => rule.test(candidate));
28
- }
29
- function collectStructured(value, key, paths, globs) {
30
- if (typeof value === 'string') {
31
- if (PATH_KEYS.test(key)) paths.push(value);
32
- if (GLOB_KEYS.test(key)) globs.push(value);
33
- return;
34
- }
35
- if (Array.isArray(value)) {
36
- if (PATH_KEYS.test(key)) paths.push(...value.filter((item) => typeof item === 'string'));
37
- if (GLOB_KEYS.test(key)) globs.push(...value.filter((item) => typeof item === 'string'));
38
- for (const item of value) collectStructured(item, '', paths, globs);
39
- return;
40
- }
41
- if (!value || typeof value !== 'object') return;
42
- for (const [childKey, child] of Object.entries(value)) {
43
- collectStructured(child, childKey, paths, globs);
44
- }
45
- }
46
- function shellTokens(command) {
47
- const tokens = [];
48
- let token = '';
49
- let quote = '';
50
- for (let index = 0; index < command.length; index += 1) {
51
- const char = command[index];
52
- if (quote) {
53
- if (char === quote) quote = '';
54
- else if (char === '\\' && quote === '"' && index + 1 < command.length) {
55
- token += command[index += 1];
56
- } else token += char;
57
- continue;
58
- }
59
- if (char === '"' || char === "'") {
60
- quote = char;
61
- } else if (char === '\n') {
62
- if (token) tokens.push(token);
63
- token = '';
64
- tokens.push('|');
65
- } else if (/\s/.test(char)) {
66
- if (token) tokens.push(token);
67
- token = '';
68
- } else if (';&|'.includes(char)) {
69
- if (token) tokens.push(token);
70
- token = '';
71
- const pair = command.slice(index, index + 2);
72
- if (pair === '&&' || pair === '||') index += 1;
73
- tokens.push('|');
74
- } else {
75
- token += char;
76
- }
77
- }
78
- if (token) tokens.push(token);
79
- return tokens;
80
- }
81
- function commandGroups(command) {
82
- const groups = [[]];
83
- for (const token of shellTokens(command)) {
84
- if (token === '|') groups.push([]);
85
- else groups[groups.length - 1].push(clean(token));
86
- }
87
- return groups.filter((group) => group.length);
88
- }
89
-
90
- function positional(tokens, optionsWithValue = new Set()) {
91
- const out = [];
92
- for (let index = 1; index < tokens.length; index += 1) {
93
- const token = tokens[index];
94
- if (optionsWithValue.has(token)) index += 1;
95
- else if (!token.startsWith('-') || token === '-') out.push(token);
96
- }
97
- return out;
98
- }
99
-
100
- function readerOptionPaths(name, tokens) {
101
- if (!['rg', 'grep', 'sed'].includes(name)) return [];
102
- const paths = [];
103
- for (let index = 1; index < tokens.length; index += 1) {
104
- const token = tokens[index];
105
- if (token === '-f' || token === '--file') {
106
- if (tokens[index + 1]) paths.push(tokens[index + 1]);
107
- index += 1;
108
- } else if (token.startsWith('--file=')) {
109
- paths.push(token.slice('--file='.length));
110
- }
111
- }
112
- return paths;
113
- }
114
-
115
- function findRoots(tokens) {
116
- const roots = [];
117
- let index = 1;
118
- while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) {
119
- index += 1;
120
- }
121
- if (tokens[index] === '-D') index += 2;
122
- if (tokens[index] === '--') index += 1;
123
- for (; index < tokens.length; index += 1) {
124
- const token = tokens[index];
125
- if (token.startsWith('-') || ['!', '(', ')'].includes(token)) break;
126
- roots.push(token);
127
- }
128
- return roots;
129
- }
130
-
131
- function commandPaths(group, globs) {
132
- const commandIndex = group.findIndex((token) => (
133
- token && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
134
- ));
135
- if (commandIndex < 0) return [];
136
- const tokens = group.slice(commandIndex);
137
- while (['sudo', 'env', 'time', 'command'].includes(tokens[0])) tokens.shift();
138
- if (!tokens.length) return [];
139
- const name = tokens[0].replace(/\\/g, '/').split('/').pop();
140
- const optionPaths = readerOptionPaths(name, tokens);
141
-
142
- for (let index = 1; index < tokens.length; index += 1) {
143
- const token = tokens[index];
144
- if (token === '-g' || token === '--glob' || token === '--iglob') {
145
- if (tokens[index + 1]) globs.push(tokens[index + 1]);
146
- } else if (/^--(?:i?glob)=/.test(token)) {
147
- globs.push(token.slice(token.indexOf('=') + 1));
148
- }
149
- }
150
-
151
- if (name === 'rg' || name === 'grep') {
152
- const values = positional(tokens, new Set([
153
- '-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob',
154
- '-t', '--type', '-T', '--type-not'
155
- ]));
156
- const explicitPattern = tokens.some((token) => token === '-e' || token === '--regexp');
157
- const fileMode = tokens.includes('--files');
158
- return [...optionPaths, ...(explicitPattern || fileMode ? values : values.slice(1))];
159
- }
160
- if (name === 'sed') {
161
- const values = positional(tokens, new Set(['-e', '--expression', '-f', '--file']));
162
- const explicitScript = tokens.some((token) => (
163
- ['-e', '--expression', '-f', '--file'].includes(token)
164
- ));
165
- return [...optionPaths, ...(explicitScript ? values : values.slice(1))];
166
- }
167
- if (name === 'find') return findRoots(tokens);
168
- if (['cat', 'head', 'tail', 'ls', 'wc', 'du', 'tree', 'file', 'stat', 'cd', 'pushd', 'source', '.'].includes(name)) {
169
- return positional(tokens, new Set(['-n', '--lines', '-c', '--bytes']));
170
- }
171
- return tokens.filter((token) => (
172
- /^(?:\.{0,2}[\\/]|~[\\/])/.test(token)
173
- || (token.includes('/') && blockedSegment(token))
174
- ));
175
- }
176
-
177
- function inspectInput(input) {
178
- const paths = [];
179
- const globs = [];
180
- collectStructured(input, '', paths, globs);
181
- const command = typeof input?.command === 'string'
182
- ? input.command
183
- : typeof input?.cmd === 'string' ? input.cmd : '';
184
- for (const group of commandGroups(command)) {
185
- paths.push(...commandPaths(group, globs));
186
- }
187
- return {
188
- broadGlob: globs.find(isBroadGlob),
189
- blockedPath: paths.map((requested) => ({
190
- requested,
191
- blocked: blockedSegment(requested)
192
- })).find((result) => result.blocked)
193
- };
194
- }
195
-
196
- module.exports = {
197
- BLOCKED_DIRS,
198
- inspectInput
199
- };
1
+ module.exports = require('../../../claude/hooks/lib/inspect-paths.cjs');
@@ -0,0 +1,2 @@
1
+ 'use strict';
2
+ module.exports = require('../../../claude/hooks/lib/plugin-options.cjs');
@@ -0,0 +1 @@
1
+ module.exports = require('../../../claude/hooks/lib/privacy-paths.cjs');
@@ -4,6 +4,8 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const PATH_SAFETY = require('./runtime-path-safety.cjs');
7
+ const { displayNames } = require('./privacy-paths.cjs');
8
+ const { pluginRoot, pluginProjectDir } = require('./hook-context.cjs');
7
9
 
8
10
  // Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
9
11
  // "ask" decision -- so approval costs a full round trip: the agent has to relay
@@ -15,7 +17,18 @@ const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
15
17
  function stateDir(projectRoot) {
16
18
  return path.join(projectRoot, '.codex', 'hooks', '.privacy');
17
19
  }
20
+ // Under the plugin, approvals live in the plugin state root, one directory per
21
+ // repository; pluginProjectDir throws for an unsafe root so callers fail closed.
22
+ function pluginStateDir(projectRoot, create) {
23
+ const dir = pluginProjectDir(PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root'), 'privacy');
24
+ if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ else if (!fs.existsSync(dir)) return null;
26
+ const stat = fs.lstatSync(dir);
27
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('privacy state must be a regular directory');
28
+ return dir;
29
+ }
18
30
  function secureStateDir(projectRoot, create = false) {
31
+ if (pluginRoot()) return pluginStateDir(projectRoot, create);
19
32
  const root = PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root');
20
33
  const hooks = path.join(root, '.codex', 'hooks');
21
34
  try { PATH_SAFETY.assertComponentPath(root, hooks); }
@@ -24,7 +37,12 @@ function secureStateDir(projectRoot, create = false) {
24
37
  throw error;
25
38
  }
26
39
  const dir = path.join(hooks, '.privacy');
27
- if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700 });
40
+ if (create && !fs.existsSync(dir)) {
41
+ // A parallel hook can create the directory between the check and the mkdir;
42
+ // the component and directory checks below still reject anything but a real directory.
43
+ try { fs.mkdirSync(dir, { mode: 0o700 }); }
44
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
45
+ }
28
46
  try { PATH_SAFETY.assertComponentPath(root, dir); }
29
47
  catch (error) {
30
48
  if (!create && error.code === 'ENOENT') return null;
@@ -61,13 +79,6 @@ function samePathKeys(left, right) {
61
79
  && left.every((value, index) => value === right[index]);
62
80
  }
63
81
 
64
- function displayNames(filePaths) {
65
- const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
66
- return names.length <= 3
67
- ? names.join(', ')
68
- : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
69
- }
70
-
71
82
  function atomicWrite(dir, file, payload) {
72
83
  PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
73
84
  const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
@@ -84,14 +95,81 @@ function readJson(dir, file) {
84
95
  }
85
96
  }
86
97
 
98
+ function validPending(record, sessionId, requestId, now = Date.now()) {
99
+ return record?.requestId === requestId && record.sessionId === sessionId
100
+ && Number.isFinite(record.expiresAt) && record.expiresAt > now
101
+ && typeof record.displayName === 'string' && record.displayName.length > 0
102
+ && Array.isArray(record.pathKeys) && record.pathKeys.length > 0
103
+ && record.pathKeys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/.test(key));
104
+ }
105
+
106
+ function writeShortMarker(dir, record) {
107
+ const { requestId, sessionId, expiresAt } = record;
108
+ fs.writeFileSync(path.join(dir, `short-${hash(sessionId)}-${requestId}.json`),
109
+ `${JSON.stringify({ requestId, sessionId, expiresAt })}\n`,
110
+ { encoding: 'utf8', mode: 0o600, flag: 'wx' });
111
+ }
112
+
113
+ /** Each prompt owns only markers it successfully renamed, never a shared mutable flag. */
114
+ function claimShortMarkers(projectRoot, sessionId) {
115
+ const requestIds = [];
116
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: true, requestIds };
117
+ const dir = secureStateDir(projectRoot);
118
+ if (!dir) return { ok: true, requestIds };
119
+ const prefix = `short-${hash(sessionId)}-`;
120
+ let ok = true;
121
+ for (const name of fs.readdirSync(dir)) {
122
+ if (!name.startsWith(prefix) || !name.endsWith('.json')) continue;
123
+ const file = path.join(dir, name);
124
+ const claimed = path.join(dir, `claim-${crypto.randomBytes(12).toString('hex')}.tmp`);
125
+ const requestId = name.slice(prefix.length, -'.json'.length);
126
+ let moved = false;
127
+ try {
128
+ PATH_SAFETY.assertComponentPath(dir, file);
129
+ fs.renameSync(file, claimed);
130
+ moved = true;
131
+ requestIds.push(requestId);
132
+ PATH_SAFETY.assertComponentPath(dir, claimed);
133
+ const marker = JSON.parse(fs.readFileSync(claimed, 'utf8'));
134
+ if (!/^[a-f0-9]{24}$/.test(requestId) || marker.requestId !== requestId
135
+ || marker.sessionId !== sessionId || !Number.isFinite(marker.expiresAt)
136
+ || marker.expiresAt <= Date.now()) throw new Error('Invalid short approval marker');
137
+ } catch {
138
+ ok = false;
139
+ if (!moved) {
140
+ // A failed claim cancels the shortcut; deletion never counts as ownership.
141
+ try { fs.unlinkSync(file); }
142
+ catch {
143
+ let descriptor;
144
+ try {
145
+ PATH_SAFETY.canonicalRegularFile(dir, file, 'short approval marker');
146
+ descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
147
+ fs.ftruncateSync(descriptor, 0);
148
+ } catch { /* no permission follows a failed invalidation */ }
149
+ finally {
150
+ if (descriptor !== undefined) {
151
+ try { fs.closeSync(descriptor); } catch { /* already rejected */ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ } finally {
157
+ if (moved) {
158
+ try { fs.unlinkSync(claimed); } catch { ok = false; }
159
+ }
160
+ }
161
+ }
162
+ return { ok, requestIds };
163
+ }
164
+
87
165
  function cleanup(projectRoot, now = Date.now()) {
88
166
  const dir = secureStateDir(projectRoot);
89
167
  if (!dir) return;
90
168
  for (const name of fs.readdirSync(dir)) {
91
- if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
169
+ if (!/^(?:(?:pending|token)-[a-f0-9]+|short-[a-f0-9]{64}-[a-f0-9]{24})\.json$/.test(name)) continue;
92
170
  const file = path.join(dir, name);
93
171
  const record = readJson(dir, file);
94
- if (!record || Number(record.expiresAt) <= now) {
172
+ if (!record || !Number.isFinite(record.expiresAt) || record.expiresAt <= now) {
95
173
  try {
96
174
  fs.unlinkSync(file);
97
175
  } catch {
@@ -101,13 +179,20 @@ function cleanup(projectRoot, now = Date.now()) {
101
179
  }
102
180
  }
103
181
 
104
- function clearState(projectRoot) {
182
+ function clearState(projectRoot, sessionId) {
105
183
  try {
184
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return;
106
185
  const dir = secureStateDir(projectRoot);
107
186
  if (!dir) return;
108
187
  for (const name of fs.readdirSync(dir)) {
188
+ if (name.startsWith(`short-${hash(sessionId)}-`) && name.endsWith('.json')) {
189
+ try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently claimed */ }
190
+ continue;
191
+ }
109
192
  if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
110
- try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
193
+ const file = path.join(dir, name);
194
+ if (readJson(dir, file)?.sessionId !== sessionId) continue;
195
+ try { fs.unlinkSync(file); } catch { /* concurrently consumed */ }
111
196
  }
112
197
  }
113
198
  } catch {
@@ -123,26 +208,38 @@ function createPending({
123
208
  filePath
124
209
  }) {
125
210
  cleanup(projectRoot);
126
- if (!sessionId) throw new Error('A Codex session ID is required');
211
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('A Codex session ID is required');
127
212
  const paths = requestPaths(filePaths, filePath);
128
213
  if (paths.length === 0) throw new Error('At least one sensitive path is required');
214
+ const pathKeys = requestPathKeys(paths, sessionCwd);
215
+ const dir = secureStateDir(projectRoot, true);
216
+ for (const name of fs.readdirSync(dir)) {
217
+ const match = name.match(/^pending-([a-f0-9]{24})\.json$/);
218
+ if (!match) continue;
219
+ const pending = readJson(dir, path.join(dir, name));
220
+ if (validPending(pending, sessionId, match[1]) && samePathKeys(pending.pathKeys, pathKeys)) {
221
+ try { writeShortMarker(dir, pending); }
222
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
223
+ return pending;
224
+ }
225
+ }
129
226
  const requestId = crypto.randomBytes(12).toString('hex');
130
227
  const record = {
131
228
  version: 2,
132
229
  requestId,
133
230
  sessionId: String(sessionId || ''),
134
- pathKeys: requestPathKeys(paths, sessionCwd),
231
+ pathKeys,
135
232
  displayName: displayNames(paths),
136
233
  expiresAt: Date.now() + TTL_MS
137
234
  };
138
- const dir = secureStateDir(projectRoot, true);
139
235
  atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
236
+ writeShortMarker(dir, record);
140
237
  return record;
141
238
  }
142
239
 
143
240
  function approvePending({ projectRoot, sessionId, requestId }) {
144
241
  cleanup(projectRoot);
145
- if (!sessionId) return { ok: false, reason: 'session' };
242
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, reason: 'session' };
146
243
  if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
147
244
 
148
245
  const dir = secureStateDir(projectRoot);
@@ -152,6 +249,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
152
249
  if (!record) return { ok: false, reason: 'missing' };
153
250
  if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
154
251
  if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
252
+ if (!validPending(record, sessionId, requestId)) return { ok: false, reason: 'invalid' };
155
253
 
156
254
  try {
157
255
  fs.unlinkSync(pendingFile);
@@ -218,5 +316,6 @@ module.exports = {
218
316
  approvePending,
219
317
  hasGrant,
220
318
  cleanup,
221
- clearState
319
+ clearState,
320
+ claimShortMarkers
222
321
  };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { hookStateDir } = require('./hook-context.cjs');
7
+
8
+ function reservationFile(projectRoot, sessionId) {
9
+ const key = crypto.createHash('sha256')
10
+ .update(String(sessionId || 'unknown'))
11
+ .digest('hex')
12
+ .slice(0, 16);
13
+ return path.join(hookStateDir(projectRoot), `rules-${key}.json`);
14
+ }
15
+
16
+ /** Reserve one injection slot per session. Returns null on reservation errors. */
17
+ function reserveSession(projectRoot, sessionId) {
18
+ if (!sessionId) return null;
19
+ try {
20
+ const file = reservationFile(projectRoot, sessionId);
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
23
+ encoding: 'utf8',
24
+ flag: 'wx',
25
+ mode: 0o600
26
+ });
27
+ return true;
28
+ } catch (error) {
29
+ return error.code === 'EEXIST' ? false : null;
30
+ }
31
+ }
32
+
33
+ function clearSessionReservation(projectRoot, sessionId) {
34
+ if (!sessionId) return;
35
+ try { fs.rmSync(reservationFile(projectRoot, sessionId), { force: true }); } catch { /* fail-open */ }
36
+ }
37
+
38
+ module.exports = { reserveSession, clearSessionReservation };
@@ -0,0 +1 @@
1
+ module.exports = require('../../../claude/hooks/lib/secret-keywords.cjs');