@haposoft/cafekit 0.18.1 → 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.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const BLOCKED_DIRS = new Set([
|
|
6
|
+
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
7
|
+
'__pycache__', '.venv', 'venv', 'vendor', 'target',
|
|
8
|
+
'.git', 'coverage', '.nyc_output'
|
|
9
|
+
]);
|
|
10
|
+
const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
11
|
+
const PATH_KEYS = /^(?:file_?paths?|paths?|search_?paths?|notebook_path|(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|folders?|cwd|root|locations?|uris?)$/i;
|
|
12
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
13
|
+
const READERS = new Set(['cat', 'head', 'tail', 'nl', 'less', 'more', 'bat', 'ls', 'tree', 'find', 'rg', 'grep', 'sed', 'awk', 'wc', 'du', 'file', 'stat']);
|
|
14
|
+
const clean = (value) => String(value || '').trim();
|
|
15
|
+
const nameOf = (value) => String(value || '').replace(/\\/g, '/').split('/').pop();
|
|
16
|
+
function real(value) { try { return fs.realpathSync(value); } catch { return value; } }
|
|
17
|
+
function relativeInside(root, candidate) {
|
|
18
|
+
const relative = path.relative(root, candidate);
|
|
19
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : null;
|
|
20
|
+
}
|
|
21
|
+
function blockedSegment(value, { cwd, projectRoot }) {
|
|
22
|
+
let candidate = clean(value).replace(/\\/g, '/');
|
|
23
|
+
if (candidate === '~' || candidate.startsWith('~/')) candidate = os.homedir() + candidate.slice(1);
|
|
24
|
+
if (path.isAbsolute(candidate)) {
|
|
25
|
+
// Prefer project root over a nested session cwd. Compare lexical and canonical
|
|
26
|
+
// roots so /var and /private/var (or a symlink install) have the same boundary.
|
|
27
|
+
for (const base of [projectRoot, cwd].filter(base => typeof base === 'string' && base)) {
|
|
28
|
+
const root = path.resolve(base);
|
|
29
|
+
const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
|
|
30
|
+
if (relative !== null) { candidate = relative; break; }
|
|
31
|
+
}
|
|
32
|
+
} else candidate = path.normalize(candidate);
|
|
33
|
+
return candidate.split('/').find((segment) => BLOCKED_DIRS.has(segment));
|
|
34
|
+
}
|
|
35
|
+
function narrowPath(value, context) {
|
|
36
|
+
if (context.cwd !== undefined && typeof context.cwd !== 'string') return false;
|
|
37
|
+
let requested = clean(value).replace(/\\/g, '/');
|
|
38
|
+
if (!requested) return false;
|
|
39
|
+
if (requested === '~' || requested.startsWith('~/')) requested = os.homedir() + requested.slice(1);
|
|
40
|
+
const root = path.resolve(context.projectRoot || context.cwd || process.cwd());
|
|
41
|
+
const candidate = path.resolve(context.cwd || root, requested);
|
|
42
|
+
const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
|
|
43
|
+
// Both boundaries must hold: a symlink cannot turn an outside read into a
|
|
44
|
+
// narrow scope, nor point a lexical child at the project root or beyond it.
|
|
45
|
+
const canonical = relative === null ? candidate : real(path.resolve(real(root), relative));
|
|
46
|
+
return !!relative && !!relativeInside(real(root), canonical)
|
|
47
|
+
&& !blockedSegment(candidate, context) && !blockedSegment(canonical, context);
|
|
48
|
+
}
|
|
49
|
+
function collectStructured(value, key, toolName, paths, globs) {
|
|
50
|
+
if (typeof value === 'string') {
|
|
51
|
+
if (PATH_KEYS.test(key)) paths.push(value);
|
|
52
|
+
if ((toolName === 'Glob' && key === 'pattern') || /^(?:glob|globs)$/i.test(key)) globs.push(value);
|
|
53
|
+
} else if (Array.isArray(value)) {
|
|
54
|
+
for (const child of value) collectStructured(child, key, toolName, paths, globs);
|
|
55
|
+
} else if (value && typeof value === 'object') {
|
|
56
|
+
for (const [childKey, child] of Object.entries(value)) collectStructured(child, childKey, toolName, paths, globs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A small shell lexer, not an executor: quoted prose is data, while substitutions
|
|
61
|
+
// contribute their own command groups. Heredoc bodies are data, never commands.
|
|
62
|
+
function commandGroups(command) {
|
|
63
|
+
function scan(start, closing = '') {
|
|
64
|
+
const groups = []; let group = []; let token = ''; let quote = ''; let index = start;
|
|
65
|
+
const heredocs = [];
|
|
66
|
+
const flush = () => { if (token) group.push(token); token = ''; };
|
|
67
|
+
const boundary = () => { flush(); if (group.length) groups.push(group); group = []; };
|
|
68
|
+
for (; index < command.length; index += 1) {
|
|
69
|
+
const char = command[index];
|
|
70
|
+
if (!quote && closing && char === closing) { boundary(); return { groups, index }; }
|
|
71
|
+
if (char === '\\' && quote !== "'") {
|
|
72
|
+
if (command[index + 1] !== '\n') token += command[index + 1] || '';
|
|
73
|
+
index += 1; continue;
|
|
74
|
+
}
|
|
75
|
+
if (quote === "'") { if (char === "'") quote = ''; else token += char; continue; }
|
|
76
|
+
if (char === '$' && command[index + 1] === '(') {
|
|
77
|
+
const sub = scan(index + 2, ')'); groups.push(...sub.groups);
|
|
78
|
+
token += '__substitution__'; index = sub.index; continue;
|
|
79
|
+
}
|
|
80
|
+
if (char === '`') {
|
|
81
|
+
const sub = scan(index + 1, '`'); groups.push(...sub.groups);
|
|
82
|
+
token += '__substitution__'; index = sub.index; continue;
|
|
83
|
+
}
|
|
84
|
+
if (quote) { if (char === quote) quote = ''; else token += char; continue; }
|
|
85
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
86
|
+
if (char === '(') {
|
|
87
|
+
boundary(); const sub = scan(index + 1, ')');
|
|
88
|
+
groups.push(...sub.groups); index = sub.index; continue;
|
|
89
|
+
}
|
|
90
|
+
if (command.startsWith('<<', index) && !command.startsWith('<<<', index)) {
|
|
91
|
+
const match = command.slice(index).match(/^<<(-?)[ \t]*((?:'[^']*'|"[^"]*"|\\[^\n]|[^\s;|&<>'"\\])+)/);
|
|
92
|
+
if (match) {
|
|
93
|
+
const delimiter = match[2].replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_, single, double, escaped) => single ?? double ?? escaped);
|
|
94
|
+
flush(); heredocs.push({ delimiter, tabs: !!match[1] });
|
|
95
|
+
index += match[0].length - 1; continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (char === '>' || char === '<') {
|
|
99
|
+
// Preserve operator identity (quoted > remains ordinary text). A numeric
|
|
100
|
+
// token touching the operator is its fd, not a positional argument.
|
|
101
|
+
if (/^\d+$/.test(token)) token = '';
|
|
102
|
+
flush();
|
|
103
|
+
const operator = command.slice(index).match(/^(?:>>|>&|>|<)/)[0];
|
|
104
|
+
group.push({ redirect: operator }); index += operator.length - 1; continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === '\n') {
|
|
107
|
+
boundary();
|
|
108
|
+
for (const here of heredocs.splice(0)) {
|
|
109
|
+
while (index < command.length) {
|
|
110
|
+
const end = command.indexOf('\n', index + 1);
|
|
111
|
+
const stop = end < 0 ? command.length : end;
|
|
112
|
+
const line = command.slice(index + 1, stop);
|
|
113
|
+
index = stop;
|
|
114
|
+
if ((here.tabs ? line.replace(/^\t+/, '') : line) === here.delimiter) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else if (';&|'.includes(char)) {
|
|
118
|
+
boundary();
|
|
119
|
+
if (command[index + 1] === char) index += 1;
|
|
120
|
+
} else if (/\s/.test(char)) flush();
|
|
121
|
+
else token += char;
|
|
122
|
+
}
|
|
123
|
+
boundary(); return { groups, index };
|
|
124
|
+
}
|
|
125
|
+
return scan(0).groups;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const WRAPPER_OPTIONS = {
|
|
129
|
+
sudo: ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D', '--user', '--group', '--host', '--prompt', '--chdir', '--chroot'],
|
|
130
|
+
env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
|
|
131
|
+
time: ['-f', '--format', '-o', '--output'], command: [], builtin: [],
|
|
132
|
+
nice: ['-n', '--adjustment'], nohup: [],
|
|
133
|
+
xargs: ['-I', '-i', '-n', '-L', '-P', '-s', '-d', '-E', '-a', '--replace', '--max-args', '--max-lines', '--max-procs', '--arg-file', '--delimiter', '--eof'],
|
|
134
|
+
npm: ['-p', '--package', '-c', '--call', '--prefix', '--workspace', '-w'],
|
|
135
|
+
npx: ['-p', '--package', '-c', '--call', '--cache'],
|
|
136
|
+
pnpm: ['--dir', '-C', '--filter'], pnpx: ['-p', '--package'],
|
|
137
|
+
yarn: ['--cwd'], bunx: ['-p', '--package'],
|
|
138
|
+
};
|
|
139
|
+
function unwrap(group, state) {
|
|
140
|
+
let index = 0;
|
|
141
|
+
while (index < group.length) {
|
|
142
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(group[index] || '')) index += 1;
|
|
143
|
+
const name = nameOf(group[index]);
|
|
144
|
+
if (!Object.hasOwn(WRAPPER_OPTIONS, name)) return group.slice(index);
|
|
145
|
+
if (name === 'xargs') state.unknownCwd = true;
|
|
146
|
+
index += 1;
|
|
147
|
+
// npm/pnpm/yarn only wrap a reader in exec mode; run/build remain opaque.
|
|
148
|
+
let exec = !['npm', 'pnpm', 'yarn'].includes(name);
|
|
149
|
+
while (index < group.length) {
|
|
150
|
+
const token = group[index];
|
|
151
|
+
const cwdOptions = { env: ['-C', '--chdir'], sudo: ['-D', '--chdir'],
|
|
152
|
+
npm: ['--prefix'], pnpm: ['-C', '--dir'], yarn: ['--cwd'] };
|
|
153
|
+
if (cwdOptions[name]?.some(option => String(token).split('=')[0] === option
|
|
154
|
+
|| (option.length === 2 && String(token).startsWith(option)))) state.unknownCwd = true;
|
|
155
|
+
if (name === 'env' && /^-[^-]*C/.test(token)) state.unknownCwd = true;
|
|
156
|
+
if (token === 'exec' && !exec) { exec = true; index += 1; continue; }
|
|
157
|
+
if (token === '--') { index += 1; break; }
|
|
158
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; }
|
|
159
|
+
const commandOption = name === 'env' ? /^(?:-S|--split-string)(?:=|$)/ : ['npm', 'npx'].includes(name) ? /^(?:-c|--call)(?:=|$)/ : null;
|
|
160
|
+
if (commandOption?.test(token)) {
|
|
161
|
+
const equals = token.indexOf('=');
|
|
162
|
+
return { command: equals < 0 ? group[index + 1] || '' : token.slice(equals + 1),
|
|
163
|
+
rest: name === 'env' ? group.slice(index + (equals < 0 ? 2 : 1)) : [] };
|
|
164
|
+
}
|
|
165
|
+
if (!token.startsWith('-')) break;
|
|
166
|
+
index += WRAPPER_OPTIONS[name].includes(token) ? 2 : 1;
|
|
167
|
+
}
|
|
168
|
+
if (!exec) return [];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
function positional(tokens, options) {
|
|
173
|
+
const values = []; let afterDash = false;
|
|
174
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
175
|
+
const token = tokens[index];
|
|
176
|
+
if (!afterDash && token === '--') { afterDash = true; continue; }
|
|
177
|
+
if (!afterDash && options.has(token)) { index += 1; continue; }
|
|
178
|
+
if (afterDash || !token.startsWith('-') || token === '-') values.push(token);
|
|
179
|
+
}
|
|
180
|
+
return values;
|
|
181
|
+
}
|
|
182
|
+
function knownSearchOptions(tokens, options, name) {
|
|
183
|
+
const booleans = new Set(['-n', '-i', '-v', '-w', '-x', '-F', '-P', '-l', '-L', '-q', '-s', '-h', '-H', '-o', '-c', '-a', '-u', '-U', '-z',
|
|
184
|
+
'--line-number', '--ignore-case', '--invert-match', '--word-regexp', '--line-regexp', '--fixed-strings', '--pcre2',
|
|
185
|
+
'--files-with-matches', '--files-without-match', '--quiet', '--no-messages', '--no-filename', '--with-filename',
|
|
186
|
+
'--only-matching', '--count', '--text', '--multiline', '--null', '--hidden', '--files', '--stats']);
|
|
187
|
+
if (name === 'grep') for (const flag of ['-r', '-R', '--recursive', '--dereference-recursive']) booleans.add(flag);
|
|
188
|
+
if (name === 'rg') for (const flag of ['--json', '-S', '--smart-case', '-N', '--no-heading', '--heading',
|
|
189
|
+
'--column', '--vimgrep', '--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude',
|
|
190
|
+
'--no-ignore-files', '--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
|
|
191
|
+
'--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim']) booleans.add(flag);
|
|
192
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
193
|
+
const token = tokens[index];
|
|
194
|
+
if (token === '--') return true;
|
|
195
|
+
if (!token.startsWith('-') || token === '-') continue;
|
|
196
|
+
if (options.has(token)) { index += 1; continue; }
|
|
197
|
+
if (/^-[ABCmMjdt]\S+/.test(token) && options.has(token.slice(0, 2))) continue;
|
|
198
|
+
if (token.startsWith('--') && options.has(token.split('=')[0]) && token.includes('=')) continue;
|
|
199
|
+
if (booleans.has(token)) continue;
|
|
200
|
+
if (!token.startsWith('--') && [...token.slice(1)].every(flag => booleans.has(`-${flag}`))) continue;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
function commandPaths(group, globs, scopes, state) {
|
|
206
|
+
const inputs = []; const args = [];
|
|
207
|
+
for (let index = 0; index < group.length; index += 1) {
|
|
208
|
+
const token = group[index];
|
|
209
|
+
if (token.redirect) {
|
|
210
|
+
const operand = group[index += 1];
|
|
211
|
+
if (token.redirect === '<' && typeof operand === 'string') inputs.push(operand);
|
|
212
|
+
} else args.push(token);
|
|
213
|
+
}
|
|
214
|
+
return [...inputs, ...readerPaths(args, globs, scopes, state)];
|
|
215
|
+
}
|
|
216
|
+
function readerPaths(group, globs, scopes, state) {
|
|
217
|
+
const tokens = unwrap(group, state);
|
|
218
|
+
if (tokens.command !== undefined) {
|
|
219
|
+
const groups = commandGroups(tokens.command);
|
|
220
|
+
if (groups.length) groups[groups.length - 1].push(...tokens.rest);
|
|
221
|
+
return groups.flatMap((nested) => commandPaths(nested, globs, scopes, state));
|
|
222
|
+
}
|
|
223
|
+
const name = nameOf(tokens[0]);
|
|
224
|
+
if (['cd', 'pushd', 'popd', 'eval', 'source', '.'].includes(name)) state.unknownCwd = true;
|
|
225
|
+
if (!READERS.has(name)) return []; // Executables and non-reader arguments are never paths.
|
|
226
|
+
if (name === 'find') {
|
|
227
|
+
let index = 1;
|
|
228
|
+
while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) index += 1;
|
|
229
|
+
if (tokens[index] === '-D') index += 2;
|
|
230
|
+
if (tokens[index] === '--') index += 1;
|
|
231
|
+
const roots = [];
|
|
232
|
+
for (; index < tokens.length && !/^(?:-|!|\(|\))/.test(tokens[index]); index += 1) roots.push(tokens[index]);
|
|
233
|
+
return roots; // Expressions such as -path ... -prune are not read roots.
|
|
234
|
+
}
|
|
235
|
+
if (['rg', 'grep'].includes(name)) {
|
|
236
|
+
const firstGlob = globs.length;
|
|
237
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
238
|
+
const token = tokens[index];
|
|
239
|
+
if (['-g', '--glob', '--iglob'].includes(token)) globs.push(tokens[index + 1] || '');
|
|
240
|
+
else if (/^--i?glob=/.test(token)) globs.push(token.slice(token.indexOf('=') + 1));
|
|
241
|
+
}
|
|
242
|
+
const options = new Set(['-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob', '-t', '--type', '-T', '--type-not', '--exclude', '--exclude-dir', '--include', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context', '--encoding', '--color',
|
|
243
|
+
'--max-depth', '-d', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr', '--replace', '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit', '--directories', '-D', '--devices', '--label']);
|
|
244
|
+
// rg -r takes replacement text; grep -r is a recursive flag.
|
|
245
|
+
if (name === 'rg') options.add('-r');
|
|
246
|
+
const values = positional(tokens, options);
|
|
247
|
+
const explicit = tokens.some((token) => /^(?:-[ef]|--(?:regexp|file)(?:=|$))/.test(token)) || tokens.includes('--files');
|
|
248
|
+
const paths = explicit ? values : values.slice(1);
|
|
249
|
+
scopes.push({ paths: state.unknownCwd || !knownSearchOptions(tokens, options, name) ? [] : paths, globs: globs.slice(firstGlob) });
|
|
250
|
+
return paths;
|
|
251
|
+
}
|
|
252
|
+
if (['sed', 'awk'].includes(name)) {
|
|
253
|
+
const options = new Set(['-e', '--expression', '-f', '--file', '-F', '-v']);
|
|
254
|
+
const values = positional(tokens, options);
|
|
255
|
+
return tokens.some((token) => /^(?:-[ef]|--(?:expression|file)(?:=|$))/.test(token)) ? values : values.slice(1);
|
|
256
|
+
}
|
|
257
|
+
// A flag's meaning belongs to its reader: cat -n and wc -c are booleans,
|
|
258
|
+
// whereas head -n consumes a number. Never swallow the following read path.
|
|
259
|
+
const options = {
|
|
260
|
+
head: ['-n', '--lines', '-c', '--bytes'], tail: ['-n', '--lines', '-c', '--bytes', '--pid', '-s', '--sleep-interval'],
|
|
261
|
+
nl: ['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w'],
|
|
262
|
+
tree: ['-I', '-P', '-L', '-o', '--filelimit', '--sort'],
|
|
263
|
+
ls: ['-I', '--ignore', '--hide', '--format', '--sort', '--time', '--block-size', '--quoting-style', '-w', '--width'],
|
|
264
|
+
du: ['-d', '--max-depth', '--exclude', '-X', '--exclude-from', '-B', '--block-size'],
|
|
265
|
+
stat: ['-c', '--format', '--printf'], file: ['-m', '--magic-file', '-f', '--files-from', '-F', '--separator'],
|
|
266
|
+
bat: ['--color', '--style', '--language', '--paging', '--theme', '--line-range', '-r', '--tabs'],
|
|
267
|
+
less: ['-x', '--tabs', '-j', '-p', '-P', '-z'],
|
|
268
|
+
};
|
|
269
|
+
return positional(tokens, new Set(options[name] || []));
|
|
270
|
+
}
|
|
271
|
+
function broadGlobFor(paths, globs, context) {
|
|
272
|
+
if (paths.length > 0 && paths.every(requested => narrowPath(requested, context))) return undefined;
|
|
273
|
+
return globs.find(glob => !glob.startsWith('!') && BROAD_GLOBS.some(rule => rule.test(clean(glob))));
|
|
274
|
+
}
|
|
275
|
+
function inspectInput(toolName, toolInput, context = {}) {
|
|
276
|
+
if (WRITE_TOOLS.has(toolName)) return {};
|
|
277
|
+
const paths = []; const globs = [];
|
|
278
|
+
collectStructured(toolInput, '', toolName, paths, globs);
|
|
279
|
+
let broadGlob = broadGlobFor(paths, globs, context);
|
|
280
|
+
const scopes = [];
|
|
281
|
+
const commandContext = { ...context };
|
|
282
|
+
let unknownCwd = context.cwd !== undefined && typeof context.cwd !== 'string';
|
|
283
|
+
const overrides = ['cwd', 'workdir'].filter(key => Object.hasOwn(toolInput || {}, key));
|
|
284
|
+
for (const key of overrides) {
|
|
285
|
+
if (typeof toolInput[key] !== 'string' || !toolInput[key].trim()) unknownCwd = true;
|
|
286
|
+
else commandContext.cwd = path.resolve(typeof context.cwd === 'string' ? context.cwd : process.cwd(), toolInput[key]);
|
|
287
|
+
}
|
|
288
|
+
if (overrides.length > 1 && toolInput.cwd !== toolInput.workdir) unknownCwd = true;
|
|
289
|
+
for (const key of ['command', 'cmd']) {
|
|
290
|
+
if (typeof toolInput?.[key] === 'string') {
|
|
291
|
+
const state = { unknownCwd };
|
|
292
|
+
for (const group of commandGroups(toolInput[key])) paths.push(...commandPaths(group, globs, scopes, state));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const included = globs.filter((glob) => !glob.startsWith('!'));
|
|
296
|
+
for (const scope of scopes) broadGlob ||= broadGlobFor(scope.paths, scope.globs, commandContext);
|
|
297
|
+
paths.push(...included);
|
|
298
|
+
return {
|
|
299
|
+
broadGlob,
|
|
300
|
+
blockedPath: paths.map((requested) => ({ requested, blocked: blockedSegment(requested, context) })).find((result) => result.blocked),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { BLOCKED_DIRS, inspectInput };
|
|
@@ -80,7 +80,7 @@ function processEntry(entry, toolMap, agentMap, latestTodos, result) {
|
|
|
80
80
|
for (const block of content) {
|
|
81
81
|
// Handle tool_use blocks
|
|
82
82
|
if (block.type === 'tool_use' && block.id && block.name) {
|
|
83
|
-
if (block.name === 'Task') {
|
|
83
|
+
if (block.name === 'Task' || block.name === 'Agent') {
|
|
84
84
|
// Agent spawn
|
|
85
85
|
agentMap.set(block.id, {
|
|
86
86
|
id: block.id,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '<') {
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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 };
|