@haposoft/cafekit 0.18.0 → 0.18.2

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 (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -107,6 +107,7 @@ function scanShell(command) {
107
107
  const source = String(command).replace(/\\\r?\n/g, '');
108
108
  const segments = [];
109
109
  const nested = [];
110
+ const recordNested = command => nested.push({ command, segment: segments.length });
110
111
  let words = [];
111
112
  let word = { text: '', activeExpansion: false, started: false };
112
113
  let quote = null;
@@ -132,7 +133,7 @@ function scanShell(command) {
132
133
  if (source[index + 1] === '(') {
133
134
  const end = matchingParen(source, index + 1);
134
135
  if (end < 0) { malformed = true; continue; }
135
- nested.push(source.slice(index + 2, end));
136
+ recordNested(source.slice(index + 2, end));
136
137
  append('()');
137
138
  index = end;
138
139
  }
@@ -142,7 +143,7 @@ function scanShell(command) {
142
143
  word.activeExpansion = true;
143
144
  const end = matchingBacktick(source, index);
144
145
  if (end < 0) { malformed = true; continue; }
145
- nested.push(source.slice(index + 1, end));
146
+ recordNested(source.slice(index + 1, end));
146
147
  append('\x60\x60');
147
148
  index = end;
148
149
  continue;
@@ -164,7 +165,7 @@ function scanShell(command) {
164
165
  if (source[index + 1] === '(') {
165
166
  const end = matchingParen(source, index + 1);
166
167
  if (end < 0) { malformed = true; continue; }
167
- nested.push(source.slice(index + 2, end));
168
+ recordNested(source.slice(index + 2, end));
168
169
  append('()');
169
170
  index = end;
170
171
  }
@@ -174,7 +175,7 @@ function scanShell(command) {
174
175
  word.activeExpansion = true;
175
176
  const end = matchingBacktick(source, index);
176
177
  if (end < 0) { malformed = true; continue; }
177
- nested.push(source.slice(index + 1, end));
178
+ recordNested(source.slice(index + 1, end));
178
179
  append('\x60\x60');
179
180
  index = end;
180
181
  continue;
@@ -183,15 +184,31 @@ function scanShell(command) {
183
184
  word.activeExpansion = true;
184
185
  const end = matchingParen(source, index + 1);
185
186
  if (end < 0) { malformed = true; continue; }
186
- nested.push(source.slice(index + 2, end));
187
+ recordNested(source.slice(index + 2, end));
187
188
  append(`${ch}()`);
188
189
  index = end;
189
190
  continue;
190
191
  }
191
192
  if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
192
193
  if (';|&\n()'.includes(ch)) { flushSegment(); continue; }
193
- if (ch === '<') { flushWord(); words.push({ text: '<', activeExpansion: false, started: true }); continue; }
194
- if (ch === '>') { flushWord(); continue; }
194
+ if (ch === '<') {
195
+ const descriptor = /^[0-9]+$/.test(word.text);
196
+ flushWord();
197
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
198
+ (words.inputRedirects ||= []).push(words.length, words.length + 1);
199
+ words.push({ text: '<', activeExpansion: false, started: true });
200
+ continue;
201
+ }
202
+ if (ch === '>') {
203
+ const descriptor = /^[0-9]+$/.test(word.text);
204
+ flushWord();
205
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
206
+ (words.redirects ||= []).push(words.length);
207
+ if (source[index + 1] === '>') index += 1;
208
+ if (source[index + 1] === '&') index += 1;
209
+ if (source[index + 1] === '|') index += 1;
210
+ continue;
211
+ }
195
212
  if (' \t\r'.includes(ch)) { flushWord(); continue; }
196
213
  append(ch);
197
214
  }
@@ -272,11 +289,150 @@ function genericOperands(words, start, result, {
272
289
 
273
290
  function merge(target, source) {
274
291
  source.paths.forEach((value) => target.paths.add(value));
292
+ target.searches.push(...source.searches);
293
+ source.writes.forEach(value => target.writes.add(value));
294
+ source.unknownWrites.forEach(value => target.unknownWrites.add(value));
275
295
  target.dynamic ||= source.dynamic;
276
296
  target.malformed ||= source.malformed;
277
297
  }
278
298
 
279
- function analyzeWords(words, result, depth) {
299
+ // Describe searches separately so the existing read operands stay unchanged.
300
+ function searchDescription(words, start, name) {
301
+ const grep = ['grep', 'egrep', 'fgrep'].includes(name);
302
+ const search = { recursive: !grep, hidden: grep, includes: [], excludes: [], roots: [] };
303
+ const values = new Set([...GREP_OPTION_VALUE, '-g', '--glob', '--iglob', '--include', '--exclude',
304
+ '--exclude-dir', '-d', '--directories', '-t', '--type', '-T', '--type-not',
305
+ '--max-depth', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr',
306
+ '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit']);
307
+ let pattern = !grep && words.slice(start).some(word => word.text === '--files');
308
+ let unrestricted = 0;
309
+ let options = true;
310
+ const positional = [];
311
+ for (let index = start; index < words.length; index += 1) {
312
+ const word = words[index];
313
+ const text = word.text;
314
+ if (options && text === '--') { options = false; continue; }
315
+ if (options && text.startsWith('-') && text !== '-') {
316
+ let option = text.split('=')[0];
317
+ let value = text.includes('=') ? text.slice(text.indexOf('=') + 1) : null;
318
+ let shortFlags = /^-[^-]/.test(text) ? text.slice(1) : '';
319
+ const attached = text.match(/^-([^-]*?)([gef])(.*)$/);
320
+ if (attached) { shortFlags = attached[1]; option = `-${attached[2]}`; value = attached[3] || null; }
321
+ if (values.has(option) && value === null) value = words[++index]?.text || '';
322
+ if (['-e', '--regexp', '-f', '--file'].includes(option)) pattern = true;
323
+ if (['-g', '--glob', '--include'].includes(option)) search.includes.push(value || '');
324
+ if (['--exclude', '--exclude-dir'].includes(option)) search.excludes.push(value || '');
325
+ if (grep && (['--recursive', '--dereference-recursive'].includes(option)
326
+ || /[rR]/.test(shortFlags)
327
+ || (['-d', '--directories'].includes(option) && value === 'recurse'))) search.recursive = true;
328
+ if (!grep) {
329
+ if (option === '--hidden' || shortFlags.includes('.')) search.hidden = true;
330
+ unrestricted += (shortFlags.match(/u/g) || []).length + (option === '--unrestricted' ? 1 : 0);
331
+ }
332
+ continue;
333
+ }
334
+ positional.push(word);
335
+ }
336
+ if (!pattern) positional.shift();
337
+ search.roots = positional.filter(word => !word.activeExpansion && word.text !== '-').map(word => word.text);
338
+ if (!search.roots.length) search.roots.push('.');
339
+ if (unrestricted >= (name === 'ag' ? 1 : 2)) search.hidden = true;
340
+ // Case-insensitive globs are not evidence that a differently cased child is excluded.
341
+ if (words.slice(start).some(word => ['--glob-case-insensitive', '--iglob'].includes(word.text.split('=')[0]))) search.includes = [];
342
+ return search;
343
+ }
344
+
345
+ function writeTarget(word, result, directory) {
346
+ if (!word?.text || word.activeExpansion || word.text === '-') return;
347
+ if (directory === null && !word.text.startsWith('/')) {
348
+ result.unknownWrites.add(word.text);
349
+ return;
350
+ }
351
+ result.writes.add(word.text.startsWith('/') || directory === '.' ? word.text : `${directory}/${word.text}`);
352
+ }
353
+
354
+ function commandWrites(words, start, name, result, directory) {
355
+ const redirected = new Set([...(words.redirects || []), ...(words.redirectFds || []), ...(words.inputRedirects || [])]);
356
+ if (name === 'tee') {
357
+ for (let index = start; index < words.length; index += 1) {
358
+ if (!redirected.has(index) && !words[index].text.startsWith('-')) writeTarget(words[index], result, directory);
359
+ }
360
+ }
361
+ if (['cp', 'mv', 'install', 'ln'].includes(name)) {
362
+ const operands = [];
363
+ let targetDirectory = false;
364
+ const values = new Set(['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group']);
365
+ let options = true;
366
+ for (let index = start; index < words.length; index += 1) {
367
+ if (redirected.has(index)) continue;
368
+ const word = words[index];
369
+ if (options && word.text === '--') { options = false; continue; }
370
+ if (options && word.text.startsWith('-')) {
371
+ let option = word.text.split('=')[0];
372
+ let value = word.text.includes('=')
373
+ ? { text: word.text.slice(word.text.indexOf('=') + 1), activeExpansion: word.activeExpansion }
374
+ : values.has(option) ? words[++index] : null;
375
+ if (!word.text.startsWith('--') && !values.has(option)) {
376
+ for (let flag = 1; flag < word.text.length; flag += 1) {
377
+ const candidate = `-${word.text[flag]}`;
378
+ if (!values.has(candidate)) continue;
379
+ option = candidate;
380
+ value = flag + 1 < word.text.length
381
+ ? { text: word.text.slice(flag + 1), activeExpansion: word.activeExpansion } : words[++index];
382
+ break;
383
+ }
384
+ }
385
+ if (['-t', '--target-directory'].includes(option)) {
386
+ targetDirectory = true;
387
+ writeTarget(value, result, directory);
388
+ }
389
+ } else operands.push(word);
390
+ }
391
+ if (!targetDirectory) writeTarget(operands.at(-1), result, directory);
392
+ if (name === 'cp' || name === 'mv') {
393
+ const sources = targetDirectory ? operands : operands.slice(0, -1);
394
+ for (const source of sources) {
395
+ const basename = source.text.replace(/\\/g, '/').replace(/\/+$/, '').split('/').at(-1).toLowerCase();
396
+ if (!['.claude', '.codex', '.omp', '.privacy'].includes(basename)) continue;
397
+ if (source.activeExpansion) result.unknownWrites.add(basename);
398
+ else writeTarget(source, result, directory);
399
+ }
400
+ }
401
+ }
402
+ if (name === 'sed') {
403
+ let inPlace = false;
404
+ let scriptOption = false;
405
+ const operands = [];
406
+ let options = true;
407
+ for (let index = start; index < words.length; index += 1) {
408
+ if (redirected.has(index)) continue;
409
+ const word = words[index];
410
+ if (options && word.text === '--') { options = false; continue; }
411
+ if (options && word.text.startsWith('-')) {
412
+ const option = word.text.split('=')[0];
413
+ if (option === '--in-place') inPlace = true;
414
+ if (['--expression', '--file'].includes(option)) {
415
+ scriptOption = true;
416
+ if (!word.text.includes('=')) index += 1;
417
+ } else if (!word.text.startsWith('--')) {
418
+ for (let flag = 1; flag < word.text.length; flag += 1) {
419
+ if (word.text[flag] === 'i') { inPlace = true; break; }
420
+ if (word.text[flag] === 'e' || word.text[flag] === 'f') {
421
+ scriptOption = true;
422
+ if (flag + 1 === word.text.length) index += 1;
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ } else operands.push(word);
428
+ }
429
+ if (operands[0]?.text === '') operands.shift();
430
+ if (inPlace) for (const word of operands.slice(scriptOption ? 0 : 1)) writeTarget(word, result, directory);
431
+ }
432
+ }
433
+
434
+ function analyzeWords(words, result, depth, directory = '.') {
435
+ for (const index of words.redirects || []) writeTarget(words[index], result, directory);
280
436
  for (let index = 0; index + 1 < words.length; index += 1) {
281
437
  if (words[index].text === '<') addOperand(result, words[index + 1]);
282
438
  }
@@ -284,11 +440,13 @@ function analyzeWords(words, result, depth) {
284
440
  if (command.index < 0) return;
285
441
  const start = command.index + 1;
286
442
  const name = command.name;
443
+ commandWrites(words, start, name, result, directory);
287
444
  if (SIMPLE_READERS.has(name)) {
288
445
  genericOperands(words, start, result, { valueOptions: SIMPLE_OPTION_VALUES.get(name) || new Set() });
289
446
  return;
290
447
  }
291
448
  if (GREP_READERS.has(name)) {
449
+ result.searches.push(searchDescription(words, start, name));
292
450
  const patternOption = words.slice(start).some((word) => ['-e', '--regexp', '-f', '--file'].includes(word.text.split('=')[0]));
293
451
  genericOperands(words, start, result, {
294
452
  skipFirst: patternOption ? 0 : 1,
@@ -319,7 +477,7 @@ function analyzeWords(words, result, depth) {
319
477
  if (SHELLS.has(name)) {
320
478
  for (let index = start; index + 1 < words.length; index += 1) {
321
479
  if (words[index].text === '-c' || words[index].text === '--command') {
322
- merge(result, analyzeCommand(words[index + 1].text, depth + 1));
480
+ merge(result, analyzeCommand(words[index + 1].text, depth + 1, directory));
323
481
  return;
324
482
  }
325
483
  }
@@ -327,7 +485,7 @@ function analyzeWords(words, result, depth) {
327
485
  return;
328
486
  }
329
487
  if (name === 'eval') {
330
- merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1));
488
+ merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1, directory));
331
489
  return;
332
490
  }
333
491
  if (name === 'find') {
@@ -335,7 +493,7 @@ function analyzeWords(words, result, depth) {
335
493
  if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
336
494
  const nested = [];
337
495
  for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
338
- analyzeWords(nested, result, depth + 1);
496
+ analyzeWords(nested, result, depth + 1, directory);
339
497
  }
340
498
  return;
341
499
  }
@@ -351,7 +509,7 @@ function analyzeWords(words, result, depth) {
351
509
  index = skipOption(words, index, XARGS_OPTION_VALUE);
352
510
  nestedStart = index;
353
511
  }
354
- if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1);
512
+ if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1, directory);
355
513
  return;
356
514
  }
357
515
  if (INLINE_RUNTIMES.has(name)) {
@@ -369,13 +527,28 @@ function analyzeWords(words, result, depth) {
369
527
  if (name === 'openssl') genericOperands(words, start, result, { pathOptions: new Set(['-in', '-key', '-cert', '-config']) });
370
528
  }
371
529
 
372
- function analyzeCommand(command, depth = 0) {
373
- const result = { paths: new Set(), dynamic: false, malformed: false };
530
+ function analyzeCommand(command, depth = 0, directory = '.') {
531
+ const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set() };
374
532
  if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
375
533
  const parsed = scanShell(command);
376
534
  result.malformed = parsed.malformed;
377
- for (const words of parsed.segments) analyzeWords(words, result, depth);
378
- for (const nested of parsed.nested) merge(result, analyzeCommand(nested, depth + 1));
535
+ let currentDirectory = directory;
536
+ const directories = [];
537
+ for (const words of parsed.segments) {
538
+ directories.push(currentDirectory);
539
+ analyzeWords(words, result, depth, currentDirectory);
540
+ const info = unwrap(words);
541
+ if (['cd', 'pushd'].includes(info.name)) {
542
+ const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
543
+ if (!target || target.activeExpansion) currentDirectory = null;
544
+ else if (target.text.startsWith('/')) currentDirectory = target.text;
545
+ else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
546
+ }
547
+ }
548
+ for (const nested of parsed.nested) {
549
+ const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
550
+ merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
551
+ }
379
552
  if (result.malformed && parsed.segments.some((words) => {
380
553
  const commandInfo = unwrap(words);
381
554
  return SIMPLE_READERS.has(commandInfo.name) || GREP_READERS.has(commandInfo.name)
@@ -387,7 +560,8 @@ function analyzeCommand(command, depth = 0) {
387
560
 
388
561
  function commandAccess(command) {
389
562
  const result = analyzeCommand(command);
390
- return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed };
563
+ return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
564
+ writes: [...result.writes], unknownWrites: [...result.unknownWrites] };
391
565
  }
392
566
 
393
567
  module.exports = { DYNAMIC_READ_PATH, commandAccess };
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { commandAccess } = require('./privacy-command-analysis.cjs');
6
+
7
+ const RESTRICTED = [
8
+ /^\.env(?:[.\[*?{]|$)/i,
9
+ /^credentials/i,
10
+ /secrets?\.(ya?ml|json)$/i,
11
+ /\.pem$/i,
12
+ /\.key$/i,
13
+ /\.p12$/i,
14
+ /\.pfx$/i,
15
+ /^id_(rsa|ed25519|ecdsa|dsa)$/i,
16
+ /\.netrc$/i,
17
+ /\.pgpass$/i,
18
+ /kubeconfig/i,
19
+ /\.keystore$/i,
20
+ /\.jks$/i,
21
+ /auth\.json$/i,
22
+ /token(s)?\.json$/i
23
+ ];
24
+
25
+ const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
26
+
27
+ function matchesAny(filePath, rules) {
28
+ const base = path.basename(filePath);
29
+ return rules.some((rule) => rule.test(base) || rule.test(filePath));
30
+ }
31
+
32
+ function isSafe(filePath) {
33
+ return matchesAny(filePath, EXEMPT);
34
+ }
35
+
36
+ function isSensitive(filePath) {
37
+ return matchesAny(filePath, RESTRICTED);
38
+ }
39
+
40
+ function resolveTarget(filePath, cwd) {
41
+ try {
42
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
43
+ return fs.realpathSync(requested);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * When realpath fails, check whether the requested path is a symlink (or chain)
51
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
52
+ * dangling benign link or link to allowed example/template -> false (allow).
53
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
54
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
55
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
56
+ */
57
+ function isSymlinkToSensitive(filePath, cwd) {
58
+ try {
59
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
60
+ let current = requested;
61
+ let hops = 0;
62
+ const MAX_HOPS = 8;
63
+ while (hops < MAX_HOPS) {
64
+ let stat;
65
+ try {
66
+ stat = fs.lstatSync(current);
67
+ } catch {
68
+ return false;
69
+ }
70
+ if (!stat.isSymbolicLink()) return false;
71
+ let linkTarget;
72
+ try {
73
+ linkTarget = fs.readlinkSync(current);
74
+ } catch {
75
+ return true;
76
+ }
77
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
78
+ if (isSafe(linkTarget)) return false;
79
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
80
+ if (next === current) return true;
81
+ try {
82
+ const real = fs.realpathSync(next);
83
+ if (isSensitive(real) && !isSafe(real)) return true;
84
+ if (isSafe(real)) return false;
85
+ return false;
86
+ } catch {
87
+ current = next;
88
+ hops += 1;
89
+ continue;
90
+ }
91
+ }
92
+ return true;
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ function scrubCommand(command) {
99
+ return String(command).replace(
100
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2([^\n]*)\n[\s\S]*?\n[ \t]*\3\b/g,
101
+ '$1$2$3$2$4'
102
+ );
103
+ }
104
+
105
+ function extractCommandPaths(command) {
106
+ // The analyzer parses the command and returns the operands of every reading
107
+ // command it recognises, nested shells and `find -exec` included. Treating
108
+ // every unresolved expansion as sensitive, as this did before, denied
109
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
110
+ // costs a round trip through a one-shot approval prompt.
111
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
112
+ // nothing inside it, so no word in the body is ever read. The analyzer has no
113
+ // notion of heredocs and parses the body as shell, so prose that quotes a
114
+ // command -- a PR body carrying `cat .env` in backticks -- came back as a real
115
+ // command substitution. Blank those bodies out first. An unquoted delimiter
116
+ // (<<BODY) does expand, so it is left to the analyzer.
117
+ const scrubbed = scrubCommand(command);
118
+
119
+ const paths = new Set(commandAccess(scrubbed).paths);
120
+
121
+ // Two shapes the analyzer cannot return. It drops any word carrying an
122
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
123
+ // name. And it only walks commands it recognises, so an unknown tool handed a
124
+ // secret through a file-shaped option, such as
125
+ // `docker compose --env-file .env`, is invisible to it.
126
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
127
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
128
+ for (let index = 0; index < tokens.length; index += 1) {
129
+ const token = tokens[index];
130
+ const shaped = /^~|[*?[{]/.test(token);
131
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
132
+ if (!shaped && !optionFed) continue;
133
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
134
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
135
+ }
136
+
137
+ return [...paths];
138
+ }
139
+
140
+ function extractPatchPaths(patch) {
141
+ return [...String(patch).matchAll(
142
+ /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
143
+ )]
144
+ .map((match) => match[1].trim())
145
+ .filter(Boolean);
146
+ }
147
+
148
+
149
+ function shellInput(input) {
150
+ const value = input?.command ?? input?.cmd;
151
+ if (typeof value === 'string') return value;
152
+ if (!Array.isArray(value)) return '';
153
+ const index = value.findIndex(word => word === '-c' || word === '-lc');
154
+ return index >= 0 ? String(value[index + 1] || '') : value.join(' ');
155
+ }
156
+
157
+ function patchInput(input) {
158
+ if (typeof input === 'string') return input;
159
+ for (const key of ['command', 'patch', 'input']) {
160
+ if (typeof input?.[key] === 'string') return input[key];
161
+ }
162
+ return '';
163
+ }
164
+
165
+ function isPathKey(key, mcp) {
166
+ return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?)$/i.test(key)
167
+ || (mcp && /path|file|dir|uri|source|dest|target/i.test(key));
168
+ }
169
+
170
+ function collectPathValues(value, key, out, mcp) {
171
+ if (typeof value === 'string' && isPathKey(key, mcp)) {
172
+ const filePath = value.trim().replace(/^file:\/\//i, '');
173
+ if (filePath) out.push(filePath);
174
+ } else if (Array.isArray(value)) {
175
+ for (const item of value) collectPathValues(item, key, out, mcp);
176
+ } else if (value && typeof value === 'object') {
177
+ for (const [childKey, childValue] of Object.entries(value)) {
178
+ collectPathValues(childValue, childKey, out, mcp);
179
+ }
180
+ }
181
+ }
182
+
183
+ function extractPaths(toolName, input) {
184
+ const paths = [];
185
+ const tool = String(toolName).toLowerCase();
186
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
187
+ if (['bash', 'exec_command', 'shell'].includes(tool)) {
188
+ paths.push(...extractCommandPaths(shellInput(input)));
189
+ } else if (['apply_patch', 'edit', 'write'].includes(tool)) {
190
+ paths.push(...extractPatchPaths(patchInput(input)));
191
+ }
192
+ return [...new Set(paths)];
193
+ }
194
+
195
+ function sensitiveRequest(filePath, cwd) {
196
+ const target = resolveTarget(filePath, cwd);
197
+ if (target) {
198
+ if (isSensitive(target) && !isSafe(target)) return true;
199
+ if (isSafe(target)) return false;
200
+ } else {
201
+ if (isSymlinkToSensitive(filePath, cwd)) return true;
202
+ }
203
+ return !isSafe(filePath) && isSensitive(filePath);
204
+ }
205
+
206
+ function protectedLocation(filePath) {
207
+ const normalized = path.posix.normalize(filePath).toLowerCase();
208
+ const parts = normalized.split('/');
209
+ const runtimes = new Set(['.claude', '.codex', '.omp']);
210
+ if (runtimes.has(parts.at(-1)) || parts.at(-1) === '.privacy') return true;
211
+ if (parts.at(-1) === 'runtime.json' && runtimes.has(parts.at(-2))) return true;
212
+ for (let index = 0; index + 2 < parts.length; index += 1) {
213
+ if (parts[index] === '.codex' && parts[index + 1] === 'hooks' && parts[index + 2] === '.privacy') return true;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ function protectedWrite(filePath, cwd) {
219
+ const absolute = path.posix.resolve(cwd.replace(/\\/g, '/'), filePath.replace(/\\/g, '/'));
220
+ if (protectedLocation(absolute)) return true;
221
+ let current = absolute;
222
+ const suffix = [];
223
+ const links = new Set();
224
+ for (;;) {
225
+ try {
226
+ if (fs.lstatSync(current).isSymbolicLink()) {
227
+ if (links.has(current) || links.size >= 8) return true;
228
+ links.add(current);
229
+ const target = path.posix.resolve(path.posix.dirname(current), fs.readlinkSync(current));
230
+ if (protectedLocation(path.posix.join(target, ...suffix.slice().reverse()))) return true;
231
+ current = target;
232
+ continue;
233
+ }
234
+ const real = fs.realpathSync(current);
235
+ return protectedLocation(path.posix.join(real, ...suffix.reverse()));
236
+ } catch (error) {
237
+ if (error.code === 'ELOOP') return true;
238
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return false;
239
+ }
240
+ const parent = path.posix.dirname(current);
241
+ if (parent === current) return false;
242
+ suffix.push(path.posix.basename(current));
243
+ current = parent;
244
+ }
245
+ }
246
+
247
+ function extractWritePaths(tool, input) {
248
+ if (['bash', 'exec_command', 'shell'].includes(tool)) return commandAccess(scrubCommand(shellInput(input)));
249
+ const paths = [];
250
+ if (['write', 'edit', 'multiedit', 'notebookedit'].includes(tool) || tool.startsWith('mcp__')) {
251
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
252
+ }
253
+ if (['apply_patch', 'edit', 'write'].includes(tool)) paths.push(...extractPatchPaths(patchInput(input)));
254
+ return { writes: paths, unknownWrites: [] };
255
+ }
256
+
257
+ function protectedUnknownWrite(filePath) {
258
+ const parts = path.posix.normalize(filePath.replace(/\\/g, '/')).toLowerCase().split('/');
259
+ return parts.at(-1) === 'runtime.json' || parts.includes('.privacy')
260
+ || ['.claude', '.codex', '.omp'].includes(parts.at(-1));
261
+ }
262
+
263
+ function sensitivePaths(toolName, input, cwd) {
264
+ const paths = extractPaths(toolName, input).filter(filePath => sensitiveRequest(filePath, cwd));
265
+ const tool = String(toolName).toLowerCase();
266
+ const access = extractWritePaths(tool, input);
267
+ paths.push(...access.writes.filter(filePath => protectedWrite(filePath, cwd)),
268
+ ...access.unknownWrites.filter(protectedUnknownWrite));
269
+ const searches = tool === 'grep'
270
+ ? [{ recursive: true, hidden: true,
271
+ roots: [typeof input?.path === 'string' && input.path.trim() ? input.path : cwd],
272
+ includes: [input?.glob, ...(Array.isArray(input?.globs) ? input.globs : [])]
273
+ .filter(glob => typeof glob === 'string' && glob),
274
+ }]
275
+ : ['bash', 'exec_command', 'shell'].includes(tool)
276
+ ? commandAccess(scrubCommand(shellInput(input))).searches : [];
277
+ for (const search of searches) {
278
+ if (!search.recursive) continue;
279
+ // Negative globs and exclusion/type flags cannot prove a sensitive child is omitted.
280
+ const includes = search.includes.filter(glob => glob && !glob.startsWith('!'));
281
+ for (const root of search.roots) {
282
+ const directory = path.resolve(cwd, root);
283
+ let entries;
284
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
285
+ for (const entry of entries) {
286
+ if (entry.isDirectory() || (!search.hidden && entry.name.startsWith('.'))) continue;
287
+ if (includes.length && !includes.some(glob => matchesInclude(entry.name, glob))) continue;
288
+ const filePath = path.join(directory, entry.name);
289
+ if (sensitiveRequest(filePath, cwd)) paths.push(filePath);
290
+ }
291
+ }
292
+ }
293
+ return [...new Set(paths)];
294
+ }
295
+
296
+ function matchesInclude(name, glob) {
297
+ if (typeof path.matchesGlob !== 'function') return true;
298
+ glob = glob.replace(/^\/+/, '');
299
+ if (glob.length > 256 || (glob.match(/[*?]/g) || []).length > 4 || /\{[^}]*\.\.[^}]*\}/.test(glob)
300
+ || !/^[\w.\-\/]*(?:[*?][\w.\-\/]*){0,4}(?:\{[\w.\-*,]*\}[\w.\-\/]*)?$/.test(glob)) return true;
301
+ // Prefixing both sides removes Node's implicit leading-dot exclusion for a basename.
302
+ return path.matchesGlob(name, glob)
303
+ || (name.startsWith('.') && path.matchesGlob(`x${name}`, `x${glob.replace(/^(?:\*\*\/)+/, '')}`));
304
+ }
305
+
306
+ function displayNames(filePaths) {
307
+ const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
308
+ return names.length <= 3
309
+ ? names.join(', ')
310
+ : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
311
+ }
312
+
313
+ 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 };