@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
@@ -35,6 +35,8 @@ const SED_OPTION_VALUE = new Set(['-e', '--expression', '-f', '--file']);
35
35
  const AWK_OPTION_VALUE = new Set(['-f', '--file', '-v', '--assign', '-F', '--field-separator']);
36
36
  const XARGS_OPTION_VALUE = new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '--eof', '-I', '--replace', '-L', '--max-lines', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars']);
37
37
 
38
+ const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
39
+
38
40
  function expansionAt(source, index) {
39
41
  const next = source[index + 1];
40
42
  return Boolean(next && (next === '{' || next === '('
@@ -107,17 +109,28 @@ function scanShell(command) {
107
109
  const source = String(command).replace(/\\\r?\n/g, '');
108
110
  const segments = [];
109
111
  const nested = [];
112
+ // Each nested command is also kept on the word it sits in, so an operand built from $(...) carries its literals.
113
+ const recordNested = command => { nested.push({ command, segment: segments.length }); (word.nested ||= []).push(command); };
110
114
  let words = [];
111
115
  let word = { text: '', activeExpansion: false, started: false };
112
116
  let quote = null;
113
117
  let escaped = false;
114
118
  let malformed = false;
115
119
  const append = (value) => { word.text += value; word.started = true; };
120
+ // `$NAME` is written as `${NAME}` so a quote that ends the name (`.en"$Y"v`) still ends it after the quotes go.
121
+ const appendName = (index) => {
122
+ IDENTIFIER.lastIndex = index + 1;
123
+ const match = IDENTIFIER.exec(source);
124
+ if (!match) return index;
125
+ append(`{${match[0]}}`);
126
+ return index + match[0].length;
127
+ };
116
128
  const flushWord = () => {
117
129
  if (word.started || word.text) words.push(word);
118
130
  word = { text: '', activeExpansion: false, started: false };
119
131
  };
120
- const flushSegment = () => { flushWord(); if (words.length) segments.push(words); words = []; };
132
+ // `link` is how the segment ends: 'pipe' for a single |, anything else for ;, &&, ||, &, newline and parentheses.
133
+ const flushSegment = (link = 'end') => { flushWord(); if (words.length) { words.link = link; segments.push(words); } words = []; };
121
134
 
122
135
  for (let index = 0; index < source.length; index += 1) {
123
136
  const ch = source[index];
@@ -132,17 +145,17 @@ function scanShell(command) {
132
145
  if (source[index + 1] === '(') {
133
146
  const end = matchingParen(source, index + 1);
134
147
  if (end < 0) { malformed = true; continue; }
135
- nested.push(source.slice(index + 2, end));
148
+ recordNested(source.slice(index + 2, end));
136
149
  append('()');
137
150
  index = end;
138
- }
151
+ } else index = appendName(index);
139
152
  continue;
140
153
  }
141
154
  if (ch === '\x60') {
142
155
  word.activeExpansion = true;
143
156
  const end = matchingBacktick(source, index);
144
157
  if (end < 0) { malformed = true; continue; }
145
- nested.push(source.slice(index + 1, end));
158
+ recordNested(source.slice(index + 1, end));
146
159
  append('\x60\x60');
147
160
  index = end;
148
161
  continue;
@@ -164,17 +177,17 @@ function scanShell(command) {
164
177
  if (source[index + 1] === '(') {
165
178
  const end = matchingParen(source, index + 1);
166
179
  if (end < 0) { malformed = true; continue; }
167
- nested.push(source.slice(index + 2, end));
180
+ recordNested(source.slice(index + 2, end));
168
181
  append('()');
169
182
  index = end;
170
- }
183
+ } else index = appendName(index);
171
184
  continue;
172
185
  }
173
186
  if (ch === '\x60') {
174
187
  word.activeExpansion = true;
175
188
  const end = matchingBacktick(source, index);
176
189
  if (end < 0) { malformed = true; continue; }
177
- nested.push(source.slice(index + 1, end));
190
+ recordNested(source.slice(index + 1, end));
178
191
  append('\x60\x60');
179
192
  index = end;
180
193
  continue;
@@ -183,15 +196,35 @@ function scanShell(command) {
183
196
  word.activeExpansion = true;
184
197
  const end = matchingParen(source, index + 1);
185
198
  if (end < 0) { malformed = true; continue; }
186
- nested.push(source.slice(index + 2, end));
199
+ recordNested(source.slice(index + 2, end));
187
200
  append(`${ch}()`);
188
201
  index = end;
189
202
  continue;
190
203
  }
191
204
  if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
192
- if (';|&\n()'.includes(ch)) { flushSegment(); continue; }
193
- if (ch === '<') { flushWord(); words.push({ text: '<', activeExpansion: false, started: true }); continue; }
194
- if (ch === '>') { flushWord(); continue; }
205
+ if (';|&\n()'.includes(ch)) {
206
+ const pipe = ch === '|' && source[index + 1] !== '|' && source[index - 1] !== '|' && source[index + 1] !== '&';
207
+ flushSegment(pipe ? 'pipe' : 'other');
208
+ continue;
209
+ }
210
+ if (ch === '<') {
211
+ const descriptor = /^[0-9]+$/.test(word.text);
212
+ flushWord();
213
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
214
+ (words.inputRedirects ||= []).push(words.length, words.length + 1);
215
+ words.push({ text: '<', activeExpansion: false, started: true });
216
+ continue;
217
+ }
218
+ if (ch === '>') {
219
+ const descriptor = /^[0-9]+$/.test(word.text);
220
+ flushWord();
221
+ if (descriptor) (words.redirectFds ||= []).push(words.length - 1);
222
+ (words.redirects ||= []).push(words.length);
223
+ if (source[index + 1] === '>') index += 1;
224
+ if (source[index + 1] === '&') index += 1;
225
+ if (source[index + 1] === '|') index += 1;
226
+ continue;
227
+ }
195
228
  if (' \t\r'.includes(ch)) { flushWord(); continue; }
196
229
  append(ch);
197
230
  }
@@ -216,9 +249,17 @@ function skipOption(words, index, valueOptions = WRAPPER_OPTION_VALUE) {
216
249
  return valueOptions.has(value) ? index + 2 : index + 1;
217
250
  }
218
251
 
252
+ // Words that open or continue a compound command; the command they lead into is still the one that runs.
253
+ const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '{', '!', 'time', 'coproc', 'function']);
254
+
219
255
  function unwrap(words) {
220
256
  let index = 0;
221
- while (index < words.length && assignment(words[index])) index += 1;
257
+ while (index < words.length && (assignment(words[index]) || KEYWORDS.has(words[index].text))) {
258
+ index += 1;
259
+ if (words[index - 1].text === 'time') while (words[index]?.text.startsWith('-')) index += 1;
260
+ // `function f { cat .env; }`: the word after `function` is the name being defined, not a command.
261
+ if (words[index - 1].text === 'function') index += 1;
262
+ }
222
263
  for (let depth = 0; depth < 12 && index < words.length; depth += 1) {
223
264
  const name = commandName(words[index]);
224
265
  if (!WRAPPERS.has(name)) return { index, name };
@@ -243,8 +284,19 @@ function unwrap(words) {
243
284
 
244
285
  function addOperand(result, word) {
245
286
  if (!word) return;
246
- if (word.activeExpansion) result.dynamic = true;
247
- else if (word.text && word.text !== '-') result.paths.add(word.text);
287
+ if (word.activeExpansion) {
288
+ result.dynamic = true;
289
+ // Kept for the path resolver: the raw text, the directory a tracked `cd` left, and the literal words of every
290
+ // command substitution inside it. The assignments it can see are attached when its segment finishes.
291
+ result.expansions.push({ text: word.text, directory: result.directory === undefined ? '.' : result.directory,
292
+ literals: (word.nested || []).flatMap(literalWords) });
293
+ } else if (word.text && word.text !== '-') result.paths.add(word.text);
294
+ }
295
+
296
+ // The literal words of a command line: no expansion, no option.
297
+ function literalWords(command) {
298
+ return scanShell(command).segments.flat().filter((word) => !word.activeExpansion && word.text && !word.text.startsWith('-'))
299
+ .map((word) => word.text);
248
300
  }
249
301
 
250
302
  function genericOperands(words, start, result, {
@@ -272,11 +324,154 @@ function genericOperands(words, start, result, {
272
324
 
273
325
  function merge(target, source) {
274
326
  source.paths.forEach((value) => target.paths.add(value));
327
+ target.searches.push(...source.searches);
328
+ source.writes.forEach(value => target.writes.add(value));
329
+ source.unknownWrites.forEach(value => target.unknownWrites.add(value));
330
+ target.expansions.push(...source.expansions);
331
+ target.pipeNames.push(...source.pipeNames);
332
+ target.decodedRead ||= source.decodedRead;
275
333
  target.dynamic ||= source.dynamic;
276
334
  target.malformed ||= source.malformed;
277
335
  }
278
336
 
279
- function analyzeWords(words, result, depth) {
337
+ // Describe searches separately so the existing read operands stay unchanged.
338
+ function searchDescription(words, start, name) {
339
+ const grep = ['grep', 'egrep', 'fgrep'].includes(name);
340
+ const search = { recursive: !grep, hidden: grep, includes: [], excludes: [], roots: [] };
341
+ const values = new Set([...GREP_OPTION_VALUE, '-g', '--glob', '--iglob', '--include', '--exclude',
342
+ '--exclude-dir', '-d', '--directories', '-t', '--type', '-T', '--type-not',
343
+ '--max-depth', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr',
344
+ '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit']);
345
+ let pattern = !grep && words.slice(start).some(word => word.text === '--files');
346
+ let unrestricted = 0;
347
+ let options = true;
348
+ const positional = [];
349
+ for (let index = start; index < words.length; index += 1) {
350
+ const word = words[index];
351
+ const text = word.text;
352
+ if (options && text === '--') { options = false; continue; }
353
+ if (options && text.startsWith('-') && text !== '-') {
354
+ let option = text.split('=')[0];
355
+ let value = text.includes('=') ? text.slice(text.indexOf('=') + 1) : null;
356
+ let shortFlags = /^-[^-]/.test(text) ? text.slice(1) : '';
357
+ const attached = text.match(/^-([^-]*?)([gef])(.*)$/);
358
+ if (attached) { shortFlags = attached[1]; option = `-${attached[2]}`; value = attached[3] || null; }
359
+ if (values.has(option) && value === null) value = words[++index]?.text || '';
360
+ if (['-e', '--regexp', '-f', '--file'].includes(option)) pattern = true;
361
+ if (['-g', '--glob', '--include'].includes(option)) search.includes.push(value || '');
362
+ if (['--exclude', '--exclude-dir'].includes(option)) search.excludes.push(value || '');
363
+ if (grep && (['--recursive', '--dereference-recursive'].includes(option)
364
+ || /[rR]/.test(shortFlags)
365
+ || (['-d', '--directories'].includes(option) && value === 'recurse'))) search.recursive = true;
366
+ if (!grep) {
367
+ if (option === '--hidden' || shortFlags.includes('.')) search.hidden = true;
368
+ unrestricted += (shortFlags.match(/u/g) || []).length + (option === '--unrestricted' ? 1 : 0);
369
+ }
370
+ continue;
371
+ }
372
+ positional.push(word);
373
+ }
374
+ if (!pattern) positional.shift();
375
+ search.roots = positional.filter(word => !word.activeExpansion && word.text !== '-').map(word => word.text);
376
+ if (!search.roots.length) search.roots.push('.');
377
+ if (unrestricted >= (name === 'ag' ? 1 : 2)) search.hidden = true;
378
+ // Case-insensitive globs are not evidence that a differently cased child is excluded.
379
+ if (words.slice(start).some(word => ['--glob-case-insensitive', '--iglob'].includes(word.text.split('=')[0]))) search.includes = [];
380
+ return search;
381
+ }
382
+
383
+ function writeTarget(word, result, directory) {
384
+ if (!word?.text || word.activeExpansion || word.text === '-') return;
385
+ if (directory === null && !word.text.startsWith('/')) {
386
+ result.unknownWrites.add(word.text);
387
+ return;
388
+ }
389
+ result.writes.add(word.text.startsWith('/') || directory === '.' ? word.text : `${directory}/${word.text}`);
390
+ }
391
+
392
+ function commandWrites(words, start, name, result, directory) {
393
+ const redirected = new Set([...(words.redirects || []), ...(words.redirectFds || []), ...(words.inputRedirects || [])]);
394
+ if (name === 'tee') {
395
+ for (let index = start; index < words.length; index += 1) {
396
+ if (!redirected.has(index) && !words[index].text.startsWith('-')) writeTarget(words[index], result, directory);
397
+ }
398
+ }
399
+ if (['cp', 'mv', 'install', 'ln'].includes(name)) {
400
+ const operands = [];
401
+ let targetDirectory = false;
402
+ const values = new Set(['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group']);
403
+ let options = true;
404
+ for (let index = start; index < words.length; index += 1) {
405
+ if (redirected.has(index)) continue;
406
+ const word = words[index];
407
+ if (options && word.text === '--') { options = false; continue; }
408
+ if (options && word.text.startsWith('-')) {
409
+ let option = word.text.split('=')[0];
410
+ let value = word.text.includes('=')
411
+ ? { text: word.text.slice(word.text.indexOf('=') + 1), activeExpansion: word.activeExpansion }
412
+ : values.has(option) ? words[++index] : null;
413
+ if (!word.text.startsWith('--') && !values.has(option)) {
414
+ for (let flag = 1; flag < word.text.length; flag += 1) {
415
+ const candidate = `-${word.text[flag]}`;
416
+ if (!values.has(candidate)) continue;
417
+ option = candidate;
418
+ value = flag + 1 < word.text.length
419
+ ? { text: word.text.slice(flag + 1), activeExpansion: word.activeExpansion } : words[++index];
420
+ break;
421
+ }
422
+ }
423
+ if (['-t', '--target-directory'].includes(option)) {
424
+ targetDirectory = true;
425
+ writeTarget(value, result, directory);
426
+ }
427
+ } else operands.push(word);
428
+ }
429
+ if (!targetDirectory) writeTarget(operands.at(-1), result, directory);
430
+ if (name === 'cp' || name === 'mv') {
431
+ const sources = targetDirectory ? operands : operands.slice(0, -1);
432
+ for (const source of sources) {
433
+ const basename = source.text.replace(/\\/g, '/').replace(/\/+$/, '').split('/').at(-1).toLowerCase();
434
+ if (!['.claude', '.codex', '.omp', '.privacy'].includes(basename)) continue;
435
+ if (source.activeExpansion) result.unknownWrites.add(basename);
436
+ else writeTarget(source, result, directory);
437
+ }
438
+ }
439
+ }
440
+ if (name === 'sed') {
441
+ let inPlace = false;
442
+ let scriptOption = false;
443
+ const operands = [];
444
+ let options = true;
445
+ for (let index = start; index < words.length; index += 1) {
446
+ if (redirected.has(index)) continue;
447
+ const word = words[index];
448
+ if (options && word.text === '--') { options = false; continue; }
449
+ if (options && word.text.startsWith('-')) {
450
+ const option = word.text.split('=')[0];
451
+ if (option === '--in-place') inPlace = true;
452
+ if (['--expression', '--file'].includes(option)) {
453
+ scriptOption = true;
454
+ if (!word.text.includes('=')) index += 1;
455
+ } else if (!word.text.startsWith('--')) {
456
+ for (let flag = 1; flag < word.text.length; flag += 1) {
457
+ if (word.text[flag] === 'i') { inPlace = true; break; }
458
+ if (word.text[flag] === 'e' || word.text[flag] === 'f') {
459
+ scriptOption = true;
460
+ if (flag + 1 === word.text.length) index += 1;
461
+ break;
462
+ }
463
+ }
464
+ }
465
+ } else operands.push(word);
466
+ }
467
+ if (operands[0]?.text === '') operands.shift();
468
+ if (inPlace) for (const word of operands.slice(scriptOption ? 0 : 1)) writeTarget(word, result, directory);
469
+ }
470
+ }
471
+
472
+ function analyzeWords(words, result, depth, directory = '.') {
473
+ result.directory = directory;
474
+ for (const index of words.redirects || []) writeTarget(words[index], result, directory);
280
475
  for (let index = 0; index + 1 < words.length; index += 1) {
281
476
  if (words[index].text === '<') addOperand(result, words[index + 1]);
282
477
  }
@@ -284,11 +479,13 @@ function analyzeWords(words, result, depth) {
284
479
  if (command.index < 0) return;
285
480
  const start = command.index + 1;
286
481
  const name = command.name;
482
+ commandWrites(words, start, name, result, directory);
287
483
  if (SIMPLE_READERS.has(name)) {
288
484
  genericOperands(words, start, result, { valueOptions: SIMPLE_OPTION_VALUES.get(name) || new Set() });
289
485
  return;
290
486
  }
291
487
  if (GREP_READERS.has(name)) {
488
+ result.searches.push(searchDescription(words, start, name));
292
489
  const patternOption = words.slice(start).some((word) => ['-e', '--regexp', '-f', '--file'].includes(word.text.split('=')[0]));
293
490
  genericOperands(words, start, result, {
294
491
  skipFirst: patternOption ? 0 : 1,
@@ -319,7 +516,7 @@ function analyzeWords(words, result, depth) {
319
516
  if (SHELLS.has(name)) {
320
517
  for (let index = start; index + 1 < words.length; index += 1) {
321
518
  if (words[index].text === '-c' || words[index].text === '--command') {
322
- merge(result, analyzeCommand(words[index + 1].text, depth + 1));
519
+ merge(result, analyzeCommand(words[index + 1].text, depth + 1, directory));
323
520
  return;
324
521
  }
325
522
  }
@@ -327,15 +524,21 @@ function analyzeWords(words, result, depth) {
327
524
  return;
328
525
  }
329
526
  if (name === 'eval') {
330
- merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1));
527
+ merge(result, analyzeCommand(words.slice(start).map((word) => word.text).join(' '), depth + 1, directory));
331
528
  return;
332
529
  }
333
530
  if (name === 'find') {
531
+ // A name test that selects a secret, handed to a reader through -exec, reads it.
532
+ const names = [];
533
+ for (let index = start; index + 1 < words.length; index += 1) {
534
+ if (['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'].includes(words[index].text)) names.push(words[index + 1].text);
535
+ }
334
536
  for (let index = start; index < words.length; index += 1) {
335
537
  if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
336
538
  const nested = [];
337
539
  for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
338
- analyzeWords(nested, result, depth + 1);
540
+ if (READS.has(unwrap(nested).name)) result.pipeNames.push(...names);
541
+ analyzeWords(nested, result, depth + 1, directory);
339
542
  }
340
543
  return;
341
544
  }
@@ -351,7 +554,7 @@ function analyzeWords(words, result, depth) {
351
554
  index = skipOption(words, index, XARGS_OPTION_VALUE);
352
555
  nestedStart = index;
353
556
  }
354
- if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1);
557
+ if (nestedStart < words.length) analyzeWords(words.slice(nestedStart), result, depth + 1, directory);
355
558
  return;
356
559
  }
357
560
  if (INLINE_RUNTIMES.has(name)) {
@@ -369,13 +572,45 @@ function analyzeWords(words, result, depth) {
369
572
  if (name === 'openssl') genericOperands(words, start, result, { pathOptions: new Set(['-in', '-key', '-cert', '-config']) });
370
573
  }
371
574
 
372
- function analyzeCommand(command, depth = 0) {
373
- const result = { paths: new Set(), dynamic: false, malformed: false };
575
+ function analyzeCommand(command, depth = 0, directory = '.') {
576
+ const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set(),
577
+ expansions: [], pipeNames: [], decodedRead: false };
374
578
  if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
375
579
  const parsed = scanShell(command);
376
580
  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));
581
+ let currentDirectory = directory;
582
+ const directories = [];
583
+ const assignments = [];
584
+ const visibleAt = [];
585
+ // Every operand found while analysing a segment -- its own, or one from a nested eval, `bash -c` or $(...) -- sees the
586
+ // assignments of the earlier segments. `f=.env cat $f` does not: the shell expands $f before that prefix applies.
587
+ const scope = (from, segmentIndex) => {
588
+ for (const expansion of result.expansions.slice(from)) {
589
+ expansion.scopes = [{ list: assignments, count: visibleAt[segmentIndex] }, ...(expansion.scopes || [])];
590
+ }
591
+ };
592
+ for (const [segmentIndex, words] of parsed.segments.entries()) {
593
+ directories.push(currentDirectory);
594
+ visibleAt.push(assignments.length);
595
+ recordAssignments(words, assignments);
596
+ const from = result.expansions.length;
597
+ analyzeWords(words, result, depth, currentDirectory);
598
+ scope(from, segmentIndex);
599
+ const info = unwrap(words);
600
+ if (['cd', 'pushd'].includes(info.name)) {
601
+ const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
602
+ if (!target || target.activeExpansion) currentDirectory = null;
603
+ else if (target.text.startsWith('/')) currentDirectory = target.text;
604
+ else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
605
+ }
606
+ }
607
+ analyzePipelines(parsed.segments, result);
608
+ for (const nested of parsed.nested) {
609
+ const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
610
+ const from = result.expansions.length;
611
+ merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
612
+ if (visibleAt[nested.segment] !== undefined) scope(from, nested.segment);
613
+ }
379
614
  if (result.malformed && parsed.segments.some((words) => {
380
615
  const commandInfo = unwrap(words);
381
616
  return SIMPLE_READERS.has(commandInfo.name) || GREP_READERS.has(commandInfo.name)
@@ -385,9 +620,67 @@ function analyzeCommand(command, depth = 0) {
385
620
  return result;
386
621
  }
387
622
 
623
+ const ASSIGNING = new Set(['export', 'declare', 'typeset', 'local', 'readonly']);
624
+
625
+ // A segment that only assigns (`f=.env`), or an export/declare/local/typeset/readonly. The value keeps its expansions
626
+ // (`f="$PWD/.env"`, `G=$F`); the resolver substitutes them in order.
627
+ function recordAssignments(words, out) {
628
+ const bare = words.every(assignment);
629
+ const builtin = ASSIGNING.has(commandName(words[0]));
630
+ if (!bare && !builtin) return;
631
+ for (const word of words.slice(bare ? 0 : 1)) {
632
+ if (!assignment(word)) continue;
633
+ const at = word.text.indexOf('=');
634
+ out.push({ name: word.text.slice(0, at), value: word.text.slice(at + 1) });
635
+ }
636
+ }
637
+
638
+ const DECODERS = (words) => {
639
+ const name = commandName(words[unwrap(words).index]);
640
+ const flags = words.map((word) => word.text);
641
+ if (name === 'base64' || name === 'basenc') return flags.some((flag) => ['-d', '-D', '--decode'].includes(flag));
642
+ if (name === 'openssl') return flags.some((flag) => ['base64', 'enc'].includes(flag)) && flags.includes('-d');
643
+ if (name === 'xxd') return flags.includes('-r') || flags.includes('-revert');
644
+ if (name === 'gpg') return flags.some((flag) => ['-d', '--decrypt'].includes(flag));
645
+ return ['uudecode', 'b64decode'].includes(name);
646
+ };
647
+ const READS = new Set([...SIMPLE_READERS, ...GREP_READERS, ...AWK_READERS, 'sed', 'source', '.']);
648
+
649
+ // A pipeline whose decoder feeds a shell, eval or xargs runs what it decoded; xargs handing a reader the literal words of
650
+ // an earlier stage (`printf .env | xargs cat`, `find . -name .env | xargs cat`) reads those words. One pass: each stage
651
+ // is looked at once, and each earlier word is handed over at most once.
652
+ function analyzePipelines(segments, result) {
653
+ let decoded = false;
654
+ let first = true;
655
+ let words = [];
656
+ let handed = 0;
657
+ for (const stage of segments) {
658
+ const info = unwrap(stage);
659
+ if (info.index >= 0 && !first) {
660
+ if (decoded && (SHELLS.has(info.name) || ['eval', 'xargs', 'source', '.'].includes(info.name))) result.decodedRead = true;
661
+ if (info.name === 'xargs') {
662
+ let index = info.index + 1;
663
+ while (index < stage.length && stage[index].text.startsWith('-')) index = skipOption(stage, index, XARGS_OPTION_VALUE);
664
+ const reader = unwrap(stage.slice(index)).name;
665
+ if (READS.has(reader) || SHELLS.has(reader)) {
666
+ result.pipeNames.push(...words.slice(handed));
667
+ handed = words.length;
668
+ }
669
+ }
670
+ }
671
+ if (DECODERS(stage)) decoded = true;
672
+ for (const word of stage.slice(info.index + 1)) if (word.text && !word.text.startsWith('-')) words.push(word.text);
673
+ first = stage.link !== 'pipe';
674
+ if (first) { decoded = false; words = []; handed = 0; }
675
+ }
676
+ }
677
+
388
678
  function commandAccess(command) {
389
679
  const result = analyzeCommand(command);
390
- return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed };
680
+ return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
681
+ writes: [...result.writes], unknownWrites: [...result.unknownWrites],
682
+ expansions: result.expansions.map(({ text, directory, literals, scopes }) => ({ text, directory, literals, scopes: scopes || [] })),
683
+ pipeNames: [...new Set(result.pipeNames)], decodedRead: result.decodedRead };
391
684
  }
392
685
 
393
686
  module.exports = { DYNAMIC_READ_PATH, commandAccess };