@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.
- package/README.md +1 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/codex-hooks.test.js +38 -6
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native.test.js +15 -8
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +7 -0
- package/bin/lib/context.js +5 -0
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +5 -1
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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])
|
|
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)
|
|
264
|
-
|
|
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
|
-
|
|
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 };
|