praxis-agent 0.9.0 → 0.11.0
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 +18 -4
- package/dist/application/session-service.d.ts +5 -2
- package/dist/application/session-service.js +23 -0
- package/dist/application/subagent-service.d.ts +4 -2
- package/dist/application/subagent-service.js +4 -0
- package/dist/cli/interactive.d.ts +4 -3
- package/dist/cli/interactive.js +760 -195
- package/dist/cli/tui/claude-command-inventory.d.ts +368 -0
- package/dist/cli/tui/claude-command-inventory.js +220 -0
- package/dist/cli/tui/claude-style.d.ts +12 -14
- package/dist/cli/tui/claude-style.js +45 -67
- package/dist/cli/tui/config-dashboard.d.ts +4 -8
- package/dist/cli/tui/config-dashboard.js +4 -18
- package/dist/cli/tui/config-settings.js +1 -1
- package/dist/cli/tui/conversation-export.d.ts +1 -1
- package/dist/cli/tui/conversation-export.js +2 -11
- package/dist/cli/tui/mcp-elicitation.d.ts +75 -0
- package/dist/cli/tui/mcp-elicitation.js +497 -0
- package/dist/cli/tui/open-url.d.ts +2 -0
- package/dist/cli/tui/open-url.js +13 -0
- package/dist/cli/tui/permission-settings.d.ts +6 -0
- package/dist/cli/tui/permission-settings.js +84 -0
- package/dist/cli/tui/runtime-interactions.d.ts +2 -0
- package/dist/cli/tui/runtime-interactions.js +44 -17
- package/dist/cli/tui/slash-commands.js +3 -28
- package/dist/cli/tui/tool-permission.d.ts +36 -0
- package/dist/cli/tui/tool-permission.js +386 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +28 -13
- package/dist/core/runtime.d.ts +50 -2
- package/dist/core/runtime.js +30 -3
- package/dist/extensions/claude-extension-tools.d.ts +3 -2
- package/dist/extensions/claude-extension-tools.js +15 -5
- package/dist/extensions/claude-extensions.d.ts +1 -0
- package/dist/extensions/claude-extensions.js +10 -0
- package/dist/hooks/claude-hook-tools.js +1 -1
- package/dist/mcp/claude-mcp-tools.js +12 -6
- package/dist/permissions/bash-ast.d.ts +39 -0
- package/dist/permissions/bash-ast.js +1046 -0
- package/dist/permissions/bash-normalization.d.ts +5 -0
- package/dist/permissions/bash-normalization.js +241 -0
- package/dist/permissions/bash-path-safety.d.ts +28 -0
- package/dist/permissions/bash-path-safety.js +575 -0
- package/dist/permissions/claude-permission-resolver.d.ts +7 -0
- package/dist/permissions/claude-permission-resolver.js +351 -40
- package/dist/permissions/claude-shell-permission.d.ts +4 -0
- package/dist/permissions/claude-shell-permission.js +108 -0
- package/dist/permissions/permission-updates.d.ts +16 -0
- package/dist/permissions/permission-updates.js +320 -0
- package/dist/permissions/sed-safety.d.ts +9 -0
- package/dist/permissions/sed-safety.js +172 -0
- package/dist/permissions/shell-rule-matching.d.ts +14 -0
- package/dist/permissions/shell-rule-matching.js +76 -0
- package/dist/tools/claude-interactive-tools.d.ts +10 -1
- package/dist/tools/claude-interactive-tools.js +10 -4
- package/dist/tools/local-tools.js +22 -2
- package/package.json +3 -1
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import Bash from 'tree-sitter-bash';
|
|
3
|
+
const parser = new Parser();
|
|
4
|
+
parser.setLanguage(Bash);
|
|
5
|
+
export const MAX_BASH_PERMISSION_COMMANDS = 50;
|
|
6
|
+
const SUBSTITUTION_PLACEHOLDER = '__PRAXIS_COMMAND_SUBSTITUTION__';
|
|
7
|
+
const BRACE_EXPANSION = /\{[^{}\s]*(?:,|\.\.)[^{}\s]*\}/u;
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
const CONTROL_CHARACTER = /[\x00-\x08\x0b-\x1f\x7f]/u;
|
|
10
|
+
const UNICODE_WHITESPACE = /[\u00a0\u1680\u2000-\u200b\u2028\u2029\u202f\u205f\u3000\ufeff]/u;
|
|
11
|
+
const BACKSLASH_WHITESPACE = /\\[ \t]|[^ \t\n\\]\\\n/u;
|
|
12
|
+
const PROC_ENVIRON = /\/proc\/.*\/environ/u;
|
|
13
|
+
const NEWLINE_COMMENT = /\n[ \t]*#/u;
|
|
14
|
+
const SHELL_KEYWORDS = new Set([
|
|
15
|
+
'if',
|
|
16
|
+
'then',
|
|
17
|
+
'elif',
|
|
18
|
+
'else',
|
|
19
|
+
'fi',
|
|
20
|
+
'while',
|
|
21
|
+
'until',
|
|
22
|
+
'for',
|
|
23
|
+
'in',
|
|
24
|
+
'do',
|
|
25
|
+
'done',
|
|
26
|
+
'case',
|
|
27
|
+
'esac',
|
|
28
|
+
'function',
|
|
29
|
+
'select',
|
|
30
|
+
]);
|
|
31
|
+
const ZSH_ESCAPE_BUILTINS = new Set([
|
|
32
|
+
'zmodload',
|
|
33
|
+
'emulate',
|
|
34
|
+
'sysopen',
|
|
35
|
+
'sysread',
|
|
36
|
+
'syswrite',
|
|
37
|
+
'sysseek',
|
|
38
|
+
'zpty',
|
|
39
|
+
'ztcp',
|
|
40
|
+
'zsocket',
|
|
41
|
+
'zf_rm',
|
|
42
|
+
'zf_mv',
|
|
43
|
+
'zf_ln',
|
|
44
|
+
'zf_chmod',
|
|
45
|
+
'zf_chown',
|
|
46
|
+
'zf_mkdir',
|
|
47
|
+
'zf_rmdir',
|
|
48
|
+
'zf_chgrp',
|
|
49
|
+
]);
|
|
50
|
+
const EVAL_LIKE_BUILTINS = new Set([
|
|
51
|
+
'eval',
|
|
52
|
+
'source',
|
|
53
|
+
'.',
|
|
54
|
+
'exec',
|
|
55
|
+
'command',
|
|
56
|
+
'builtin',
|
|
57
|
+
'fc',
|
|
58
|
+
'coproc',
|
|
59
|
+
'noglob',
|
|
60
|
+
'nocorrect',
|
|
61
|
+
'trap',
|
|
62
|
+
'enable',
|
|
63
|
+
'mapfile',
|
|
64
|
+
'readarray',
|
|
65
|
+
'hash',
|
|
66
|
+
'bind',
|
|
67
|
+
'complete',
|
|
68
|
+
'compgen',
|
|
69
|
+
'alias',
|
|
70
|
+
'let',
|
|
71
|
+
]);
|
|
72
|
+
const SUBSCRIPT_FLAGS = {
|
|
73
|
+
test: new Set(['-v', '-R']),
|
|
74
|
+
'[': new Set(['-v', '-R']),
|
|
75
|
+
'[[': new Set(['-v', '-R']),
|
|
76
|
+
printf: new Set(['-v']),
|
|
77
|
+
read: new Set(['-a']),
|
|
78
|
+
unset: new Set(['-v']),
|
|
79
|
+
wait: new Set(['-p']),
|
|
80
|
+
};
|
|
81
|
+
const READ_VALUE_FLAGS = new Set(['-p', '-d', '-n', '-N', '-t', '-u', '-i']);
|
|
82
|
+
const SAFE_STRING_ENVIRONMENT = new Set([
|
|
83
|
+
'HOME',
|
|
84
|
+
'PWD',
|
|
85
|
+
'OLDPWD',
|
|
86
|
+
'USER',
|
|
87
|
+
'LOGNAME',
|
|
88
|
+
'SHELL',
|
|
89
|
+
'PATH',
|
|
90
|
+
'HOSTNAME',
|
|
91
|
+
'UID',
|
|
92
|
+
'EUID',
|
|
93
|
+
'PPID',
|
|
94
|
+
'RANDOM',
|
|
95
|
+
'SECONDS',
|
|
96
|
+
'LINENO',
|
|
97
|
+
'TMPDIR',
|
|
98
|
+
'BASH_VERSION',
|
|
99
|
+
'BASHPID',
|
|
100
|
+
'SHLVL',
|
|
101
|
+
'HISTFILE',
|
|
102
|
+
'IFS',
|
|
103
|
+
]);
|
|
104
|
+
const SAFE_STRING_SPECIAL_VARIABLES = new Set(['?', '$', '!', '#', '0', '-']);
|
|
105
|
+
const EMPTY_VARIABLE_SCOPE = new Map();
|
|
106
|
+
const ARITHMETIC_CONTAINER_TYPES = new Set([
|
|
107
|
+
'binary_expression',
|
|
108
|
+
'unary_expression',
|
|
109
|
+
'ternary_expression',
|
|
110
|
+
'parenthesized_expression',
|
|
111
|
+
]);
|
|
112
|
+
const ARITHMETIC_LITERAL = /^(?:[0-9]+|0[xX][0-9a-fA-F]+|[0-9]+#[0-9a-zA-Z]+|[-+*/%^&|~!<>=?:(),]+|<<|>>|\*\*|&&|\|\||[<>=!]=|\$\(\(|\)\))$/u;
|
|
113
|
+
function arithmeticIsLiteral(node) {
|
|
114
|
+
for (const child of node.children) {
|
|
115
|
+
if (child.children.length === 0) {
|
|
116
|
+
if (!ARITHMETIC_LITERAL.test(child.text))
|
|
117
|
+
return false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!ARITHMETIC_CONTAINER_TYPES.has(child.type) ||
|
|
121
|
+
!arithmeticIsLiteral(child)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
function quotedHeredocStart(node) {
|
|
128
|
+
return ((node.text.startsWith("'") && node.text.endsWith("'")) ||
|
|
129
|
+
(node.text.startsWith('"') && node.text.endsWith('"')) ||
|
|
130
|
+
node.text.startsWith('\\'));
|
|
131
|
+
}
|
|
132
|
+
function safeCatHeredoc(node) {
|
|
133
|
+
if (node.type !== 'command_substitution')
|
|
134
|
+
return null;
|
|
135
|
+
const statements = node.namedChildren;
|
|
136
|
+
if (statements.length !== 1 ||
|
|
137
|
+
statements[0]?.type !== 'redirected_statement') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const statement = statements[0];
|
|
141
|
+
const command = statement.namedChildren.find((child) => child.type === 'command');
|
|
142
|
+
const redirects = statement.namedChildren.filter((child) => child.type === 'heredoc_redirect');
|
|
143
|
+
if (!command ||
|
|
144
|
+
command.namedChildren.length !== 1 ||
|
|
145
|
+
command.childForFieldName('name')?.text !== 'cat' ||
|
|
146
|
+
redirects.length !== 1 ||
|
|
147
|
+
statement.namedChildren.length !== 2) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const redirect = redirects[0];
|
|
151
|
+
const start = redirect?.namedChildren.find((child) => child.type === 'heredoc_start');
|
|
152
|
+
const body = redirect?.namedChildren.find((child) => child.type === 'heredoc_body');
|
|
153
|
+
if (!start || !body || !quotedHeredocStart(start))
|
|
154
|
+
return null;
|
|
155
|
+
if (body.namedChildren.some((child) => child.type !== 'heredoc_content')) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (PROC_ENVIRON.test(body.text) || /\bsystem\s*\(/u.test(body.text)) {
|
|
159
|
+
return { dangerous: true };
|
|
160
|
+
}
|
|
161
|
+
return { body: body.text };
|
|
162
|
+
}
|
|
163
|
+
function isCatHeredocCommand(node) {
|
|
164
|
+
const substitution = node.parent?.parent;
|
|
165
|
+
return substitution ? safeCatHeredoc(substitution) !== null : false;
|
|
166
|
+
}
|
|
167
|
+
function staticNodeValue(node, substitutions, scope = EMPTY_VARIABLE_SCOPE) {
|
|
168
|
+
if (node.type === 'command_name') {
|
|
169
|
+
const child = node.namedChild(0);
|
|
170
|
+
return child
|
|
171
|
+
? staticNodeValue(child, substitutions, scope)
|
|
172
|
+
: { ok: false, nodeType: node.type };
|
|
173
|
+
}
|
|
174
|
+
if (node.type === 'word' ||
|
|
175
|
+
node.type === 'string_content' ||
|
|
176
|
+
node.type === 'test_operator') {
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
value: node.text.replace(/\\\n/gu, '').replace(/\\(.)/gu, '$1'),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (node.type === 'number') {
|
|
183
|
+
return node.children.length === 0
|
|
184
|
+
? { ok: true, value: node.text }
|
|
185
|
+
: { ok: false, nodeType: node.namedChild(0)?.type ?? node.type };
|
|
186
|
+
}
|
|
187
|
+
if (node.type === 'raw_string') {
|
|
188
|
+
return { ok: true, value: node.text.slice(1, -1) };
|
|
189
|
+
}
|
|
190
|
+
if (node.type === 'command_substitution' && substitutions) {
|
|
191
|
+
const heredoc = safeCatHeredoc(node);
|
|
192
|
+
if (heredoc && 'dangerous' in heredoc) {
|
|
193
|
+
return { ok: false, nodeType: node.type };
|
|
194
|
+
}
|
|
195
|
+
if (heredoc) {
|
|
196
|
+
const body = heredoc.body.replace(/\n+$/u, '');
|
|
197
|
+
return { ok: true, value: body.includes('\n') ? '' : body };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, value: SUBSTITUTION_PLACEHOLDER };
|
|
200
|
+
}
|
|
201
|
+
if (node.type === 'arithmetic_expansion') {
|
|
202
|
+
return arithmeticIsLiteral(node)
|
|
203
|
+
? { ok: true, value: node.text }
|
|
204
|
+
: { ok: false, nodeType: node.type };
|
|
205
|
+
}
|
|
206
|
+
if (node.type === 'simple_expansion') {
|
|
207
|
+
const variable = node.namedChildren.find((child) => child.type === 'variable_name');
|
|
208
|
+
const special = node.namedChildren.find((child) => child.type === 'special_variable_name');
|
|
209
|
+
const name = variable?.text ?? special?.text;
|
|
210
|
+
const value = name ? scope.get(name) : undefined;
|
|
211
|
+
if (value !== undefined)
|
|
212
|
+
return { ok: true, value };
|
|
213
|
+
if (substitutions && name && SAFE_STRING_ENVIRONMENT.has(name)) {
|
|
214
|
+
return { ok: true, value: SUBSTITUTION_PLACEHOLDER };
|
|
215
|
+
}
|
|
216
|
+
if (substitutions &&
|
|
217
|
+
name &&
|
|
218
|
+
((special && SAFE_STRING_SPECIAL_VARIABLES.has(name)) ||
|
|
219
|
+
/^\d+$/u.test(name))) {
|
|
220
|
+
return { ok: true, value: SUBSTITUTION_PLACEHOLDER };
|
|
221
|
+
}
|
|
222
|
+
return { ok: false, nodeType: node.type };
|
|
223
|
+
}
|
|
224
|
+
if (node.type === 'string') {
|
|
225
|
+
let value = '';
|
|
226
|
+
let cursor = node.startIndex + 1;
|
|
227
|
+
for (const child of node.namedChildren) {
|
|
228
|
+
value += sourceSlice(node, cursor, child.startIndex);
|
|
229
|
+
const part = staticNodeValue(child, substitutions, scope);
|
|
230
|
+
if (!part.ok)
|
|
231
|
+
return part;
|
|
232
|
+
value += part.value;
|
|
233
|
+
cursor = child.endIndex;
|
|
234
|
+
}
|
|
235
|
+
value += sourceSlice(node, cursor, node.endIndex - 1);
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
value: value.replace(/\\([$`"\\\n])/gu, '$1'),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (node.type === 'concatenation') {
|
|
242
|
+
let value = '';
|
|
243
|
+
for (const child of node.namedChildren) {
|
|
244
|
+
const part = staticNodeValue(child, substitutions, scope);
|
|
245
|
+
if (!part.ok)
|
|
246
|
+
return part;
|
|
247
|
+
value += part.value;
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, value };
|
|
250
|
+
}
|
|
251
|
+
return { ok: false, nodeType: node.type };
|
|
252
|
+
}
|
|
253
|
+
function sourceSlice(node, start, end) {
|
|
254
|
+
const relativeStart = Math.max(0, start - node.startIndex);
|
|
255
|
+
const relativeEnd = Math.max(relativeStart, end - node.startIndex);
|
|
256
|
+
return node.text.slice(relativeStart, relativeEnd);
|
|
257
|
+
}
|
|
258
|
+
function commandArgv(node, scope = EMPTY_VARIABLE_SCOPE) {
|
|
259
|
+
const nameNode = node.childForFieldName('name');
|
|
260
|
+
if (!nameNode)
|
|
261
|
+
return { ok: false, nodeType: node.type };
|
|
262
|
+
const name = staticNodeValue(nameNode, false, scope);
|
|
263
|
+
if (!name.ok)
|
|
264
|
+
return name;
|
|
265
|
+
if (name.value === '' ||
|
|
266
|
+
name.value.includes(SUBSTITUTION_PLACEHOLDER) ||
|
|
267
|
+
/[ \t\n*?[\]]/u.test(name.value)) {
|
|
268
|
+
return { ok: false, nodeType: 'simple_expansion' };
|
|
269
|
+
}
|
|
270
|
+
const argv = [name.value];
|
|
271
|
+
for (const argument of node.childrenForFieldName('argument')) {
|
|
272
|
+
const value = staticNodeValue(argument, argument.type === 'string' || argument.type === 'concatenation', scope);
|
|
273
|
+
if (!value.ok)
|
|
274
|
+
return value;
|
|
275
|
+
if ((argument.type === 'simple_expansion' &&
|
|
276
|
+
(value.value === '' || /[ \t\n*?[\]]/u.test(value.value))) ||
|
|
277
|
+
value.value === SUBSTITUTION_PLACEHOLDER) {
|
|
278
|
+
return { ok: false, nodeType: 'simple_expansion' };
|
|
279
|
+
}
|
|
280
|
+
argv.push(value.value);
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, value: name.value, argv };
|
|
283
|
+
}
|
|
284
|
+
const DECLARATION_COMMANDS = new Set([
|
|
285
|
+
'export',
|
|
286
|
+
'local',
|
|
287
|
+
'readonly',
|
|
288
|
+
'declare',
|
|
289
|
+
'typeset',
|
|
290
|
+
]);
|
|
291
|
+
function declarationArgv(node, scope) {
|
|
292
|
+
const keyword = node.children.find((child) => DECLARATION_COMMANDS.has(child.type))?.text;
|
|
293
|
+
if (!keyword)
|
|
294
|
+
return { ok: false, nodeType: node.type };
|
|
295
|
+
const argv = [keyword];
|
|
296
|
+
for (const child of node.namedChildren) {
|
|
297
|
+
if (child.type === 'variable_assignment') {
|
|
298
|
+
const name = child.childForFieldName('name')?.text;
|
|
299
|
+
const valueNode = child.childForFieldName('value');
|
|
300
|
+
if (!name)
|
|
301
|
+
return { ok: false, nodeType: child.type };
|
|
302
|
+
const value = valueNode
|
|
303
|
+
? staticNodeValue(valueNode, true, scope)
|
|
304
|
+
: { ok: true, value: '' };
|
|
305
|
+
if (!value.ok)
|
|
306
|
+
return value;
|
|
307
|
+
argv.push(`${name}=${value.value}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (child.type === 'variable_name') {
|
|
311
|
+
argv.push(child.text);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const value = staticNodeValue(child, child.type === 'string' || child.type === 'concatenation', scope);
|
|
315
|
+
if (!value.ok || value.value === SUBSTITUTION_PLACEHOLDER) {
|
|
316
|
+
return value.ok ? { ok: false, nodeType: child.type } : value;
|
|
317
|
+
}
|
|
318
|
+
argv.push(value.value);
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, value: keyword, argv };
|
|
321
|
+
}
|
|
322
|
+
function declarationSemanticFailure(argv) {
|
|
323
|
+
const command = argv[0];
|
|
324
|
+
if (command === 'declare' || command === 'typeset' || command === 'local') {
|
|
325
|
+
for (const argument of argv.slice(1)) {
|
|
326
|
+
if (/^-[A-Za-z]*[niaA]/u.test(argument)) {
|
|
327
|
+
return `Declaration flag '${argument}' changes assignment semantics`;
|
|
328
|
+
}
|
|
329
|
+
if (!argument.startsWith('-') && /^[^=]*\[/u.test(argument)) {
|
|
330
|
+
return `Declaration operand '${argument}' contains array subscript evaluation`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return firstSemanticFailure(argv);
|
|
335
|
+
}
|
|
336
|
+
export function normalizeBashWrapperArgv(input) {
|
|
337
|
+
let argv = [...input];
|
|
338
|
+
for (;;) {
|
|
339
|
+
const wrapper = argv[0];
|
|
340
|
+
if (wrapper === 'time' || wrapper === 'nohup') {
|
|
341
|
+
argv = argv.slice(1);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (wrapper === 'timeout') {
|
|
345
|
+
let index = 1;
|
|
346
|
+
while (index < argv.length) {
|
|
347
|
+
const value = argv[index] ?? '';
|
|
348
|
+
const next = argv[index + 1];
|
|
349
|
+
if (value === '--foreground' ||
|
|
350
|
+
value === '--preserve-status' ||
|
|
351
|
+
value === '--verbose' ||
|
|
352
|
+
value === '-v' ||
|
|
353
|
+
/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/u.test(value) ||
|
|
354
|
+
/^-[ks][A-Za-z0-9_.+-]+$/u.test(value)) {
|
|
355
|
+
index += 1;
|
|
356
|
+
}
|
|
357
|
+
else if (['--kill-after', '--signal', '-k', '-s'].includes(value) &&
|
|
358
|
+
next &&
|
|
359
|
+
/^[A-Za-z0-9_.+-]+$/u.test(next)) {
|
|
360
|
+
index += 2;
|
|
361
|
+
}
|
|
362
|
+
else if (value.startsWith('-')) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
reason: `timeout with ${value} flag cannot be statically analyzed`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const duration = argv[index];
|
|
373
|
+
if (duration === undefined)
|
|
374
|
+
return { ok: true, argv };
|
|
375
|
+
if (!/^\d+(?:\.\d+)?[smhd]?$/u.test(duration)) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
reason: `timeout duration '${duration}' cannot be statically analyzed`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
argv = argv.slice(index + 1);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (wrapper === 'nice') {
|
|
385
|
+
if (argv[1] === '-n') {
|
|
386
|
+
if (!/^-?\d+$/u.test(argv[2] ?? '')) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
reason: 'nice priority cannot be statically analyzed',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
argv = argv.slice(3);
|
|
393
|
+
}
|
|
394
|
+
else if (/^-\d+$/u.test(argv[1] ?? '')) {
|
|
395
|
+
argv = argv.slice(2);
|
|
396
|
+
}
|
|
397
|
+
else if (/[$(`]/u.test(argv[1] ?? '')) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: 'nice argument contains expansion and cannot be statically analyzed',
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
argv = argv.slice(1);
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (wrapper === 'env') {
|
|
409
|
+
let index = 1;
|
|
410
|
+
while (index < argv.length) {
|
|
411
|
+
const value = argv[index] ?? '';
|
|
412
|
+
if ((value.includes('=') && !value.startsWith('-')) ||
|
|
413
|
+
value === '-i' ||
|
|
414
|
+
value === '-0' ||
|
|
415
|
+
value === '-v') {
|
|
416
|
+
index += 1;
|
|
417
|
+
}
|
|
418
|
+
else if (value === '-u' && argv[index + 1]) {
|
|
419
|
+
index += 2;
|
|
420
|
+
}
|
|
421
|
+
else if (value.startsWith('-')) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
reason: `env with ${value} flag cannot be statically analyzed`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (index === argv.length)
|
|
432
|
+
return { ok: true, argv };
|
|
433
|
+
argv = argv.slice(index);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (wrapper === 'stdbuf') {
|
|
437
|
+
let index = 1;
|
|
438
|
+
while (index < argv.length) {
|
|
439
|
+
const value = argv[index] ?? '';
|
|
440
|
+
if (/^-[ioe].+/u.test(value) ||
|
|
441
|
+
/^--(?:input|output|error)=/u.test(value)) {
|
|
442
|
+
index += 1;
|
|
443
|
+
}
|
|
444
|
+
else if (/^-[ioe]$/u.test(value) && argv[index + 1]) {
|
|
445
|
+
index += 2;
|
|
446
|
+
}
|
|
447
|
+
else if (value.startsWith('-')) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
reason: `stdbuf with ${value} flag cannot be statically analyzed`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (index === 1 || index === argv.length)
|
|
458
|
+
return { ok: true, argv };
|
|
459
|
+
argv = argv.slice(index);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
return { ok: true, argv };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function firstSemanticFailure(argv) {
|
|
466
|
+
const normalized = normalizeBashWrapperArgv(argv);
|
|
467
|
+
if (!normalized.ok)
|
|
468
|
+
return normalized.reason;
|
|
469
|
+
const command = normalized.argv[0];
|
|
470
|
+
if (command === undefined)
|
|
471
|
+
return undefined;
|
|
472
|
+
if (command === '' || command.includes(SUBSTITUTION_PLACEHOLDER)) {
|
|
473
|
+
return 'Command name is runtime-determined';
|
|
474
|
+
}
|
|
475
|
+
if (/^[-|&]/u.test(command))
|
|
476
|
+
return 'Command appears to be incomplete';
|
|
477
|
+
if (SHELL_KEYWORDS.has(command))
|
|
478
|
+
return `Shell keyword '${command}' was parsed as a command`;
|
|
479
|
+
if (ZSH_ESCAPE_BUILTINS.has(command)) {
|
|
480
|
+
return `Zsh builtin '${command}' can bypass security checks`;
|
|
481
|
+
}
|
|
482
|
+
if (EVAL_LIKE_BUILTINS.has(command)) {
|
|
483
|
+
const args = normalized.argv.slice(1);
|
|
484
|
+
const safeCommandLookup = command === 'command' && (args[0] === '-v' || args[0] === '-V');
|
|
485
|
+
const safeHistoryList = command === 'fc' && !args.some((arg) => /^-[^-]*[es]/u.test(arg));
|
|
486
|
+
const safeCompletionList = command === 'compgen' && !args.some((arg) => /^-[^-]*[CFW]/u.test(arg));
|
|
487
|
+
if (!safeCommandLookup && !safeHistoryList && !safeCompletionList) {
|
|
488
|
+
return `'${command}' evaluates arguments as shell code`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (command === 'jq') {
|
|
492
|
+
if (normalized.argv.some((arg) => /\bsystem\s*\(/u.test(arg))) {
|
|
493
|
+
return 'jq system() executes arbitrary commands';
|
|
494
|
+
}
|
|
495
|
+
if (normalized.argv.some((arg) => /^(?:-[fL](?:$|[^A-Za-z])|--(?:from-file|rawfile|slurpfile|library-path)(?:$|=))/u.test(arg))) {
|
|
496
|
+
return 'jq file-loading flags cannot be automatically approved';
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const subscriptFlags = SUBSCRIPT_FLAGS[command];
|
|
500
|
+
if (subscriptFlags) {
|
|
501
|
+
for (let index = 1; index < normalized.argv.length; index += 1) {
|
|
502
|
+
const argument = normalized.argv[index] ?? '';
|
|
503
|
+
for (const flag of subscriptFlags) {
|
|
504
|
+
if (argument === flag && normalized.argv[index + 1]?.includes('[')) {
|
|
505
|
+
return `${command} ${flag} operand contains array subscript`;
|
|
506
|
+
}
|
|
507
|
+
if (flag.length === 2 &&
|
|
508
|
+
argument.startsWith(flag) &&
|
|
509
|
+
argument.length > 2 &&
|
|
510
|
+
argument.includes('[')) {
|
|
511
|
+
return `${command} ${flag} fused operand contains array subscript`;
|
|
512
|
+
}
|
|
513
|
+
if (argument.length > 2 &&
|
|
514
|
+
argument.startsWith('-') &&
|
|
515
|
+
!argument.startsWith('--') &&
|
|
516
|
+
argument.includes(flag.slice(1)) &&
|
|
517
|
+
normalized.argv[index + 1]?.includes('[')) {
|
|
518
|
+
return `${command} combined ${flag} operand contains array subscript`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (command === 'read' || command === 'unset') {
|
|
524
|
+
let skipNext = false;
|
|
525
|
+
for (const argument of normalized.argv.slice(1)) {
|
|
526
|
+
if (skipNext) {
|
|
527
|
+
skipNext = false;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (argument.startsWith('-')) {
|
|
531
|
+
if (command === 'read' && READ_VALUE_FLAGS.has(argument)) {
|
|
532
|
+
skipNext = true;
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (argument.includes('[')) {
|
|
537
|
+
return `${command} positional operand contains array subscript`;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const argument of normalized.argv) {
|
|
542
|
+
if (PROC_ENVIRON.test(argument))
|
|
543
|
+
return 'Accesses /proc/*/environ';
|
|
544
|
+
if (NEWLINE_COMMENT.test(argument)) {
|
|
545
|
+
return 'Newline followed by # can hide arguments from validation';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
function permissionUnit(node) {
|
|
551
|
+
const parent = node.parent;
|
|
552
|
+
return parent?.type === 'redirected_statement' ? parent : node;
|
|
553
|
+
}
|
|
554
|
+
function commandNodes(root) {
|
|
555
|
+
const commands = [];
|
|
556
|
+
const visit = (node) => {
|
|
557
|
+
if (node.type === 'command' ||
|
|
558
|
+
node.type === 'declaration_command' ||
|
|
559
|
+
node.type === 'test_command' ||
|
|
560
|
+
node.type === 'unset_command') {
|
|
561
|
+
if (node.type !== 'command' || !isCatHeredocCommand(node)) {
|
|
562
|
+
commands.push(permissionUnit(node));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const child of node.namedChildren)
|
|
566
|
+
visit(child);
|
|
567
|
+
};
|
|
568
|
+
visit(root);
|
|
569
|
+
return commands;
|
|
570
|
+
}
|
|
571
|
+
function rawCommandNodes(root) {
|
|
572
|
+
const commands = [];
|
|
573
|
+
const visit = (node) => {
|
|
574
|
+
if (node.type === 'command' && !isCatHeredocCommand(node)) {
|
|
575
|
+
commands.push(node);
|
|
576
|
+
}
|
|
577
|
+
for (const child of node.namedChildren)
|
|
578
|
+
visit(child);
|
|
579
|
+
};
|
|
580
|
+
visit(root);
|
|
581
|
+
return commands;
|
|
582
|
+
}
|
|
583
|
+
function nodeKey(node) {
|
|
584
|
+
return `${node.startIndex}:${node.endIndex}`;
|
|
585
|
+
}
|
|
586
|
+
function applyVariableAssignment(node, scope) {
|
|
587
|
+
const name = node.childForFieldName('name')?.text;
|
|
588
|
+
if (!name)
|
|
589
|
+
return;
|
|
590
|
+
const valueNode = node.childForFieldName('value');
|
|
591
|
+
const value = valueNode
|
|
592
|
+
? staticNodeValue(valueNode, true, scope)
|
|
593
|
+
: { ok: true, value: '' };
|
|
594
|
+
if (!value.ok) {
|
|
595
|
+
scope.delete(name);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const append = node.children.some((child) => child.type === '+=');
|
|
599
|
+
const combined = append ? (scope.get(name) ?? '') + value.value : value.value;
|
|
600
|
+
scope.set(name, combined.includes(SUBSTITUTION_PLACEHOLDER)
|
|
601
|
+
? SUBSTITUTION_PLACEHOLDER
|
|
602
|
+
: combined);
|
|
603
|
+
}
|
|
604
|
+
function buildCommandScopes(root) {
|
|
605
|
+
const scopes = new Map();
|
|
606
|
+
let failure;
|
|
607
|
+
const trackConditionReads = (node, scope) => {
|
|
608
|
+
for (const command of rawCommandNodes(node)) {
|
|
609
|
+
const parsed = commandArgv(command, scopes.get(nodeKey(command)) ?? EMPTY_VARIABLE_SCOPE);
|
|
610
|
+
if (!parsed.ok || parsed.argv?.[0] !== 'read')
|
|
611
|
+
continue;
|
|
612
|
+
for (const argument of parsed.argv.slice(1)) {
|
|
613
|
+
if (argument.startsWith('-') ||
|
|
614
|
+
!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(argument)) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const existing = scope.get(argument);
|
|
618
|
+
if (existing !== undefined &&
|
|
619
|
+
!existing.includes(SUBSTITUTION_PLACEHOLDER)) {
|
|
620
|
+
failure = `read ${argument} in a condition cannot safely overwrite a tracked literal`;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
scope.set(argument, SUBSTITUTION_PLACEHOLDER);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const walkChildren = (node, scope) => {
|
|
628
|
+
for (const child of node.namedChildren)
|
|
629
|
+
walk(child, scope);
|
|
630
|
+
};
|
|
631
|
+
const walkConditional = (node, scope) => {
|
|
632
|
+
let inPrimaryBody = false;
|
|
633
|
+
for (const child of node.children) {
|
|
634
|
+
if (!child.isNamed) {
|
|
635
|
+
if (child.type === 'then')
|
|
636
|
+
inPrimaryBody = true;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (child.type === 'do_group') {
|
|
640
|
+
walkChildren(child, new Map(scope));
|
|
641
|
+
}
|
|
642
|
+
else if (child.type === 'elif_clause' || child.type === 'else_clause') {
|
|
643
|
+
walkChildren(child, new Map(scope));
|
|
644
|
+
}
|
|
645
|
+
else if (inPrimaryBody) {
|
|
646
|
+
walk(child, new Map(scope));
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
walk(child, scope);
|
|
650
|
+
trackConditionReads(child, scope);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const walk = (node, scope) => {
|
|
655
|
+
if (node.type === 'command') {
|
|
656
|
+
scopes.set(nodeKey(node), new Map(scope));
|
|
657
|
+
walkChildren(node, new Map(scope));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (node.type === 'variable_assignment' &&
|
|
661
|
+
node.parent?.type !== 'command') {
|
|
662
|
+
applyVariableAssignment(node, scope);
|
|
663
|
+
walkChildren(node, new Map(scope));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (node.type === 'declaration_command') {
|
|
667
|
+
scopes.set(nodeKey(node), new Map(scope));
|
|
668
|
+
for (const child of node.namedChildren) {
|
|
669
|
+
if (child.type === 'variable_assignment') {
|
|
670
|
+
applyVariableAssignment(child, scope);
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
walk(child, new Map(scope));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (node.type === 'unset_command') {
|
|
679
|
+
for (const child of node.namedChildren) {
|
|
680
|
+
if (child.type === 'variable_name')
|
|
681
|
+
scope.delete(child.text);
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (node.type === 'pipeline') {
|
|
686
|
+
const incoming = new Map(scope);
|
|
687
|
+
for (const child of node.namedChildren)
|
|
688
|
+
walk(child, new Map(incoming));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (node.type === 'list') {
|
|
692
|
+
const incoming = new Map(scope);
|
|
693
|
+
let active = scope;
|
|
694
|
+
for (const child of node.children) {
|
|
695
|
+
if (!child.isNamed) {
|
|
696
|
+
if (child.type === '||' || child.type === '&') {
|
|
697
|
+
active = new Map(incoming);
|
|
698
|
+
}
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
walk(child, active);
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (node.type === 'subshell' || node.type === 'command_substitution') {
|
|
706
|
+
walkChildren(node, new Map(scope));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (node.type === 'for_statement') {
|
|
710
|
+
const loopVariable = node.childForFieldName('variable')?.text;
|
|
711
|
+
if (loopVariable)
|
|
712
|
+
scope.set(loopVariable, SUBSTITUTION_PLACEHOLDER);
|
|
713
|
+
for (const child of node.namedChildren) {
|
|
714
|
+
if (child.type === 'variable_name')
|
|
715
|
+
continue;
|
|
716
|
+
if (child.type === 'do_group')
|
|
717
|
+
walkChildren(child, new Map(scope));
|
|
718
|
+
else
|
|
719
|
+
walk(child, new Map(scope));
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (node.type === 'if_statement' || node.type === 'while_statement') {
|
|
724
|
+
walkConditional(node, scope);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
walkChildren(node, scope);
|
|
728
|
+
};
|
|
729
|
+
walk(root, new Map());
|
|
730
|
+
return failure ? { scopes, failure } : { scopes };
|
|
731
|
+
}
|
|
732
|
+
function variableScopeForCommand(scopes, command) {
|
|
733
|
+
return scopes.get(nodeKey(command)) ?? EMPTY_VARIABLE_SCOPE;
|
|
734
|
+
}
|
|
735
|
+
function shellQuoted(argument) {
|
|
736
|
+
if (argument !== '' && !/["'\\ \t\n$`;|&<>(){}*?[\]~#]/u.test(argument)) {
|
|
737
|
+
return argument;
|
|
738
|
+
}
|
|
739
|
+
return `'${argument.replace(/'/gu, `'\\''`)}'`;
|
|
740
|
+
}
|
|
741
|
+
function resolvedCommandText(node, argv) {
|
|
742
|
+
return /\$[A-Za-z_]/u.test(node.text) || node.text.includes('\n')
|
|
743
|
+
? argv.map(shellQuoted).join(' ')
|
|
744
|
+
: node.text;
|
|
745
|
+
}
|
|
746
|
+
const REDIRECT_OPERATORS = new Set([
|
|
747
|
+
'>',
|
|
748
|
+
'>>',
|
|
749
|
+
'<',
|
|
750
|
+
'>&',
|
|
751
|
+
'<&',
|
|
752
|
+
'>|',
|
|
753
|
+
'&>',
|
|
754
|
+
'&>>',
|
|
755
|
+
'<<<',
|
|
756
|
+
]);
|
|
757
|
+
export function analyzeBashStructure(source) {
|
|
758
|
+
const tree = parser.parse(source);
|
|
759
|
+
if (tree.rootNode.hasError) {
|
|
760
|
+
return { parsed: false, reason: 'Command syntax could not be parsed' };
|
|
761
|
+
}
|
|
762
|
+
const permissionUnits = commandNodes(tree.rootNode);
|
|
763
|
+
if (permissionUnits.length > MAX_BASH_PERMISSION_COMMANDS) {
|
|
764
|
+
return {
|
|
765
|
+
parsed: false,
|
|
766
|
+
reason: `Command contains more than ${MAX_BASH_PERMISSION_COMMANDS} permission units`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const nodes = rawCommandNodes(tree.rootNode);
|
|
770
|
+
const commandScopes = buildCommandScopes(tree.rootNode);
|
|
771
|
+
if (commandScopes.failure) {
|
|
772
|
+
return { parsed: false, reason: commandScopes.failure };
|
|
773
|
+
}
|
|
774
|
+
const commands = [];
|
|
775
|
+
for (const node of nodes) {
|
|
776
|
+
const parsed = commandArgv(node, variableScopeForCommand(commandScopes.scopes, node));
|
|
777
|
+
if (!parsed.ok || !parsed.argv) {
|
|
778
|
+
return {
|
|
779
|
+
parsed: false,
|
|
780
|
+
reason: !parsed.ok
|
|
781
|
+
? `Command contains dynamic ${parsed.nodeType}`
|
|
782
|
+
: 'Command cannot be statically analyzed',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
commands.push({
|
|
786
|
+
text: resolvedCommandText(node, parsed.argv),
|
|
787
|
+
argv: parsed.argv,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const redirects = [];
|
|
791
|
+
let failure;
|
|
792
|
+
const visit = (node) => {
|
|
793
|
+
if (failure)
|
|
794
|
+
return;
|
|
795
|
+
if (node.type === 'file_redirect') {
|
|
796
|
+
const destination = node.childForFieldName('destination');
|
|
797
|
+
const operator = node.children.find((child) => !child.isNamed &&
|
|
798
|
+
REDIRECT_OPERATORS.has(child.type))?.type;
|
|
799
|
+
if (destination && operator) {
|
|
800
|
+
const target = staticNodeValue(destination, false);
|
|
801
|
+
if (!target.ok) {
|
|
802
|
+
failure = `Redirect target contains dynamic ${target.nodeType}`;
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
redirects.push({ operator, target: target.value });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
for (const child of node.namedChildren)
|
|
809
|
+
visit(child);
|
|
810
|
+
};
|
|
811
|
+
visit(tree.rootNode);
|
|
812
|
+
return failure
|
|
813
|
+
? { parsed: false, reason: failure }
|
|
814
|
+
: { parsed: true, commands, redirects };
|
|
815
|
+
}
|
|
816
|
+
export function validateBashSemantics(source) {
|
|
817
|
+
if (CONTROL_CHARACTER.test(source)) {
|
|
818
|
+
return { safe: false, reason: 'Command contains control characters' };
|
|
819
|
+
}
|
|
820
|
+
if (UNICODE_WHITESPACE.test(source)) {
|
|
821
|
+
return { safe: false, reason: 'Command contains Unicode whitespace' };
|
|
822
|
+
}
|
|
823
|
+
if (BACKSLASH_WHITESPACE.test(source)) {
|
|
824
|
+
return {
|
|
825
|
+
safe: false,
|
|
826
|
+
reason: 'Backslash-escaped whitespace cannot be statically analyzed',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (/~\[/u.test(source)) {
|
|
830
|
+
return {
|
|
831
|
+
safe: false,
|
|
832
|
+
reason: 'Command contains zsh dynamic directory syntax',
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
if (/(?:^|[\s;&|])=[A-Za-z_]/u.test(source)) {
|
|
836
|
+
return { safe: false, reason: 'Command contains zsh equals expansion' };
|
|
837
|
+
}
|
|
838
|
+
const tree = parser.parse(source);
|
|
839
|
+
if (tree.rootNode.hasError) {
|
|
840
|
+
return { safe: false, reason: 'Command syntax could not be parsed' };
|
|
841
|
+
}
|
|
842
|
+
const permissionUnits = commandNodes(tree.rootNode);
|
|
843
|
+
if (permissionUnits.length > MAX_BASH_PERMISSION_COMMANDS) {
|
|
844
|
+
return {
|
|
845
|
+
safe: false,
|
|
846
|
+
reason: `Command contains more than ${MAX_BASH_PERMISSION_COMMANDS} permission units`,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
const commands = rawCommandNodes(tree.rootNode);
|
|
850
|
+
const commandScopes = buildCommandScopes(tree.rootNode);
|
|
851
|
+
if (commandScopes.failure) {
|
|
852
|
+
return { safe: false, reason: commandScopes.failure };
|
|
853
|
+
}
|
|
854
|
+
let structuralFailure;
|
|
855
|
+
const inspect = (node) => {
|
|
856
|
+
if (structuralFailure)
|
|
857
|
+
return;
|
|
858
|
+
if ((node.type === 'word' || node.type === 'concatenation') &&
|
|
859
|
+
BRACE_EXPANSION.test(node.text)) {
|
|
860
|
+
structuralFailure = 'Command contains brace expansion';
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (node.type === 'function_definition' || node.type === 'case_statement') {
|
|
864
|
+
structuralFailure = `${node.type === 'function_definition' ? 'Shell function definition' : 'Case statement'} cannot be statically analyzed`;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (node.type === 'for_statement') {
|
|
868
|
+
const variable = node.childForFieldName('variable')?.text;
|
|
869
|
+
if (variable === 'IFS' || variable === 'PS4') {
|
|
870
|
+
structuralFailure = `${variable} loop variable bypasses assignment validation`;
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (node.type === 'heredoc_redirect') {
|
|
875
|
+
const start = node.namedChildren.find((child) => child.type === 'heredoc_start')?.text;
|
|
876
|
+
const quoted = start !== undefined &&
|
|
877
|
+
((start.startsWith("'") && start.endsWith("'")) ||
|
|
878
|
+
(start.startsWith('"') && start.endsWith('"')) ||
|
|
879
|
+
start.startsWith('\\'));
|
|
880
|
+
if (!quoted) {
|
|
881
|
+
structuralFailure =
|
|
882
|
+
'Heredoc with unquoted delimiter undergoes shell expansion';
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (node.type === 'herestring_redirect') {
|
|
887
|
+
const content = node.namedChildren.at(-1);
|
|
888
|
+
if (!content) {
|
|
889
|
+
structuralFailure = 'Here-string content cannot be statically analyzed';
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const value = staticNodeValue(content, false, EMPTY_VARIABLE_SCOPE);
|
|
893
|
+
if (!value.ok || NEWLINE_COMMENT.test(value.value)) {
|
|
894
|
+
structuralFailure =
|
|
895
|
+
'Here-string expansion cannot be statically analyzed';
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (node.type === 'variable_assignment') {
|
|
900
|
+
const name = node.childForFieldName('name')?.text;
|
|
901
|
+
const valueNode = node.childForFieldName('value');
|
|
902
|
+
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
|
|
903
|
+
structuralFailure = 'Invalid variable assignment name';
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (name === 'IFS') {
|
|
907
|
+
structuralFailure =
|
|
908
|
+
'IFS assignment changes word splitting and cannot be statically analyzed';
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (valueNode &&
|
|
912
|
+
valueNode.type !== 'raw_string' &&
|
|
913
|
+
valueNode.type !== 'string' &&
|
|
914
|
+
valueNode.text.includes('~')) {
|
|
915
|
+
structuralFailure =
|
|
916
|
+
'Tilde in assignment value may expand before permission validation';
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (name === 'PS4') {
|
|
920
|
+
if (node.children.some((child) => child.type === '+=')) {
|
|
921
|
+
structuralFailure = 'PS4 append cannot be statically analyzed';
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const value = valueNode
|
|
925
|
+
? staticNodeValue(valueNode, true, EMPTY_VARIABLE_SCOPE)
|
|
926
|
+
: { ok: true, value: '' };
|
|
927
|
+
if (!value.ok ||
|
|
928
|
+
value.value.includes(SUBSTITUTION_PLACEHOLDER) ||
|
|
929
|
+
!/^[A-Za-z0-9 _+:./=[\]-]*$/u.test(value.value.replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/gu, ''))) {
|
|
930
|
+
structuralFailure = 'PS4 value cannot be statically verified';
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (node.type === 'declaration_command') {
|
|
936
|
+
const parsed = declarationArgv(node, variableScopeForCommand(commandScopes.scopes, node));
|
|
937
|
+
if (!parsed.ok || !parsed.argv) {
|
|
938
|
+
structuralFailure = 'Declaration command cannot be statically analyzed';
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
structuralFailure = declarationSemanticFailure(parsed.argv);
|
|
942
|
+
if (structuralFailure)
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (node.type === 'test_command') {
|
|
946
|
+
const text = node.text;
|
|
947
|
+
if (/(?:-v|-R)\s+["']?[^\s"']*\[/u.test(text) ||
|
|
948
|
+
/["']?[^\s"']*\[[^\s]*\s+(?:-eq|-ne|-lt|-le|-gt|-ge)\b/u.test(text) ||
|
|
949
|
+
/\b(?:-eq|-ne|-lt|-le|-gt|-ge)\s+["']?[^\s"']*\[/u.test(text)) {
|
|
950
|
+
structuralFailure = 'Test operand contains array subscript evaluation';
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (node.type === 'unset_command' &&
|
|
955
|
+
/(?:^|\s)["']?[^\s"']*\[/u.test(node.text)) {
|
|
956
|
+
structuralFailure = 'unset operand contains array subscript evaluation';
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (node.type === 'file_redirect') {
|
|
960
|
+
const destination = node.childForFieldName('destination');
|
|
961
|
+
if (destination) {
|
|
962
|
+
const target = staticNodeValue(destination, false);
|
|
963
|
+
if (!target.ok) {
|
|
964
|
+
structuralFailure = `Redirect target contains dynamic ${target.nodeType}`;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (PROC_ENVIRON.test(target.value)) {
|
|
968
|
+
structuralFailure = 'Accesses /proc/*/environ';
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (NEWLINE_COMMENT.test(target.value)) {
|
|
972
|
+
structuralFailure =
|
|
973
|
+
'Newline followed by # can hide arguments from validation';
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
for (const child of node.namedChildren)
|
|
979
|
+
inspect(child);
|
|
980
|
+
};
|
|
981
|
+
inspect(tree.rootNode);
|
|
982
|
+
if (structuralFailure)
|
|
983
|
+
return { safe: false, reason: structuralFailure };
|
|
984
|
+
for (const command of commands) {
|
|
985
|
+
const scope = variableScopeForCommand(commandScopes.scopes, command);
|
|
986
|
+
const parsed = commandArgv(command, scope);
|
|
987
|
+
if (!parsed.ok || !parsed.argv) {
|
|
988
|
+
const nameNode = command.childForFieldName('name');
|
|
989
|
+
const parsedName = nameNode
|
|
990
|
+
? staticNodeValue(nameNode, false, scope)
|
|
991
|
+
: undefined;
|
|
992
|
+
const failureNode = !parsed.ok ? parsed.nodeType : command.type;
|
|
993
|
+
return {
|
|
994
|
+
safe: false,
|
|
995
|
+
reason: parsedName && !parsedName.ok
|
|
996
|
+
? `Command name is runtime-determined (${failureNode})`
|
|
997
|
+
: failureNode === 'command_substitution'
|
|
998
|
+
? 'Command contains bare command substitution that cannot be statically analyzed'
|
|
999
|
+
: 'Command cannot be statically analyzed',
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
const failure = firstSemanticFailure(parsed.argv);
|
|
1003
|
+
if (failure)
|
|
1004
|
+
return { safe: false, reason: failure };
|
|
1005
|
+
}
|
|
1006
|
+
return { safe: true };
|
|
1007
|
+
}
|
|
1008
|
+
export function analyzeBashCommands(source) {
|
|
1009
|
+
const trimmed = source.trim();
|
|
1010
|
+
if (!trimmed)
|
|
1011
|
+
return { commands: [], parsed: true };
|
|
1012
|
+
const tree = parser.parse(source);
|
|
1013
|
+
if (tree.rootNode.hasError) {
|
|
1014
|
+
return { commands: [trimmed], parsed: false };
|
|
1015
|
+
}
|
|
1016
|
+
const staticAnalysis = analyzeBashStructure(source);
|
|
1017
|
+
const resolvedByCommand = new Map();
|
|
1018
|
+
if (staticAnalysis.parsed) {
|
|
1019
|
+
rawCommandNodes(tree.rootNode).forEach((node, index) => {
|
|
1020
|
+
const resolved = staticAnalysis.commands[index];
|
|
1021
|
+
if (resolved && resolved.text !== node.text) {
|
|
1022
|
+
resolvedByCommand.set(`${node.startIndex}:${node.endIndex}`, resolved.text);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const ranges = new Map();
|
|
1027
|
+
for (const node of commandNodes(tree.rootNode)) {
|
|
1028
|
+
ranges.set(`${node.startIndex}:${node.endIndex}`, node);
|
|
1029
|
+
}
|
|
1030
|
+
const commands = [...ranges.values()]
|
|
1031
|
+
.sort((left, right) => left.startIndex - right.startIndex || right.endIndex - left.endIndex)
|
|
1032
|
+
.map((node) => {
|
|
1033
|
+
const command = node.type === 'command'
|
|
1034
|
+
? node
|
|
1035
|
+
: rawCommandNodes(node).find((candidate) => candidate.startIndex >= node.startIndex);
|
|
1036
|
+
return ((command
|
|
1037
|
+
? resolvedByCommand.get(`${command.startIndex}:${command.endIndex}`)
|
|
1038
|
+
: undefined) ?? source.slice(node.startIndex, node.endIndex)).trim();
|
|
1039
|
+
})
|
|
1040
|
+
.filter(Boolean);
|
|
1041
|
+
if (commands.length > MAX_BASH_PERMISSION_COMMANDS) {
|
|
1042
|
+
return { commands: [trimmed], parsed: false };
|
|
1043
|
+
}
|
|
1044
|
+
return { commands: commands.length > 0 ? commands : [trimmed], parsed: true };
|
|
1045
|
+
}
|
|
1046
|
+
//# sourceMappingURL=bash-ast.js.map
|