@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,673 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { commandAccess } = require('./privacy-command-analysis.cjs');
7
+
8
+ const RESTRICTED = [
9
+ /^\.env(?:[.\[*?{]|$)/i,
10
+ /^credentials/i,
11
+ /secrets?\.(ya?ml|json)$/i,
12
+ /\.pem$/i,
13
+ /\.key$/i,
14
+ /\.p12$/i,
15
+ /\.pfx$/i,
16
+ /^id_(rsa|ed25519|ecdsa|dsa)$/i,
17
+ /\.netrc$/i,
18
+ /\.pgpass$/i,
19
+ /kubeconfig/i,
20
+ /\.keystore$/i,
21
+ /\.jks$/i,
22
+ /auth\.json$/i,
23
+ /token(s)?\.json$/i
24
+ ];
25
+
26
+ const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
27
+
28
+ function matchesAny(filePath, rules) {
29
+ const base = path.basename(filePath);
30
+ return rules.some((rule) => rule.test(base) || rule.test(filePath));
31
+ }
32
+
33
+ function isSafe(filePath) {
34
+ return matchesAny(filePath, EXEMPT);
35
+ }
36
+
37
+ function isSensitive(filePath) {
38
+ return matchesAny(filePath, RESTRICTED);
39
+ }
40
+
41
+ function resolveTarget(filePath, cwd) {
42
+ try {
43
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
44
+ return fs.realpathSync(requested);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * When realpath fails, check whether the requested path is a symlink (or chain)
52
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
53
+ * dangling benign link or link to allowed example/template -> false (allow).
54
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
55
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
56
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
57
+ */
58
+ function isSymlinkToSensitive(filePath, cwd) {
59
+ try {
60
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
61
+ let current = requested;
62
+ let hops = 0;
63
+ const MAX_HOPS = 8;
64
+ while (hops < MAX_HOPS) {
65
+ let stat;
66
+ try {
67
+ stat = fs.lstatSync(current);
68
+ } catch {
69
+ return false;
70
+ }
71
+ if (!stat.isSymbolicLink()) return false;
72
+ let linkTarget;
73
+ try {
74
+ linkTarget = fs.readlinkSync(current);
75
+ } catch {
76
+ return true;
77
+ }
78
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
79
+ if (isSafe(linkTarget)) return false;
80
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
81
+ if (next === current) return true;
82
+ try {
83
+ const real = fs.realpathSync(next);
84
+ if (isSensitive(real) && !isSafe(real)) return true;
85
+ if (isSafe(real)) return false;
86
+ return false;
87
+ } catch {
88
+ current = next;
89
+ hops += 1;
90
+ continue;
91
+ }
92
+ }
93
+ return true;
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ function scrubCommand(command) {
100
+ return String(command).replace(
101
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2([^\n]*)\n[\s\S]*?\n[ \t]*\3\b/g,
102
+ '$1$2$3$2$4'
103
+ );
104
+ }
105
+
106
+ function extractCommandPaths(command) {
107
+ // The analyzer parses the command and returns the operands of every reading
108
+ // command it recognises, nested shells and `find -exec` included. Treating
109
+ // every unresolved expansion as sensitive, as this did before, denied
110
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
111
+ // costs a round trip through a one-shot approval prompt.
112
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
113
+ // nothing inside it, so no word in the body is ever read. The analyzer has no
114
+ // notion of heredocs and parses the body as shell, so prose that quotes a
115
+ // command -- a PR body carrying `cat .env` in backticks -- came back as a real
116
+ // command substitution. Blank those bodies out first. An unquoted delimiter
117
+ // (<<BODY) does expand, so it is left to the analyzer.
118
+ const scrubbed = scrubCommand(command);
119
+
120
+ const paths = new Set(cachedAccess(scrubbed).paths);
121
+
122
+ // Two shapes the analyzer cannot return. It drops any word carrying an
123
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
124
+ // name. And it only walks commands it recognises, so an unknown tool handed a
125
+ // secret through a file-shaped option, such as
126
+ // `docker compose --env-file .env`, is invisible to it.
127
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
128
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
129
+ for (let index = 0; index < tokens.length; index += 1) {
130
+ const token = tokens[index];
131
+ const shaped = /^~|[*?[{]/.test(token);
132
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
133
+ if (!shaped && !optionFed) continue;
134
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
135
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
136
+ }
137
+
138
+ return [...paths];
139
+ }
140
+
141
+ // omp's edit modes name files in their own grammars: hashline headers `[path#TAG]` (tag
142
+ // optional, path may be quoted) and `¶path`, a hashline `MV <dest>` move, and the sloppy
143
+ // `*** Edit File:` header. The move destination is a write like any other.
144
+ const PATCH_FILE_LINES = [
145
+ /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm,
146
+ /^[ \t]*\*{3}[ \t]+Edit[ \t]+File:[ \t]*(.+)$/gim,
147
+ /^\[(.+?)(?:#[0-9A-Fa-f]{4})?\]$/gm,
148
+ /^\u00b6(.+)$/gm,
149
+ /^[ \t]*MV[ \t]+(.+)$/gm,
150
+ ];
151
+
152
+ function extractPatchPaths(patch) {
153
+ const text = String(patch).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
154
+ return PATCH_FILE_LINES.flatMap((pattern) => [...text.matchAll(pattern)])
155
+ .map((match) => match[1].trim().replace(/^(["'])(.*)\1$/, '$2').replace(/#[0-9A-Fa-f]{4}$/, ''))
156
+ .filter(Boolean);
157
+ }
158
+
159
+ // omp's read takes `path:<selector>` (`raw`, `conflicts`, `img`, a line or range list);
160
+ // the file is the part before it, so `.env:raw` is `.env`.
161
+ const READ_SELECTOR = /:((?:raw|conflicts|img|-?\d+(?:[-+,.]\d+)*))$/i;
162
+
163
+ function withoutSelectors(filePath) {
164
+ const variants = [];
165
+ let current = filePath;
166
+ for (let count = 0; count < 2 && READ_SELECTOR.test(current); count += 1) {
167
+ current = current.replace(READ_SELECTOR, '');
168
+ if (current) variants.push(current);
169
+ }
170
+ return variants;
171
+ }
172
+
173
+ // omp's eval runs code with no path argument; a quoted literal naming a secret file is the
174
+ // only file reference the gate can see.
175
+ function evalLiterals(input) {
176
+ const code = typeof input?.code === 'string' ? input.code : '';
177
+ return [...code.matchAll(/(["'`])((?:(?!\1)[^\\\n]|\\.)*)\1/g)].map((match) => match[2].trim()).filter(Boolean);
178
+ }
179
+
180
+
181
+ function shellInput(input) {
182
+ const value = input?.command ?? input?.cmd;
183
+ if (typeof value === 'string') return value;
184
+ if (!Array.isArray(value)) return '';
185
+ const index = value.findIndex(word => word === '-c' || word === '-lc');
186
+ return index >= 0 ? String(value[index + 1] || '') : value.join(' ');
187
+ }
188
+
189
+ function patchInput(input) {
190
+ if (typeof input === 'string') return input;
191
+ for (const key of ['command', 'patch', 'input']) {
192
+ if (typeof input?.[key] === 'string') return input[key];
193
+ }
194
+ return '';
195
+ }
196
+
197
+ function isPathKey(key, mcp) {
198
+ return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?|rename)$/i.test(key)
199
+ || (mcp && /path|file|dir|uri|source|dest|target/i.test(key));
200
+ }
201
+
202
+ function collectPathValues(value, key, out, mcp) {
203
+ if (typeof value === 'string' && isPathKey(key, mcp)) {
204
+ const filePath = value.trim().replace(/^file:\/\//i, '');
205
+ if (filePath) out.push(filePath);
206
+ } else if (Array.isArray(value)) {
207
+ for (const item of value) collectPathValues(item, key, out, mcp);
208
+ } else if (value && typeof value === 'object') {
209
+ for (const [childKey, childValue] of Object.entries(value)) {
210
+ collectPathValues(childValue, childKey, out, mcp);
211
+ }
212
+ }
213
+ }
214
+
215
+ function extractPaths(toolName, input) {
216
+ const paths = [];
217
+ const tool = String(toolName).toLowerCase();
218
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
219
+ if (['bash', 'exec_command', 'shell'].includes(tool)) {
220
+ paths.push(...extractCommandPaths(shellInput(input)));
221
+ } else if (['apply_patch', 'edit', 'write'].includes(tool)) {
222
+ const patch = patchInput(input);
223
+ paths.push(...extractPatchPaths(patch));
224
+ // An edit whose text names no file cannot be judged, so it is not allowed through.
225
+ if (tool === 'edit' && patch.trim() && !paths.length) throw new Error('edit input names no file');
226
+ } else if (tool === 'eval') {
227
+ paths.push(...evalLiterals(input));
228
+ }
229
+ return [...new Set(paths.flatMap((filePath) => [filePath, ...withoutSelectors(filePath)]))];
230
+ }
231
+
232
+ function sensitiveRequest(filePath, cwd) {
233
+ const target = resolveTarget(filePath, cwd);
234
+ if (target) {
235
+ if (isSensitive(target) && !isSafe(target)) return true;
236
+ if (isSafe(target)) return false;
237
+ } else {
238
+ if (isSymlinkToSensitive(filePath, cwd)) return true;
239
+ }
240
+ return !isSafe(filePath) && isSensitive(filePath);
241
+ }
242
+
243
+ function protectedLocation(filePath) {
244
+ const normalized = path.posix.normalize(filePath).toLowerCase();
245
+ const parts = normalized.split('/');
246
+ const runtimes = new Set(['.claude', '.codex', '.omp']);
247
+ if (runtimes.has(parts.at(-1)) || parts.at(-1) === '.privacy') return true;
248
+ if (parts.at(-1) === 'runtime.json' && runtimes.has(parts.at(-2))) return true;
249
+ for (let index = 0; index + 2 < parts.length; index += 1) {
250
+ if (parts[index] === '.codex' && parts[index + 1] === 'hooks' && parts[index + 2] === '.privacy') return true;
251
+ }
252
+ return false;
253
+ }
254
+
255
+ function protectedWrite(filePath, cwd) {
256
+ const absolute = path.posix.resolve(cwd.replace(/\\/g, '/'), filePath.replace(/\\/g, '/'));
257
+ if (protectedLocation(absolute)) return true;
258
+ let current = absolute;
259
+ const suffix = [];
260
+ const links = new Set();
261
+ for (;;) {
262
+ try {
263
+ if (fs.lstatSync(current).isSymbolicLink()) {
264
+ if (links.has(current) || links.size >= 8) return true;
265
+ links.add(current);
266
+ const target = path.posix.resolve(path.posix.dirname(current), fs.readlinkSync(current));
267
+ if (protectedLocation(path.posix.join(target, ...suffix.slice().reverse()))) return true;
268
+ current = target;
269
+ continue;
270
+ }
271
+ const real = fs.realpathSync(current);
272
+ return protectedLocation(path.posix.join(real, ...suffix.reverse()));
273
+ } catch (error) {
274
+ if (error.code === 'ELOOP') return true;
275
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return false;
276
+ }
277
+ const parent = path.posix.dirname(current);
278
+ if (parent === current) return false;
279
+ suffix.push(path.posix.basename(current));
280
+ current = parent;
281
+ }
282
+ }
283
+
284
+ function extractWritePaths(tool, input) {
285
+ if (['bash', 'exec_command', 'shell'].includes(tool)) return cachedAccess(scrubCommand(shellInput(input)));
286
+ const paths = [];
287
+ if (['write', 'edit', 'multiedit', 'notebookedit'].includes(tool) || tool.startsWith('mcp__')) {
288
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
289
+ }
290
+ if (['apply_patch', 'edit', 'write'].includes(tool)) paths.push(...extractPatchPaths(patchInput(input)));
291
+ return { writes: paths, unknownWrites: [] };
292
+ }
293
+
294
+ function protectedUnknownWrite(filePath) {
295
+ const parts = path.posix.normalize(filePath.replace(/\\/g, '/')).toLowerCase().split('/');
296
+ return parts.at(-1) === 'runtime.json' || parts.includes('.privacy')
297
+ || ['.claude', '.codex', '.omp'].includes(parts.at(-1));
298
+ }
299
+
300
+ // Dynamic shell. One budget covers the whole command -- directory entries and stats, assignments resolved, and wall
301
+ // time -- so a crafted command cannot outrun the hook's own timeout; running out of it asks rather than allows. Every
302
+ // step below is linear in its input, and every loop that touches the disk checks the budget.
303
+ const DYNAMIC_BUDGET = { entries: 4000, assignments: 100000, milliseconds: 250, globstarMilliseconds: 60, textLength: 16384, braceVariants: 64, braceGroups: 64 };
304
+ const OVER_BUDGET = 'dynamic shell expansion over the check budget';
305
+ const DECODED_READ = 'decoded input fed to a shell or reader';
306
+ const UNRESOLVED = '\u0000';
307
+ // Names a glob is tested against when its directory is unknown (after `cd "$D"`) or only its name is visible.
308
+ const SENSITIVE_NAMES = ['.env', '.env.local', '.env.production', '.env.development', 'credentials', 'credentials.json',
309
+ 'secrets.yml', 'secrets.yaml', 'secrets.json', 'server.pem', 'server.key', 'cert.p12', 'cert.pfx', 'id_rsa', 'id_ed25519',
310
+ 'id_ecdsa', 'id_dsa', '.netrc', '.pgpass', 'kubeconfig', 'app.keystore', 'app.jks', 'auth.json', 'token.json', 'tokens.json'];
311
+
312
+ const hasGlob = (text) => /[*?[]/.test(text);
313
+ const overBudget = (budget) => {
314
+ if (budget.entries > DYNAMIC_BUDGET.entries || budget.assignments > DYNAMIC_BUDGET.assignments
315
+ || Date.now() > budget.deadline) budget.over = true;
316
+ return budget.over;
317
+ };
318
+
319
+ // One path segment of a shell glob: * and ? never cross '/', [..] is a class ([!..] negated, a-z ranges), and a name
320
+ // starting with '.' only matches when the pattern starts with '.' too (bash without dotglob). Matching is the two-pointer
321
+ // wildcard walk, so a pattern full of stars costs pattern x name steps, never exponential backtracking.
322
+ const STAR = Symbol('star');
323
+ const ANY = Symbol('any');
324
+ function segmentMatcher(pattern) {
325
+ const tokens = [];
326
+ // Once one '[' finds no ']' after it, no later '[' can: they are all literal, and the rest is not scanned again.
327
+ let unclosed = false;
328
+ for (let index = 0; index < pattern.length; index += 1) {
329
+ const ch = pattern[index];
330
+ if (ch === '*') { if (tokens.at(-1) !== STAR) tokens.push(STAR); continue; }
331
+ if (ch === '?') { tokens.push(ANY); continue; }
332
+ if (ch === '[' && !unclosed) {
333
+ const negated = pattern[index + 1] === '!' || pattern[index + 1] === '^';
334
+ const open = index + (negated ? 2 : 1);
335
+ const close = classEnd(pattern, open);
336
+ if (close > 0) {
337
+ tokens.push(characterClass(pattern.slice(open, close), negated));
338
+ index = close;
339
+ continue;
340
+ }
341
+ unclosed = true;
342
+ }
343
+ tokens.push(ch);
344
+ }
345
+ const dot = pattern.startsWith('.');
346
+ return (name) => (dot || !name.startsWith('.')) && wildcardMatch(tokens, name);
347
+ }
348
+
349
+ // The ']' that closes a class opened before `open`: a ']' first in the class is a member, and a POSIX class
350
+ // (`[:alpha:]`) inside it is skipped whole.
351
+ function classEnd(pattern, open) {
352
+ for (let index = open; index < pattern.length; index += 1) {
353
+ if (pattern[index] === '[' && pattern[index + 1] === ':') {
354
+ const end = pattern.indexOf(':]', index + 2);
355
+ if (end > 0) { index = end + 1; continue; }
356
+ }
357
+ if (pattern[index] === ']' && index > open) return index;
358
+ }
359
+ return -1;
360
+ }
361
+
362
+ function characterClass(body, negated) {
363
+ // A POSIX class (`.e[[:alpha:]]v`) is taken to match any character, negated or not: it may ask more than the shell
364
+ // would match, never less.
365
+ if (body.includes('[:')) return () => true;
366
+ const ranges = [];
367
+ for (let index = 0; index < body.length; index += 1) {
368
+ if (body[index + 1] === '-' && index + 2 < body.length) { ranges.push([body[index], body[index + 2]]); index += 2; }
369
+ else ranges.push([body[index], body[index]]);
370
+ }
371
+ return (ch) => ranges.some(([low, high]) => low <= ch && ch <= high) !== negated;
372
+ }
373
+
374
+ function wildcardMatch(tokens, name) {
375
+ let token = 0;
376
+ let at = 0;
377
+ let star = -1;
378
+ let starAt = 0;
379
+ const one = (expected, ch) => expected === ANY || (typeof expected === 'function' ? expected(ch) : expected === ch);
380
+ while (at < name.length) {
381
+ if (token < tokens.length && tokens[token] !== STAR && one(tokens[token], name[at])) { token += 1; at += 1; continue; }
382
+ if (tokens[token] === STAR) { star = token; token += 1; starAt = at; continue; }
383
+ if (star < 0) return false;
384
+ token = star + 1;
385
+ starAt += 1;
386
+ at = starAt;
387
+ }
388
+ while (tokens[token] === STAR) token += 1;
389
+ return token === tokens.length;
390
+ }
391
+
392
+ // Ordinary names: a glob that also matches one of these (`*`, `*.json`) says nothing about secrets on its own.
393
+ const ORDINARY_NAMES = ['README.md', 'package.json', 'index.js', 'main.ts', 'config.yaml', 'settings.yml', 'data.json',
394
+ 'notes.txt', 'main.go', 'app.py', 'Makefile', 'style.css', 'index.html', 'image.png'];
395
+
396
+ // Name-only judgement, used when the directory cannot be read: a literal secret name, or a glob aimed at secrets.
397
+ const nameLooksSensitive = (pattern) => {
398
+ const base = pattern.split('/').pop();
399
+ if (!hasGlob(base)) return isSensitive(base) && !isSafe(base);
400
+ const matches = segmentMatcher(base);
401
+ return SENSITIVE_NAMES.some(matches) && !ORDINARY_NAMES.some(matches);
402
+ };
403
+
404
+ // $name, ${name}, ${name:-default} and ${name-default}; ~ at the start is HOME. Anything else that expands ($(...),
405
+ // backticks, $1, $@, unknown variables) becomes UNRESOLVED. One left-to-right pass, and the output is capped: past the
406
+ // cap the budget is spent.
407
+ const BRACED = /^([A-Za-z_]\w*)(?::?[-=]([\s\S]*))?$/;
408
+ function substitute(text, vars, budget) {
409
+ const home = process.env.HOME || os.homedir();
410
+ let out = '';
411
+ let close = -1;
412
+ const emit = (value) => {
413
+ if (value === null || out.length + value.length > DYNAMIC_BUDGET.textLength) { budget.over = true; return false; }
414
+ out += value;
415
+ return true;
416
+ };
417
+ for (let index = 0; index < text.length;) {
418
+ const ch = text[index];
419
+ const next = text[index + 1];
420
+ let value = ch;
421
+ let width = 1;
422
+ if (index === 0 && ch === '~' && (next === undefined || next === '/')) value = home;
423
+ else if (ch === '$' && next === '{') {
424
+ // The first '}' after this point; reused while it stays ahead, so `${${${…` is not rescanned for each '${'.
425
+ if (close !== Infinity && close < index + 2) close = text.indexOf('}', index + 2);
426
+ if (close < 0) close = Infinity;
427
+ if (close === Infinity) { value = UNRESOLVED; width = text.length - index; } else {
428
+ const match = BRACED.exec(text.slice(index + 2, close));
429
+ const name = match?.[1];
430
+ value = !match ? UNRESOLVED : vars.has(name) ? vars.get(name) : name === 'HOME' ? home
431
+ : match[2] !== undefined ? match[2] : UNRESOLVED;
432
+ width = close + 1 - index;
433
+ }
434
+ } else if (ch === '$' && next === '(' && text[index + 2] === ')') { value = UNRESOLVED; width = 3; }
435
+ else if ((ch === '<' || ch === '>') && next === '(' && text[index + 2] === ')') { value = UNRESOLVED; width = 3; }
436
+ else if (ch === '`' && next === '`') { value = UNRESOLVED; width = 2; }
437
+ else if (ch === '$' && next !== undefined && '@*#?$!-0123456789'.includes(next)) { value = UNRESOLVED; width = 2; }
438
+ if (!emit(value)) return '';
439
+ index += width;
440
+ }
441
+ return out;
442
+ }
443
+
444
+ // Assignments visible to an operand, outermost scope first, each value substituted with the ones before it. A value
445
+ // past the text cap is kept as null, which spends the budget only if an operand actually uses it.
446
+ const listIds = new WeakMap();
447
+ let nextListId = 0;
448
+ function resolveVars(scopes, budget, memo) {
449
+ // Operands of the same segment see the same assignments; resolve those once.
450
+ const key = scopes.map(({ list, count }) => {
451
+ if (!listIds.has(list)) listIds.set(list, nextListId++);
452
+ return `${listIds.get(list)}:${count}`;
453
+ }).join(',');
454
+ if (memo.has(key)) return memo.get(key);
455
+ const vars = new Map();
456
+ memo.set(key, vars);
457
+ for (const { list, count } of scopes) {
458
+ for (let index = 0; index < count; index += 1) {
459
+ budget.assignments += 1;
460
+ if (overBudget(budget)) return vars;
461
+ const { name, value } = list[index];
462
+ const probe = { over: false };
463
+ const resolved = value.length > DYNAMIC_BUDGET.textLength ? '' : substitute(value, vars, probe);
464
+ vars.set(name, value.length > DYNAMIC_BUDGET.textLength || probe.over ? null : resolved);
465
+ }
466
+ }
467
+ return vars;
468
+ }
469
+
470
+ // Brace expansion {a,b}: the first innermost group holding a comma, found in one scan; variants and group depth capped.
471
+ function braces(text, budget, depth = 0) {
472
+ if (budget.over) return [];
473
+ if (depth > DYNAMIC_BUDGET.braceGroups) { budget.over = true; return []; }
474
+ let open = -1;
475
+ let comma = false;
476
+ let group = null;
477
+ for (let index = 0; index < text.length && !group; index += 1) {
478
+ const ch = text[index];
479
+ if (ch === '{') { open = index; comma = false; } else if (ch === ',' && open >= 0) comma = true;
480
+ else if (ch === '}' && open >= 0) { if (comma) group = [open, index]; open = -1; }
481
+ }
482
+ if (!group) return [text];
483
+ const [start, end] = group;
484
+ const out = [];
485
+ for (const part of text.slice(start + 1, end).split(',')) {
486
+ for (const rest of braces(text.slice(0, start) + part + text.slice(end + 1), budget, depth + 1)) {
487
+ out.push(rest);
488
+ if (out.length > DYNAMIC_BUDGET.braceVariants) { budget.over = true; return out; }
489
+ }
490
+ if (budget.over) return out;
491
+ }
492
+ return out;
493
+ }
494
+
495
+ // Expand a glob against the real directory tree. Every entry read and every literal segment stat-ed is charged to the
496
+ // shared budget. `**` (zsh, bash globstar) walks every directory below, itself included, on a slice of its own: a tree
497
+ // too big for that slice (`**/*.md` at a repository root, through node_modules) returns null, and the caller judges the
498
+ // pattern by name instead of asking for every such command.
499
+ function expandGlob(pattern, base, budget) {
500
+ if (!pattern.split('/').includes('**')) return expandParts(pattern, base, budget);
501
+ const slice = { entries: 0, assignments: 0, over: false,
502
+ deadline: Math.min(budget.deadline, Date.now() + DYNAMIC_BUDGET.globstarMilliseconds) };
503
+ const found = expandParts(pattern, base, slice);
504
+ if (slice.over) return null;
505
+ budget.entries += slice.entries;
506
+ return found;
507
+ }
508
+
509
+ function expandParts(pattern, base, budget) {
510
+ const absolute = pattern.startsWith('/');
511
+ let current = [absolute ? '/' : base];
512
+ const parts = pattern.split('/').filter((part, index) => part || index === 0 && !absolute);
513
+ for (const [index, part] of parts.entries()) {
514
+ if (!part) continue;
515
+ const last = index === parts.length - 1;
516
+ const next = [];
517
+ for (const directory of current) {
518
+ if (part === '**') { if (!walk(directory, next, last, budget)) return []; continue; }
519
+ if (!hasGlob(part)) {
520
+ budget.entries += 1;
521
+ if (overBudget(budget)) return [];
522
+ const joined = path.join(directory, part);
523
+ if (last || isDirectory(joined)) next.push(joined);
524
+ continue;
525
+ }
526
+ let entries;
527
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
528
+ budget.entries += entries.length;
529
+ if (overBudget(budget)) return [];
530
+ const matches = segmentMatcher(part);
531
+ for (const entry of entries) {
532
+ if (Date.now() > budget.deadline) { budget.over = true; return []; }
533
+ if (!matches(entry.name)) continue;
534
+ const joined = path.join(directory, entry.name);
535
+ if (last || entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(joined))) next.push(joined);
536
+ }
537
+ }
538
+ current = next;
539
+ if (!current.length || overBudget(budget)) return [];
540
+ }
541
+ return current;
542
+ }
543
+
544
+ // `**`: the directory and every non-hidden directory below it, or, as the last part, every non-hidden entry below.
545
+ function walk(root, out, last, budget) {
546
+ const pending = [root];
547
+ if (!last) out.push(root);
548
+ while (pending.length) {
549
+ const directory = pending.pop();
550
+ let entries;
551
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
552
+ budget.entries += entries.length;
553
+ if (overBudget(budget)) return false;
554
+ for (const entry of entries) {
555
+ if (entry.name.startsWith('.')) continue;
556
+ const joined = path.join(directory, entry.name);
557
+ if (entry.isDirectory()) { pending.push(joined); out.push(joined); } else if (last) out.push(joined);
558
+ }
559
+ }
560
+ return true;
561
+ }
562
+
563
+ function isDirectory(filePath) {
564
+ try { return fs.statSync(filePath).isDirectory(); } catch { return false; }
565
+ }
566
+
567
+ function dynamicSensitive(access, cwd) {
568
+ const found = [];
569
+ const budget = { entries: 0, assignments: 0, deadline: Date.now() + DYNAMIC_BUDGET.milliseconds, over: false };
570
+ if (access.decodedRead) found.push(DECODED_READ);
571
+ for (const name of access.pipeNames || []) {
572
+ if (name.length > DYNAMIC_BUDGET.textLength) budget.over = true;
573
+ if (overBudget(budget)) break;
574
+ if (nameLooksSensitive(name)) found.push(name);
575
+ }
576
+ const resolved = new Map();
577
+ for (const expansion of access.expansions || []) {
578
+ if (overBudget(budget)) break;
579
+ for (const literal of expansion.literals) if (isSensitive(literal) && !isSafe(literal)) found.push(literal);
580
+ if (expansion.text.length > DYNAMIC_BUDGET.textLength) { budget.over = true; break; }
581
+ const text = substitute(expansion.text, resolveVars(expansion.scopes || [], budget, resolved), budget);
582
+ for (const variant of braces(text, budget)) {
583
+ if (overBudget(budget)) break;
584
+ if (variant.includes(UNRESOLVED)) {
585
+ // `"$X/.env"`: whatever $X is, the name read is the literal tail. `.env$X`, `.en"$Y"v`: a name that starts with
586
+ // literal text reads, when the rest is empty, the name its literal parts spell. A name that starts with an
587
+ // unknown value (`$g.*`) says nothing about which file it is.
588
+ const tail = variant.slice(variant.lastIndexOf(UNRESOLVED) + 1);
589
+ if (tail.includes('/') && nameLooksSensitive(tail)) found.push(tail.replace(/^\/+/, ''));
590
+ const name = variant.slice(variant.lastIndexOf('/') + 1);
591
+ const joined = variant.split(UNRESOLVED).join('');
592
+ if (name && !name.startsWith(UNRESOLVED) && nameLooksSensitive(joined)) found.push(joined);
593
+ continue;
594
+ }
595
+ if (expansion.directory === null) {
596
+ if (nameLooksSensitive(variant)) found.push(variant);
597
+ continue;
598
+ }
599
+ const base = path.resolve(cwd, expansion.directory);
600
+ const candidates = hasGlob(variant) ? expandGlob(variant, base, budget) : [path.resolve(base, variant)];
601
+ if (candidates === null) {
602
+ if (nameLooksSensitive(variant)) found.push(variant);
603
+ continue;
604
+ }
605
+ for (const candidate of candidates) if (sensitiveRequest(candidate, cwd)) found.push(candidate);
606
+ }
607
+ }
608
+ if (budget.over) found.push(OVER_BUDGET);
609
+ return found;
610
+ }
611
+
612
+ // One hook call parses the same command for reads, writes and searches; parse it once.
613
+ const accessCache = new Map();
614
+ function cachedAccess(command) {
615
+ if (!accessCache.has(command)) {
616
+ if (accessCache.size >= 4) accessCache.clear();
617
+ accessCache.set(command, commandAccess(command));
618
+ }
619
+ return accessCache.get(command);
620
+ }
621
+
622
+ function sensitivePaths(toolName, input, cwd) {
623
+ const paths = extractPaths(toolName, input).filter(filePath => sensitiveRequest(filePath, cwd));
624
+ const tool = String(toolName).toLowerCase();
625
+ if (['bash', 'exec_command', 'shell'].includes(tool)) paths.push(...dynamicSensitive(cachedAccess(scrubCommand(shellInput(input))), cwd));
626
+ const access = extractWritePaths(tool, input);
627
+ paths.push(...access.writes.filter(filePath => protectedWrite(filePath, cwd)),
628
+ ...access.unknownWrites.filter(protectedUnknownWrite));
629
+ const searches = tool === 'grep'
630
+ ? [{ recursive: true, hidden: true,
631
+ roots: [typeof input?.path === 'string' && input.path.trim() ? input.path : cwd],
632
+ includes: [input?.glob, ...(Array.isArray(input?.globs) ? input.globs : [])]
633
+ .filter(glob => typeof glob === 'string' && glob),
634
+ }]
635
+ : ['bash', 'exec_command', 'shell'].includes(tool)
636
+ ? cachedAccess(scrubCommand(shellInput(input))).searches : [];
637
+ for (const search of searches) {
638
+ if (!search.recursive) continue;
639
+ // Negative globs and exclusion/type flags cannot prove a sensitive child is omitted.
640
+ const includes = search.includes.filter(glob => glob && !glob.startsWith('!'));
641
+ for (const root of search.roots) {
642
+ const directory = path.resolve(cwd, root);
643
+ let entries;
644
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
645
+ for (const entry of entries) {
646
+ if (entry.isDirectory() || (!search.hidden && entry.name.startsWith('.'))) continue;
647
+ if (includes.length && !includes.some(glob => matchesInclude(entry.name, glob))) continue;
648
+ const filePath = path.join(directory, entry.name);
649
+ if (sensitiveRequest(filePath, cwd)) paths.push(filePath);
650
+ }
651
+ }
652
+ }
653
+ return [...new Set(paths)];
654
+ }
655
+
656
+ function matchesInclude(name, glob) {
657
+ if (typeof path.matchesGlob !== 'function') return true;
658
+ glob = glob.replace(/^\/+/, '');
659
+ if (glob.length > 256 || (glob.match(/[*?]/g) || []).length > 4 || /\{[^}]*\.\.[^}]*\}/.test(glob)
660
+ || !/^[\w.\-\/]*(?:[*?][\w.\-\/]*){0,4}(?:\{[\w.\-*,]*\}[\w.\-\/]*)?$/.test(glob)) return true;
661
+ // Prefixing both sides removes Node's implicit leading-dot exclusion for a basename.
662
+ return path.matchesGlob(name, glob)
663
+ || (name.startsWith('.') && path.matchesGlob(`x${name}`, `x${glob.replace(/^(?:\*\*\/)+/, '')}`));
664
+ }
665
+
666
+ function displayNames(filePaths) {
667
+ const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
668
+ return names.length <= 3
669
+ ? names.join(', ')
670
+ : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
671
+ }
672
+
673
+ module.exports = { isSensitive, isSafe, sensitiveRequest, extractPaths, sensitivePaths, displayNames };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const runtimeRoot = path.resolve(__dirname, '../..');
6
+
7
+ function projectRoot(payload = {}) {
8
+ const installed = path.basename(runtimeRoot).startsWith('.') ? path.dirname(runtimeRoot) : '';
9
+ const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string' ? process.env.CLAUDE_PROJECT_DIR.trim() : '';
10
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
11
+ const root = path.resolve(installed || configured || cwd || process.cwd());
12
+ try { return fs.realpathSync(root); } catch { return root; }
13
+ }
14
+
15
+ module.exports = { projectRoot };