@haposoft/cafekit 0.18.2 → 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 (118) hide show
  1. package/README.md +1 -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__/codex-hooks.test.js +38 -6
  7. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  8. package/bin/__tests__/codex-native.test.js +15 -8
  9. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  10. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  11. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  12. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  13. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  14. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  15. package/bin/__tests__/migrate-apply.test.js +373 -0
  16. package/bin/__tests__/migrate-cli.test.js +152 -0
  17. package/bin/__tests__/migrate-merged.test.js +269 -0
  18. package/bin/__tests__/migrate-plan.test.js +271 -0
  19. package/bin/__tests__/omp-bridge.test.js +81 -5
  20. package/bin/__tests__/omp-hooks.test.js +24 -0
  21. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  22. package/bin/__tests__/privacy-paths.test.js +184 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  24. package/bin/__tests__/vibe-run.test.js +215 -0
  25. package/bin/__tests__/vibe-skill.test.js +549 -0
  26. package/bin/install.js +99 -1
  27. package/bin/lib/backup.js +43 -24
  28. package/bin/lib/codex-install.js +7 -0
  29. package/bin/lib/context.js +5 -0
  30. package/bin/lib/lock.js +98 -18
  31. package/bin/lib/managed-writer.js +16 -10
  32. package/bin/lib/migrate-apply.js +202 -0
  33. package/bin/lib/migrate-merged.js +342 -0
  34. package/bin/lib/migrate-plan.js +419 -0
  35. package/bin/lib/plugin-inventory.js +33 -0
  36. package/bin/phases/claude-runtime.js +5 -3
  37. package/bin/phases/codex-hooks.js +38 -0
  38. package/bin/phases/codex-runtime.js +106 -74
  39. package/bin/phases/copy-payload.js +49 -1
  40. package/bin/phases/omp-runtime.js +4 -26
  41. package/bin/phases/root-config.js +5 -2
  42. package/package.json +1 -1
  43. package/src/claude/CLAUDE.md +1 -1
  44. package/src/claude/agents/advisor.md +55 -0
  45. package/src/claude/agents/debugger.md +2 -3
  46. package/src/claude/agents/deployer.md +2 -1
  47. package/src/claude/agents/docs-keeper.md +12 -0
  48. package/src/claude/agents/inspector.md +1 -1
  49. package/src/claude/agents/project-manager.md +2 -2
  50. package/src/claude/agents/researcher.md +0 -1
  51. package/src/claude/agents/spec-maker.md +4 -1
  52. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  53. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  54. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  55. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  56. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
  57. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  58. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
  59. package/src/claude/hooks/agent.cjs +5 -2
  60. package/src/claude/hooks/inspect-block.cjs +3 -1
  61. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  62. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  63. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  64. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  65. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  66. package/src/claude/hooks/plugin-context.cjs +80 -0
  67. package/src/claude/hooks/privacy-block.cjs +2 -1
  68. package/src/claude/hooks/rules.cjs +5 -2
  69. package/src/claude/hooks/session.cjs +4 -1
  70. package/src/claude/hooks/spec-state.cjs +5 -4
  71. package/src/claude/hooks/state.cjs +5 -1
  72. package/src/claude/migration-manifest.json +6 -1
  73. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  74. package/src/claude/plugin/core.md +71 -0
  75. package/src/claude/rules/manage-docs.md +1 -2
  76. package/src/claude/rules/review-audit-self-decision.md +1 -1
  77. package/src/claude/rules/skill-workflow-routing.md +1 -1
  78. package/src/claude/rules/state-sync.md +14 -0
  79. package/src/claude/rules/workflow.md +1 -1
  80. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  81. package/src/claude/scripts/provenance.cjs +40 -16
  82. package/src/claude/scripts/setup-statusline.cjs +88 -0
  83. package/src/claude/skills/adv/SKILL.md +139 -0
  84. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  85. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  86. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  87. package/src/claude/skills/code-review/SKILL.md +4 -1
  88. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  89. package/src/claude/skills/develop/SKILL.md +5 -3
  90. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  91. package/src/claude/skills/fix/SKILL.md +2 -2
  92. package/src/claude/skills/git/SKILL.md +4 -2
  93. package/src/claude/skills/git/references/merge-pr.md +125 -0
  94. package/src/claude/skills/specs/SKILL.md +2 -2
  95. package/src/claude/skills/specs/references/review.md +1 -1
  96. package/src/claude/skills/specs/references/templates.md +2 -0
  97. package/src/claude/skills/vibe/SKILL.md +69 -0
  98. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  99. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  100. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  101. package/src/codex/AGENTS.md +1 -1
  102. package/src/codex/hooks/inspect-block.cjs +7 -4
  103. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  104. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  105. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  106. package/src/codex/hooks/lib/state-store.cjs +5 -4
  107. package/src/codex/hooks/precompact.cjs +9 -8
  108. package/src/codex/hooks/privacy-approval.cjs +2 -2
  109. package/src/codex/hooks/privacy-block.cjs +5 -4
  110. package/src/codex/hooks/rules.cjs +12 -6
  111. package/src/codex/hooks/session.cjs +22 -10
  112. package/src/codex/plugin/core.md +76 -0
  113. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  114. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  115. package/src/codex/scripts/setup-agents.cjs +124 -0
  116. package/src/common/AGENTS.md +1 -1
  117. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  118. package/src/omp/hooks/privacy-block.cjs +2 -1
@@ -45,12 +45,15 @@ try {
45
45
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
46
46
  const payload = normalizeHookPayload(JSON.parse(stdin));
47
47
  const root = projectRoot(payload);
48
- const runtime = readRuntime(root);
48
+ // An npx install always has runtime.json; under the plugin it is optional.
49
+ // Loaded only under the plugin, so npx installs need no extra hook file.
50
+ const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
51
+ const runtime = readRuntime(root) || (plugin ? {} : null);
49
52
  if (runtime === null) process.exit(0);
50
53
 
51
54
  // Language config from runtime.json
52
55
  const thinkLang = runtime.locale?.thinkingLanguage || '';
53
- const respondLang = runtime.locale?.responseLanguage || '';
56
+ const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
54
57
  // Default thinking to 'en' when only response language is set
55
58
  const effectThink = thinkLang || (respondLang ? 'en' : '');
56
59
 
@@ -60,7 +60,9 @@ function denyWithReason(reason) {
60
60
  const root = projectRoot(data);
61
61
  const runtime = readRuntime(root);
62
62
 
63
- if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
63
+ // A plugin is trusted once for every repository, so a repository cannot switch it off.
64
+ const disabled = runtime.scout?.enabled === false || runtime.inspect?.enabled === false;
65
+ if (disabled && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
64
66
 
65
67
  const result = inspectInput(toolName, toolInput, {
66
68
  cwd, projectRoot: root
@@ -29,11 +29,29 @@ function isSourceTree(dir) {
29
29
  return String(dir || '').includes(SOURCE_MARKER);
30
30
  }
31
31
 
32
+ /**
33
+ * State root for hooks running from the Claude Code plugin, or null outside it.
34
+ * The plugin root is replaced on every update and the project must stay clean,
35
+ * so plugin state lives in CLAUDE_PLUGIN_DATA, or the OS temp directory when the
36
+ * host gives no usable data directory.
37
+ */
38
+ function pluginStateRoot() {
39
+ if (!process.env.CLAUDE_PLUGIN_ROOT) return null;
40
+ const data = process.env.CLAUDE_PLUGIN_DATA;
41
+ if (data && path.isAbsolute(data)) return data;
42
+ // Per-user name so accounts sharing /tmp do not collide; this is a fallback
43
+ // only, since the host sets CLAUDE_PLUGIN_DATA for plugin hooks.
44
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
45
+ return path.join(os.tmpdir(), `cafekit-plugin-${uid}`);
46
+ }
47
+
32
48
  /** Absolute directory for hook-generated state; create it before writing. */
33
49
  function hookStateDir() {
50
+ const pluginRoot = pluginStateRoot();
51
+ if (pluginRoot) return path.join(pluginRoot, '.logs');
34
52
  return isSourceTree(HOOKS_DIR)
35
53
  ? path.join(os.tmpdir(), 'cafekit-hook-logs', require('./runtime-dir.cjs').runtimeDirName().slice(1))
36
54
  : path.join(HOOKS_DIR, '.logs');
37
55
  }
38
56
 
39
- module.exports = { hookStateDir, isSourceTree };
57
+ module.exports = { hookStateDir, isSourceTree, pluginStateRoot };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Claude Code plugin options for the hooks.
5
+ *
6
+ * The plugin's userConfig values reach hooks as CLAUDE_PLUGIN_OPTION_<KEY> and are
7
+ * injected into model context verbatim, so each one is reduced to a single short
8
+ * line: no control, bidi, or zero-width characters or line breaks that could open a
9
+ * new section or disguise the text, and
10
+ * at most 64 characters so the core stays inside the host's context budget.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const MAX_OPTION_LENGTH = 64;
17
+
18
+ /**
19
+ * True when the hook runs from the Claude Code plugin rather than an npx install.
20
+ * The variable alone is not enough: an npx hook can inherit it from a parent process,
21
+ * so this file must really live under that plugin root.
22
+ */
23
+ function isPlugin() {
24
+ const declared = process.env.CLAUDE_PLUGIN_ROOT;
25
+ if (!declared) return false;
26
+ try {
27
+ return fs.realpathSync(declared) === fs.realpathSync(path.resolve(__dirname, '..', '..'));
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** One short plain line: no control, bidi, or zero-width characters; at most 64 characters. */
34
+ function sanitizeLine(raw) {
35
+ if (typeof raw !== 'string') return '';
36
+ return [...raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim()]
37
+ .slice(0, MAX_OPTION_LENGTH)
38
+ .join('')
39
+ .trim();
40
+ }
41
+
42
+ /** Sanitized option value, or '' when unset. `key` is the userConfig key, e.g. `response_language`. */
43
+ function pluginOption(key) {
44
+ return sanitizeLine(process.env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`]);
45
+ }
46
+
47
+ module.exports = { MAX_OPTION_LENGTH, isPlugin, pluginOption, sanitizeLine };
@@ -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,18 +109,28 @@ function scanShell(command) {
107
109
  const source = String(command).replace(/\\\r?\n/g, '');
108
110
  const segments = [];
109
111
  const nested = [];
110
- const recordNested = command => nested.push({ command, segment: segments.length });
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); };
111
114
  let words = [];
112
115
  let word = { text: '', activeExpansion: false, started: false };
113
116
  let quote = null;
114
117
  let escaped = false;
115
118
  let malformed = false;
116
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
+ };
117
128
  const flushWord = () => {
118
129
  if (word.started || word.text) words.push(word);
119
130
  word = { text: '', activeExpansion: false, started: false };
120
131
  };
121
- 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 = []; };
122
134
 
123
135
  for (let index = 0; index < source.length; index += 1) {
124
136
  const ch = source[index];
@@ -136,7 +148,7 @@ function scanShell(command) {
136
148
  recordNested(source.slice(index + 2, end));
137
149
  append('()');
138
150
  index = end;
139
- }
151
+ } else index = appendName(index);
140
152
  continue;
141
153
  }
142
154
  if (ch === '\x60') {
@@ -168,7 +180,7 @@ function scanShell(command) {
168
180
  recordNested(source.slice(index + 2, end));
169
181
  append('()');
170
182
  index = end;
171
- }
183
+ } else index = appendName(index);
172
184
  continue;
173
185
  }
174
186
  if (ch === '\x60') {
@@ -190,7 +202,11 @@ function scanShell(command) {
190
202
  continue;
191
203
  }
192
204
  if ((ch === '~' && !word.text) || '*?{['.includes(ch)) { word.activeExpansion = true; append(ch); continue; }
193
- if (';|&\n()'.includes(ch)) { flushSegment(); 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
+ }
194
210
  if (ch === '<') {
195
211
  const descriptor = /^[0-9]+$/.test(word.text);
196
212
  flushWord();
@@ -233,9 +249,17 @@ function skipOption(words, index, valueOptions = WRAPPER_OPTION_VALUE) {
233
249
  return valueOptions.has(value) ? index + 2 : index + 1;
234
250
  }
235
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
+
236
255
  function unwrap(words) {
237
256
  let index = 0;
238
- 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
+ }
239
263
  for (let depth = 0; depth < 12 && index < words.length; depth += 1) {
240
264
  const name = commandName(words[index]);
241
265
  if (!WRAPPERS.has(name)) return { index, name };
@@ -260,8 +284,19 @@ function unwrap(words) {
260
284
 
261
285
  function addOperand(result, word) {
262
286
  if (!word) return;
263
- if (word.activeExpansion) result.dynamic = true;
264
- 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);
265
300
  }
266
301
 
267
302
  function genericOperands(words, start, result, {
@@ -292,6 +327,9 @@ function merge(target, source) {
292
327
  target.searches.push(...source.searches);
293
328
  source.writes.forEach(value => target.writes.add(value));
294
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;
295
333
  target.dynamic ||= source.dynamic;
296
334
  target.malformed ||= source.malformed;
297
335
  }
@@ -432,6 +470,7 @@ function commandWrites(words, start, name, result, directory) {
432
470
  }
433
471
 
434
472
  function analyzeWords(words, result, depth, directory = '.') {
473
+ result.directory = directory;
435
474
  for (const index of words.redirects || []) writeTarget(words[index], result, directory);
436
475
  for (let index = 0; index + 1 < words.length; index += 1) {
437
476
  if (words[index].text === '<') addOperand(result, words[index + 1]);
@@ -489,10 +528,16 @@ function analyzeWords(words, result, depth, directory = '.') {
489
528
  return;
490
529
  }
491
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
+ }
492
536
  for (let index = start; index < words.length; index += 1) {
493
537
  if (words[index].text !== '-exec' && words[index].text !== '-execdir') continue;
494
538
  const nested = [];
495
539
  for (index += 1; index < words.length && ![';', '+'].includes(words[index].text); index += 1) nested.push(words[index]);
540
+ if (READS.has(unwrap(nested).name)) result.pipeNames.push(...names);
496
541
  analyzeWords(nested, result, depth + 1, directory);
497
542
  }
498
543
  return;
@@ -528,15 +573,29 @@ function analyzeWords(words, result, depth, directory = '.') {
528
573
  }
529
574
 
530
575
  function analyzeCommand(command, depth = 0, directory = '.') {
531
- const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set() };
576
+ const result = { paths: new Set(), dynamic: false, malformed: false, searches: [], writes: new Set(), unknownWrites: new Set(),
577
+ expansions: [], pipeNames: [], decodedRead: false };
532
578
  if (depth > MAX_DEPTH) { result.dynamic = true; return result; }
533
579
  const parsed = scanShell(command);
534
580
  result.malformed = parsed.malformed;
535
581
  let currentDirectory = directory;
536
582
  const directories = [];
537
- for (const words of parsed.segments) {
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()) {
538
593
  directories.push(currentDirectory);
594
+ visibleAt.push(assignments.length);
595
+ recordAssignments(words, assignments);
596
+ const from = result.expansions.length;
539
597
  analyzeWords(words, result, depth, currentDirectory);
598
+ scope(from, segmentIndex);
540
599
  const info = unwrap(words);
541
600
  if (['cd', 'pushd'].includes(info.name)) {
542
601
  const target = words.slice(info.index + 1).find(word => word.text && !word.text.startsWith('-'));
@@ -545,9 +604,12 @@ function analyzeCommand(command, depth = 0, directory = '.') {
545
604
  else if (currentDirectory !== null) currentDirectory = `${currentDirectory}/${target.text}`;
546
605
  }
547
606
  }
607
+ analyzePipelines(parsed.segments, result);
548
608
  for (const nested of parsed.nested) {
549
609
  const nestedDirectory = directories[nested.segment] === undefined ? directory : directories[nested.segment];
610
+ const from = result.expansions.length;
550
611
  merge(result, analyzeCommand(nested.command, depth + 1, nestedDirectory));
612
+ if (visibleAt[nested.segment] !== undefined) scope(from, nested.segment);
551
613
  }
552
614
  if (result.malformed && parsed.segments.some((words) => {
553
615
  const commandInfo = unwrap(words);
@@ -558,10 +620,67 @@ function analyzeCommand(command, depth = 0, directory = '.') {
558
620
  return result;
559
621
  }
560
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
+
561
678
  function commandAccess(command) {
562
679
  const result = analyzeCommand(command);
563
680
  return { paths: [...result.paths], dynamic: result.dynamic, malformed: result.malformed, searches: result.searches,
564
- writes: [...result.writes], unknownWrites: [...result.unknownWrites] };
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 };
565
684
  }
566
685
 
567
686
  module.exports = { DYNAMIC_READ_PATH, commandAccess };