@shanepadgett/tau-agent 0.35.0 → 0.37.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.
@@ -0,0 +1,501 @@
1
+ import { parse } from "unbash";
2
+ import type { ArithmeticExpression, Command, Node, ParsedScript, Redirect, Word, WordPart } from "unbash";
3
+
4
+ const ALWAYS_SKIP = new Set([
5
+ "cat",
6
+ "head",
7
+ "tail",
8
+ "wc",
9
+ "ls",
10
+ "tree",
11
+ "pwd",
12
+ "echo",
13
+ "printf",
14
+ "true",
15
+ "false",
16
+ "test",
17
+ "[",
18
+ "grep",
19
+ "egrep",
20
+ "fgrep",
21
+ "cut",
22
+ "tr",
23
+ "uniq",
24
+ "comm",
25
+ "cmp",
26
+ "paste",
27
+ "join",
28
+ "column",
29
+ "fmt",
30
+ "fold",
31
+ "nl",
32
+ "rev",
33
+ "tac",
34
+ "expand",
35
+ "unexpand",
36
+ "tsort",
37
+ "pr",
38
+ "seq",
39
+ "expr",
40
+ "getconf",
41
+ "id",
42
+ "whoami",
43
+ "uname",
44
+ "printenv",
45
+ "type",
46
+ "which",
47
+ "dirname",
48
+ "basename",
49
+ "readlink",
50
+ "realpath",
51
+ "file",
52
+ "stat",
53
+ "du",
54
+ "df",
55
+ "diff",
56
+ "md5sum",
57
+ "sha1sum",
58
+ "sha256sum",
59
+ "shasum",
60
+ "ps",
61
+ "uptime",
62
+ "sleep",
63
+ "cd",
64
+ "bat",
65
+ "jq",
66
+ ]);
67
+
68
+ const FIND_VETO = new Set(["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fls", "-fprint", "-fprint0", "-fprintf"]);
69
+
70
+ const GIT_READ_VERBS = new Set([
71
+ "status",
72
+ "log",
73
+ "diff",
74
+ "show",
75
+ "blame",
76
+ "rev-parse",
77
+ "describe",
78
+ "ls-files",
79
+ "ls-tree",
80
+ "cat-file",
81
+ "rev-list",
82
+ "name-rev",
83
+ "merge-base",
84
+ "reflog",
85
+ "grep",
86
+ "for-each-ref",
87
+ "shortlog",
88
+ "version",
89
+ "help",
90
+ "count-objects",
91
+ "whatchanged",
92
+ "cherry",
93
+ "range-diff",
94
+ "var",
95
+ ]);
96
+
97
+ const WRITE_REDIRECTS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
98
+ const SED_PRINT = /^(?:\d+|\d+,\d+)p$/;
99
+
100
+ interface WalkState {
101
+ sawCd: boolean;
102
+ sawGit: boolean;
103
+ }
104
+
105
+ export function isAllowlistedBash(command: string): boolean {
106
+ if (!command.trim()) return false;
107
+ let script: ParsedScript;
108
+ try {
109
+ script = parse(command);
110
+ } catch {
111
+ return false;
112
+ }
113
+ const state: WalkState = { sawCd: false, sawGit: false };
114
+ return walkScript(script, state) && !(state.sawCd && state.sawGit);
115
+ }
116
+
117
+ function walkScript(script: ParsedScript, state: WalkState): boolean {
118
+ if (script.errors && script.errors.length > 0) return false;
119
+ if (script.shebang) return false;
120
+ if (script.commands.length === 0) return false;
121
+ return script.commands.every((statement) => walkNode(statement, state));
122
+ }
123
+
124
+ function walkNode(node: Node, state: WalkState): boolean {
125
+ switch (node.type) {
126
+ case "Statement":
127
+ if (node.background) return false;
128
+ return walkRedirects(node.redirects, state) && walkNode(node.command, state);
129
+ case "Command":
130
+ return walkCommand(node, state);
131
+ case "Pipeline":
132
+ if (node.time || node.negated) return false;
133
+ if (node.operators.includes("|&")) return false;
134
+ return node.commands.every((child) => walkNode(child, state));
135
+ case "AndOr":
136
+ return node.commands.every((child) => walkNode(child, state));
137
+ case "CompoundList":
138
+ return node.commands.every((child) => walkNode(child, state));
139
+ default:
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function walkCommand(command: Command, state: WalkState): boolean {
145
+ if (command.prefix.length > 0) return false;
146
+ if (!command.name) return false;
147
+ if (!walkRedirects(command.redirects, state)) return false;
148
+ if (!walkWord(command.name, state)) return false;
149
+ if (!command.suffix.every((word) => walkWord(word, state))) return false;
150
+
151
+ const name = staticBareName(command.name);
152
+ if (!name) return false;
153
+ if (name === "cd") state.sawCd = true;
154
+ if (name === "git") state.sawGit = true;
155
+ if (state.sawCd && state.sawGit) return false;
156
+ if (ALWAYS_SKIP.has(name)) return true;
157
+
158
+ const args: string[] = [];
159
+ for (const word of command.suffix) {
160
+ const value = staticWordValue(word);
161
+ if (value === undefined) return false;
162
+ args.push(value);
163
+ }
164
+
165
+ switch (name) {
166
+ case "find":
167
+ return !args.some((arg) => FIND_VETO.has(arg));
168
+ case "fd":
169
+ return !hasFdVeto(args);
170
+ case "rg":
171
+ return !hasRgVeto(args);
172
+ case "sort":
173
+ case "base64":
174
+ case "iconv":
175
+ return !hasOutputFlag(args);
176
+ case "xxd":
177
+ return !hasShortOption(args, "r") && !hasLongOption(args, "revert");
178
+ case "yq":
179
+ return !hasYqInplace(args);
180
+ case "sed":
181
+ return isSafeSed(args);
182
+ case "hostname":
183
+ return !hasPositional(args);
184
+ case "date":
185
+ return !hasShortOption(args, "s") && !hasLongOption(args, "set");
186
+ case "git":
187
+ return isSafeGit(args);
188
+ default:
189
+ return false;
190
+ }
191
+ }
192
+
193
+ function walkRedirects(redirects: Redirect[], state: WalkState): boolean {
194
+ for (const redirect of redirects) {
195
+ if (redirect.target && !walkWord(redirect.target, state)) return false;
196
+ if (redirect.body && !walkWord(redirect.body, state)) return false;
197
+ if (WRITE_REDIRECTS.has(redirect.operator)) {
198
+ if (!redirect.target || staticWordValue(redirect.target) !== "/dev/null") return false;
199
+ continue;
200
+ }
201
+ if (redirect.operator === ">&" || redirect.operator === "<&") {
202
+ const target = redirect.target ? staticWordValue(redirect.target) : undefined;
203
+ if (target === "1" || target === "2" || target === "-") continue;
204
+ if (redirect.operator === "<&" && target === "0") continue;
205
+ return false;
206
+ }
207
+ if (
208
+ redirect.operator !== "<" &&
209
+ redirect.operator !== "<<" &&
210
+ redirect.operator !== "<<-" &&
211
+ redirect.operator !== "<<<"
212
+ ) {
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+
219
+ function walkWord(word: Word, state: WalkState): boolean {
220
+ const parts = word.parts;
221
+ if (!parts) return true;
222
+ return parts.every((part) => walkPart(part, state));
223
+ }
224
+
225
+ function walkPart(part: WordPart, state: WalkState): boolean {
226
+ switch (part.type) {
227
+ case "Literal":
228
+ case "SingleQuoted":
229
+ case "AnsiCQuoted":
230
+ case "SimpleExpansion":
231
+ return true;
232
+ case "DoubleQuoted":
233
+ case "LocaleString":
234
+ return part.parts.every((child) => walkPart(child, state));
235
+ case "ParameterExpansion":
236
+ if (part.indexParts && !part.indexParts.every((child) => walkPart(child, state))) return false;
237
+ if (part.operand && !walkWord(part.operand, state)) return false;
238
+ if (part.slice) {
239
+ if (!walkWord(part.slice.offset, state)) return false;
240
+ if (part.slice.length && !walkWord(part.slice.length, state)) return false;
241
+ }
242
+ if (part.replace) {
243
+ if (!walkWord(part.replace.pattern, state)) return false;
244
+ if (!walkWord(part.replace.replacement, state)) return false;
245
+ }
246
+ return true;
247
+ case "CommandExpansion":
248
+ return walkNestedScript(part.script, state);
249
+ case "ProcessSubstitution":
250
+ return false;
251
+ case "ArithmeticExpansion":
252
+ return part.expression ? walkArithmetic(part.expression, state) : false;
253
+ case "ExtendedGlob":
254
+ case "BraceExpansion":
255
+ return !part.parts || part.parts.every((child) => walkPart(child, state));
256
+ }
257
+ }
258
+
259
+ function walkArithmetic(expression: ArithmeticExpression, state: WalkState): boolean {
260
+ switch (expression.type) {
261
+ case "ArithmeticBinary":
262
+ return walkArithmetic(expression.left, state) && walkArithmetic(expression.right, state);
263
+ case "ArithmeticUnary":
264
+ return walkArithmetic(expression.operand, state);
265
+ case "ArithmeticTernary":
266
+ return (
267
+ walkArithmetic(expression.test, state) &&
268
+ walkArithmetic(expression.consequent, state) &&
269
+ walkArithmetic(expression.alternate, state)
270
+ );
271
+ case "ArithmeticGroup":
272
+ return walkArithmetic(expression.expression, state);
273
+ case "ArithmeticWord": {
274
+ const parts = expression.parts;
275
+ return !parts || parts.every((part) => walkPart(part, state));
276
+ }
277
+ case "ArithmeticCommandExpansion":
278
+ return walkNestedScript(expression.script, state);
279
+ }
280
+ }
281
+
282
+ function walkNestedScript(script: ParsedScript | undefined, state: WalkState): boolean {
283
+ if (!script) return false;
284
+ return walkScript(script, state);
285
+ }
286
+
287
+ function staticBareName(word: Word): string | undefined {
288
+ const value = staticWordValue(word);
289
+ if (!value || value.includes("/")) return undefined;
290
+ return value;
291
+ }
292
+
293
+ function staticWordValue(word: Word): string | undefined {
294
+ const parts = word.parts;
295
+ if (!parts) return word.value;
296
+ if (!parts.every(isStaticPart)) return undefined;
297
+ return word.value;
298
+ }
299
+
300
+ function isStaticPart(part: WordPart): boolean {
301
+ switch (part.type) {
302
+ case "Literal":
303
+ case "SingleQuoted":
304
+ case "AnsiCQuoted":
305
+ return true;
306
+ case "DoubleQuoted":
307
+ case "LocaleString":
308
+ return part.parts.every((child) => child.type === "Literal");
309
+ default:
310
+ return false;
311
+ }
312
+ }
313
+
314
+ function hasFdVeto(args: readonly string[]): boolean {
315
+ return (
316
+ hasShortOption(args, "x") ||
317
+ hasShortOption(args, "X") ||
318
+ hasLongOption(args, "exec") ||
319
+ hasLongOption(args, "exec-batch")
320
+ );
321
+ }
322
+
323
+ function hasRgVeto(args: readonly string[]): boolean {
324
+ return (
325
+ hasShortOption(args, "z") ||
326
+ hasLongOption(args, "pre") ||
327
+ hasLongOption(args, "hostname-bin") ||
328
+ hasLongOption(args, "search-zip")
329
+ );
330
+ }
331
+
332
+ function hasOutputFlag(args: readonly string[]): boolean {
333
+ return hasShortOption(args, "o") || hasLongOption(args, "output");
334
+ }
335
+
336
+ function hasYqInplace(args: readonly string[]): boolean {
337
+ return hasShortOption(args, "i") || hasLongOption(args, "inplace") || hasLongOption(args, "in-place");
338
+ }
339
+
340
+ function isSafeSed(args: readonly string[]): boolean {
341
+ if (args.length < 2 || args[0] !== "-n") return false;
342
+ const script = args[1];
343
+ if (script === undefined || !SED_PRINT.test(script)) return false;
344
+ for (const arg of args.slice(2)) {
345
+ if (arg === "--") continue;
346
+ if (arg.startsWith("-")) return false;
347
+ }
348
+ return true;
349
+ }
350
+
351
+ function hasPositional(args: readonly string[]): boolean {
352
+ let endFlags = false;
353
+ for (const arg of args) {
354
+ if (!endFlags) {
355
+ if (arg === "--") {
356
+ endFlags = true;
357
+ continue;
358
+ }
359
+ if (arg.startsWith("-") && arg !== "-") continue;
360
+ }
361
+ return true;
362
+ }
363
+ return false;
364
+ }
365
+
366
+ function isSafeGit(args: readonly string[]): boolean {
367
+ if (hasGitWriteExecFlag(args)) return false;
368
+ const verbIndex = gitVerbIndex(args);
369
+ if (verbIndex === undefined) return false;
370
+ const verb = args[verbIndex];
371
+ if (verb === undefined) return false;
372
+ const rest = args.slice(verbIndex + 1);
373
+ if (GIT_READ_VERBS.has(verb)) return true;
374
+ if (verb === "branch") return isGitBranch(rest);
375
+ if (verb === "remote") return isGitRemote(rest);
376
+ if (verb === "tag") return isGitTag(rest);
377
+ return false;
378
+ }
379
+
380
+ function gitVerbIndex(args: readonly string[]): number | undefined {
381
+ for (let index = 0; index < args.length; index += 1) {
382
+ const arg = args[index];
383
+ if (arg === undefined || arg === "--") return undefined;
384
+ if (arg === "--no-pager" || arg === "-P" || arg === "--no-color") continue;
385
+ if (
386
+ arg === "-C" ||
387
+ arg === "-c" ||
388
+ arg === "-p" ||
389
+ arg === "--paginate" ||
390
+ isLongOption(arg, "config-env") ||
391
+ isLongOption(arg, "exec-path") ||
392
+ isLongOption(arg, "git-dir") ||
393
+ isLongOption(arg, "work-tree") ||
394
+ isLongOption(arg, "namespace") ||
395
+ isLongOption(arg, "super-prefix")
396
+ ) {
397
+ return undefined;
398
+ }
399
+ if (arg.startsWith("-")) return undefined;
400
+ return index;
401
+ }
402
+ return undefined;
403
+ }
404
+
405
+ function hasGitWriteExecFlag(args: readonly string[]): boolean {
406
+ for (const arg of args) {
407
+ if (arg === "--") break;
408
+ if (
409
+ arg === "--paginate" ||
410
+ isLongOption(arg, "output") ||
411
+ isLongOption(arg, "ext-diff") ||
412
+ isLongOption(arg, "textconv") ||
413
+ isLongOption(arg, "exec")
414
+ ) {
415
+ return true;
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+
421
+ function isGitBranch(args: readonly string[]): boolean {
422
+ for (let index = 0; index < args.length; index += 1) {
423
+ const arg = args[index];
424
+ if (arg === undefined || arg === "--") return false;
425
+ if (
426
+ arg === "--list" ||
427
+ arg === "--all" ||
428
+ arg === "--remotes" ||
429
+ arg === "--verbose" ||
430
+ arg === "--show-current"
431
+ ) {
432
+ continue;
433
+ }
434
+ if (isLongOption(arg, "format")) {
435
+ if (arg === "--format") {
436
+ if (args[index + 1] === undefined) return false;
437
+ index += 1;
438
+ }
439
+ continue;
440
+ }
441
+ if (arg.startsWith("--")) return false;
442
+ if (!arg.startsWith("-") || arg.length < 2) return false;
443
+ for (const flag of arg.slice(1)) {
444
+ if (flag !== "a" && flag !== "l" && flag !== "r" && flag !== "v") return false;
445
+ }
446
+ }
447
+ return true;
448
+ }
449
+
450
+ function isGitRemote(args: readonly string[]): boolean {
451
+ let index = 0;
452
+ while (index < args.length) {
453
+ const arg = args[index];
454
+ if (arg !== "-v" && arg !== "--verbose") break;
455
+ index += 1;
456
+ }
457
+ if (index >= args.length) return true;
458
+ const verb = args[index];
459
+ return verb === "show" || verb === "get-url";
460
+ }
461
+
462
+ function isGitTag(args: readonly string[]): boolean {
463
+ let listing = false;
464
+ for (const arg of args) {
465
+ if (arg === "--") return false;
466
+ if (arg === "-l" || arg === "--list") {
467
+ listing = true;
468
+ continue;
469
+ }
470
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1) {
471
+ for (const flag of arg.slice(1)) {
472
+ if (flag !== "l") return false;
473
+ }
474
+ listing = true;
475
+ continue;
476
+ }
477
+ if (arg.startsWith("-") || !listing) return false;
478
+ }
479
+ return true;
480
+ }
481
+
482
+ function hasShortOption(args: readonly string[], letter: string): boolean {
483
+ for (const arg of args) {
484
+ if (arg === "--") break;
485
+ if (arg.length < 2 || !arg.startsWith("-") || arg.startsWith("--")) continue;
486
+ if (arg.slice(1).includes(letter)) return true;
487
+ }
488
+ return false;
489
+ }
490
+
491
+ function hasLongOption(args: readonly string[], name: string): boolean {
492
+ for (const arg of args) {
493
+ if (arg === "--") break;
494
+ if (isLongOption(arg, name)) return true;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ function isLongOption(arg: string, name: string): boolean {
500
+ return arg === `--${name}` || arg.startsWith(`--${name}=`);
501
+ }