deterministic.ts
23829 bytes
1import { lstatSync, readlinkSync, statSync } from "node:fs";
2import { dirname, isAbsolute, resolve } from "node:path";
3import { parseBash, type BashCommand, type BashNode, type BashParseResult } from "./bash";
4import { resolveBashGlob } from "./bash-glob";
5import type { Decision } from "./types";
6
7export const ALLOW_COMMANDS: ReadonlySet<string> = new Set([
8 "./gradlew",
9 "base64",
10 "break",
11 "bun",
12 "bunx",
13 "cargo",
14 "cat",
15 "cd",
16 "chmod",
17 "chown",
18 "composer",
19 "continue",
20 "cp",
21 "cut",
22 "date",
23 "deno",
24 "dirname",
25 "dotnet",
26 "echo",
27 "elixir",
28 "exit",
29 "file",
30 "gofmt",
31 "git",
32 "getent",
33 "go",
34 "golangci-lint",
35 "grep",
36 "head",
37 "id",
38 "jar",
39 "java",
40 "javac",
41 "jq",
42 "kotlinc",
43 "ln",
44 "lsof",
45 "ls",
46 "make",
47 "mix",
48 "mkdir",
49 "mvn",
50 "mv",
51 "netstat",
52 "nl",
53 "node",
54 "npm",
55 "npx",
56 "php",
57 "pip",
58 "pip3",
59 "pnpm",
60 "poetry",
61 "printf",
62 "ps",
63 "pwd",
64 "python",
65 "python3",
66 "qmd",
67 "readlink",
68 "rocm-smi",
69 "rg",
70 "rmdir",
71 "rm",
72 "ruby",
73 "rustc",
74 "sbt",
75 "scala",
76 "sleep",
77 "sort",
78 "ss",
79 "stat",
80 "strings",
81 "swift",
82 "tail",
83 "tc",
84 "tee",
85 "touch",
86 "tree",
87 "tr",
88 "true",
89 "type",
90 "uniq",
91 "uv",
92 "wc",
93 "whereis",
94 "which",
95 "yarn",
96 "yarnpkg",
97]);
98
99const SYSTEM_DIRECTORIES = [
100 "/bin",
101 "/boot",
102 "/dev",
103 "/etc",
104 "/lib",
105 "/lib64",
106 "/opt",
107 "/proc",
108 "/root",
109 "/run",
110 "/sbin",
111 "/sys",
112 "/usr",
113 "/var",
114];
115const MUTATIONS = new Set(["chmod", "chown", "cp", "ln", "mkdir", "mv", "rm", "rmdir", "tee", "touch"]);
116const FIND_OPERATORS = new Set(["!", "-not", "-a", "-and", "-o", "-or", "(", ")"]);
117const FIND_UNARY = new Set([
118 "-depth",
119 "-empty",
120 "-ls",
121 "-print",
122 "-print0",
123 "-prune",
124 "-readable",
125 "-writable",
126 "-executable",
127]);
128const KUBECTL_MUTATIONS = new Set([
129 "apply",
130 "autoscale",
131 "cordon",
132 "create",
133 "delete",
134 "drain",
135 "edit",
136 "expose",
137 "label",
138 "patch",
139 "replace",
140 "scale",
141 "taint",
142 "uncordon",
143]);
144const KUBECTL_SUBCOMMAND_MUTATIONS = new Map<string, ReadonlySet<string>>([
145 ["auth", new Set(["reconcile"])],
146 ["certificate", new Set(["approve", "deny"])],
147 ["rollout", new Set(["pause", "restart", "resume", "undo"])],
148 ["set", new Set(["env", "image", "resources", "selector", "serviceaccount", "subject"])],
149]);
150const KUBECTL_VALUE_OPTIONS = new Set([
151 "--as",
152 "--as-group",
153 "--as-uid",
154 "--cache-dir",
155 "--certificate-authority",
156 "--client-certificate",
157 "--client-key",
158 "--cluster",
159 "--context",
160 "--kubeconfig",
161 "--kuberc",
162 "--namespace",
163 "--password",
164 "--profile",
165 "--request-timeout",
166 "--server",
167 "--tls-server-name",
168 "--token",
169 "--user",
170 "--username",
171 "--v",
172 "--vmodule",
173 "-n",
174 "-s",
175 "-v",
176]);
177const AWS_MUTATION_PREFIXES = [
178 "abort-",
179 "accept-",
180 "activate-",
181 "add-",
182 "allocate-",
183 "approve-",
184 "associate-",
185 "attach-",
186 "authorize-",
187 "batch-write-",
188 "cancel-",
189 "commit-",
190 "complete-",
191 "copy-",
192 "create-",
193 "deactivate-",
194 "delete-",
195 "deregister-",
196 "detach-",
197 "disable-",
198 "disassociate-",
199 "enable-",
200 "execute-",
201 "import-",
202 "invoke-",
203 "merge-",
204 "modify-",
205 "move-",
206 "patch-",
207 "promote-",
208 "publish-",
209 "put-",
210 "register-",
211 "reject-",
212 "release-",
213 "remove-",
214 "replace-",
215 "reset-",
216 "restore-",
217 "reboot-",
218 "revoke-",
219 "rotate-",
220 "run-",
221 "send-",
222 "set-",
223 "start-",
224 "stop-",
225 "tag-",
226 "terminate-",
227 "transact-write-",
228 "unauthorize-",
229 "untag-",
230 "update-",
231];
232const AWS_VALUE_OPTIONS = new Set([
233 "--ca-bundle",
234 "--cli-binary-format",
235 "--cli-connect-timeout",
236 "--cli-read-timeout",
237 "--color",
238 "--endpoint-url",
239 "--output",
240 "--profile",
241 "--region",
242]);
243
244function ask(reason: string): Decision {
245 return { decision: "ask", reason, category: "dangerous" };
246}
247
248function secretPath(path: string): boolean {
249 if (
250 /(?:^|\/)(?:\.env[A-Za-z0-9._-]*|(?:credential|token|secret|password|passwd|api[-_]?key)s?(?:[._-][A-Za-z0-9_-]+)*|id_(?:rsa|dsa|ecdsa|ed25519)|[^/]+\.(?:pem|key|p12|pfx))(?:\/|$)/i.test(
251 path,
252 )
253 )
254 return true;
255 const home = process.env.HOME;
256 if (!home || !path.startsWith(`${home}/`)) return false;
257 return /^(?:\.(?:aws|ssh|azure|kube|gnupg|oci)(?:\/|$)|\.config\/(?:gcloud|gh|hub|azure|doctl|rclone|sops|containers)(?:\/|$)|\.local\/share\/keyrings(?:\/|$)|\.docker\/config\.json$|\.(?:netrc|npmrc|pypirc|git-credentials)$)/i.test(
258 path.slice(home.length + 1),
259 );
260}
261
262function systemPath(path: string): boolean {
263 return path === "/" || SYSTEM_DIRECTORIES.some((directory) => path === directory || path.startsWith(`${directory}/`));
264}
265
266function physicalPath(path: string, links = 0): string {
267 if (links > 40) throw new Error("Too many symbolic links");
268 let current = "/";
269 for (const component of path.split("/")) {
270 if (!component || component === ".") continue;
271 if (component === "..") {
272 current = dirname(current);
273 continue;
274 }
275 current = resolve(current, component);
276 try {
277 if (!lstatSync(current).isSymbolicLink()) continue;
278 const target = readlinkSync(current);
279 current = physicalPath(isAbsolute(target) ? target : `${dirname(current)}/${target}`, links + 1);
280 } catch (error) {
281 if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
282 }
283 }
284 return current;
285}
286
287function pathsFor(value: string, cwd: string): string[] {
288 const path = value.replace(/^@/, "").replace(/^file:\/\//, "");
289 const absolute = resolve(cwd, path);
290 return [path, absolute, physicalPath(isAbsolute(path) ? path : `${cwd}/${path}`)];
291}
292
293function findRoots(args: string[]): string[] {
294 const end = args.findIndex((arg) => arg.startsWith("-") || FIND_OPERATORS.has(arg));
295 return end === -1 ? args : args.slice(0, end);
296}
297
298function safeFind(args: string[]): boolean {
299 let index = findRoots(args).length;
300 while (index < args.length) {
301 const arg = args[index++];
302 if (FIND_OPERATORS.has(arg) || FIND_UNARY.has(arg)) continue;
303 const value = args[index++];
304 if (value === undefined) return false;
305 if (["-name", "-iname", "-path", "-ipath"].includes(arg)) continue;
306 if (arg === "-type" && /^[fdlbcps]$/.test(value)) continue;
307 if (["-maxdepth", "-mindepth"].includes(arg) && /^\d+$/.test(value)) continue;
308 return false;
309 }
310 return true;
311}
312
313type ArgumentRoles = { paths: string[]; searchRoots?: string[]; review?: boolean };
314
315function searchPaths(program: string, args: string[], pipedInput: boolean): ArgumentRoles {
316 const paths: string[] = [];
317 const positional: string[] = [];
318 const expressionOptions = new Set(["-e", "--regexp", "-f", "--file"]);
319 const fileOptions = new Set(["-f", "--file", "--include", "-g", "--glob", "--iglob"]);
320 const globOptions = new Set(["--include", "-g", "--glob", "--iglob"]);
321 const flags = new Set([
322 "--files",
323 "--hidden",
324 "--no-ignore",
325 "--no-ignore-vcs",
326 "--no-heading",
327 "--heading",
328 "--line-number",
329 "--no-line-number",
330 "--ignore-case",
331 "--smart-case",
332 "--case-sensitive",
333 "--fixed-strings",
334 "--extended-regexp",
335 "--perl-regexp",
336 "--pcre2",
337 "--recursive",
338 "--dereference-recursive",
339 "--files-with-matches",
340 "--files-without-match",
341 "--count",
342 "--quiet",
343 "--silent",
344 "--invert-match",
345 "--word-regexp",
346 "--line-regexp",
347 "--only-matching",
348 "--with-filename",
349 "--no-filename",
350 "--null",
351 "--null-data",
352 "--text",
353 "--binary",
354 "--version",
355 "--help",
356 "--stats",
357 "--json",
358 "--multiline",
359 "--multiline-dotall",
360 ]);
361 const values = new Set([
362 ...expressionOptions,
363 "-g",
364 "--glob",
365 "--iglob",
366 "--include",
367 "--exclude",
368 "--exclude-dir",
369 "--exclude-from",
370 "-t",
371 "--type",
372 "-T",
373 "--type-not",
374 "--type-add",
375 "--replace",
376 "--encoding",
377 "--path-separator",
378 "-A",
379 "-B",
380 "-C",
381 "--after-context",
382 "--before-context",
383 "--context",
384 "-m",
385 "--max-count",
386 "--color",
387 "--colors",
388 "--max-depth",
389 "--max-filesize",
390 "--threads",
391 "-j",
392 ]);
393 let expression = false;
394 let recursive = program === "rg";
395 let files = false;
396 let options = true;
397 for (let index = 0; index < args.length; index += 1) {
398 const arg = args[index];
399 if (options && arg === "--") {
400 options = false;
401 continue;
402 }
403 if (!options || !arg.startsWith("-") || arg === "-") {
404 positional.push(arg);
405 continue;
406 }
407 if (arg === "--pre" || arg.startsWith("--pre=")) return { paths, review: true };
408 if (["--recursive", "--dereference-recursive"].includes(arg)) recursive = true;
409 if (arg === "--files") files = true;
410 const equals = arg.indexOf("=");
411 const option = equals < 0 ? arg : arg.slice(0, equals);
412 if (values.has(option)) {
413 const value = equals < 0 ? args[++index] : arg.slice(equals + 1);
414 if (value === undefined) return { paths, review: true };
415 if (expressionOptions.has(option)) expression = true;
416 if (
417 (fileOptions.has(option) || option === "--exclude-from") &&
418 !(globOptions.has(option) && value.startsWith("!"))
419 )
420 paths.push(value);
421 continue;
422 }
423 if (arg.startsWith("--") && !flags.has(arg)) return { paths, review: true };
424 if (!arg.startsWith("--")) {
425 for (let offset = 1; offset < arg.length; offset += 1) {
426 const flag = `-${arg[offset]}`;
427 if (!values.has(flag)) {
428 if (flag === "-r" || flag === "-R") recursive = true;
429 if (!"nrRiIilLhHqsvwxcobazZEFGPUSuN0123456789".includes(arg[offset])) return { paths, review: true };
430 continue;
431 }
432 const value = arg.slice(offset + 1) || args[++index];
433 if (value === undefined) return { paths, review: true };
434 if (expressionOptions.has(flag)) expression = true;
435 if (fileOptions.has(flag) && !(globOptions.has(flag) && value.startsWith("!"))) paths.push(value);
436 break;
437 }
438 }
439 }
440 const roots = expression || (program === "rg" && files) ? positional : positional.slice(1);
441 paths.push(...roots);
442 return {
443 paths,
444 searchRoots: recursive ? (roots.length ? roots : pipedInput && program === "rg" && !files ? [] : ["."]) : undefined,
445 };
446}
447
448function argumentRoles(program: string, args: string[], pipedInput: boolean): ArgumentRoles {
449 if (["echo", "printf", "dirname", "tr"].includes(program)) return { paths: [] };
450 if (program === "grep" || program === "rg") return searchPaths(program, args, pipedInput);
451 if (program === "find") {
452 const roots = findRoots(args);
453 return { paths: roots, searchRoots: roots.length ? roots : ["."] };
454 }
455 if (program === "sed") return { paths: args.slice(2) };
456 if (program === "jq") {
457 const paths: string[] = [];
458 let filter = false;
459 for (let index = 0; index < args.length; index += 1) {
460 const arg = args[index];
461 if (["--arg", "--argjson"].includes(arg)) {
462 index += 2;
463 continue;
464 }
465 if (["--rawfile", "--slurpfile"].includes(arg)) {
466 paths.push(args[index + 2] ?? "");
467 index += 2;
468 continue;
469 }
470 if (arg.startsWith("--from-file=") || /^-f./.test(arg)) {
471 paths.push(arg.startsWith("--") ? arg.slice("--from-file=".length) : arg.slice(2));
472 filter = true;
473 continue;
474 }
475 if (arg === "-f" || arg === "--from-file") {
476 paths.push(args[++index] ?? "");
477 filter = true;
478 continue;
479 }
480 if (arg.startsWith("-")) continue;
481 if (filter) paths.push(arg);
482 filter = true;
483 }
484 return { paths };
485 }
486 const paths: string[] = [];
487 for (let index = 0; index < args.length; index += 1) {
488 const arg = args[index];
489 if (program === "git" && ["-m", "--message"].includes(arg)) {
490 index += 1;
491 continue;
492 }
493 if (program === "git" && (arg.startsWith("--message=") || /^-m./.test(arg))) continue;
494 if (program === "git" && /^-F./.test(arg)) {
495 paths.push(arg.slice(2));
496 continue;
497 }
498 paths.push(...arg.split("="));
499 }
500 return { paths };
501}
502
503function gitOperationIndex(args: string[]): number {
504 let index = 0;
505 while (index < args.length && args[index].startsWith("-")) {
506 index += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"].includes(args[index]) ? 2 : 1;
507 }
508 return index;
509}
510
511function operationIndex(args: string[], valueOptions: ReadonlySet<string>): number {
512 let index = 0;
513 while (index < args.length && args[index].startsWith("-")) {
514 const option = args[index++];
515 if (!option.includes("=") && valueOptions.has(option)) index += 1;
516 }
517 return index;
518}
519
520function dangerousCommand(program: string, args: string[]): Decision | undefined {
521 if (["sudo", "doas", "su"].includes(program)) return ask("Privilege escalation requires confirmation");
522 if (["sh", "bash"].includes(program) && args[0] !== "-n") return ask("Shell execution requires confirmation");
523 if (program === "kubectl") {
524 const index = operationIndex(args, KUBECTL_VALUE_OPTIONS);
525 const operation = args[index];
526 if (KUBECTL_MUTATIONS.has(operation) || KUBECTL_SUBCOMMAND_MUTATIONS.get(operation)?.has(args[index + 1]))
527 return ask("Kubernetes mutation requires confirmation");
528 }
529 if (program === "aws") {
530 const index = operationIndex(args, AWS_VALUE_OPTIONS);
531 const service = args[index];
532 const operation = args[index + 1];
533 if (
534 (service === "configure" && operation === "set") ||
535 AWS_MUTATION_PREFIXES.some((prefix) => operation?.startsWith(prefix))
536 )
537 return ask("AWS mutation requires confirmation");
538 }
539 if (program === "git") {
540 const index = gitOperationIndex(args);
541 const operation = args[index];
542 const options = args.slice(index + 1);
543 if (
544 (operation === "reset" && options.includes("--hard")) ||
545 (operation === "checkout" &&
546 options.some((arg) => ["--", "-f", "--force", "-B", "--ours", "--theirs", "-p", "--patch"].includes(arg))) ||
547 ["clean", "restore"].includes(operation) ||
548 (operation === "config" && options.includes("--system"))
549 )
550 return ask("Destructive or system Git operation requires confirmation");
551 }
552 if (program === "docker") {
553 let start = 0;
554 while (start < args.length && args[start].startsWith("-")) {
555 start += ["--context", "-c", "--host", "-H", "--config", "--log-level", "-l"].includes(args[start]) ? 2 : 1;
556 }
557 const index = args.findIndex((arg, position) => position >= start && ["rm", "down", "prune"].includes(arg));
558 if (
559 index >= 0 &&
560 ((args[start] === "volume" && ["rm", "prune"].includes(args[index])) ||
561 args.some((arg) => arg === "--volumes" || arg.startsWith("--volumes=") || /^-[^-]*v/.test(arg)))
562 )
563 return ask("Docker volume deletion requires confirmation");
564 }
565 return undefined;
566}
567
568function inlineCode(program: string, args: string[]): boolean {
569 if (["python", "python3"].includes(program)) return args.some((arg) => /^-c/.test(arg));
570 if (["ruby", "perl", "bun", "elixir", "scala", "swift", "node"].includes(program))
571 return args.some(
572 (arg) =>
573 /^-e/.test(arg) ||
574 arg === "--eval" ||
575 arg.startsWith("--eval=") ||
576 (program === "node" && (/^-p/.test(arg) || arg.startsWith("--print"))),
577 );
578 if (program === "php") return args.some((arg) => /^-r/.test(arg));
579 return (
580 (program === "mix" && args[0] === "run" && args.some((arg) => /^-e/.test(arg))) ||
581 (program === "deno" && args[0] === "eval")
582 );
583}
584
585function specialAllow(program: string, args: string[]): boolean {
586 if (["sh", "bash"].includes(program)) return args[0] === "-n" && args.slice(1).every((arg) => !/^[+-]/.test(arg));
587 if (program === "command") return args[0] === "-v";
588 if (program === "find") return safeFind(args);
589 if (program === "sed")
590 return (
591 args[0] === "-n" &&
592 (/^\d{1,7}(?:,\d{1,7})?p(?:;\d{1,7}(?:,\d{1,7})?p)*$/.test(args[1] ?? "") ||
593 /^\/(?:[^/\\\r\n]|\\.)*\/,\/(?:[^/\\\r\n]|\\.)*\/p$/.test(args[1] ?? "")) &&
594 args.slice(2).every((arg) => !arg.startsWith("-"))
595 );
596 if (program === "pacman") return ["-Q", "-Ql", "-Qo", "-Si"].includes(args[0]);
597 if (program === "rpm") return args[0] === "-qa";
598 if (program === "dpkg-query") return args[0] === "-W";
599 return program === "systemctl" && args[0] === "--user" && ["is-active", "is-enabled"].includes(args[1]);
600}
601
602export type BashDeterministicResult = {
603 decision?: Decision;
604 reviewCommands?: string[];
605};
606
607function checkCommand(command: BashCommand, requestCwd: string, pipedInput: boolean): BashDeterministicResult {
608 const unknown: BashDeterministicResult = { reviewCommands: [command.source] };
609 const literalWords = command.words.map((word) => word.text);
610 const literalDanger = dangerousCommand(literalWords[0], literalWords.slice(1));
611 if (literalDanger) return { ...unknown, decision: literalDanger };
612 if (
613 command.words[0]?.text === "rm" &&
614 command.words.some(
615 (word) =>
616 word.glob && !word.unresolved && ["/*", `${process.env.HOME}/*`].includes(resolve(requestCwd, word.text)),
617 )
618 )
619 return { ...unknown, decision: ask("Wide file deletion requires confirmation") };
620 const words: string[] = [];
621 let optionLikeGlob = false;
622 for (const word of command.words) {
623 const expanded = resolveBashGlob(word, requestCwd);
624 if (!expanded) return unknown;
625 if (
626 word.glob &&
627 (expanded.length !== 1 || expanded[0] !== word.text) &&
628 expanded.some((value) => value.startsWith("-"))
629 )
630 optionLikeGlob = true;
631 words.push(...expanded);
632 }
633 const [program, ...args] = words;
634 if (!program) return unknown;
635 const dangerous = dangerousCommand(program, args);
636 if (dangerous) return { ...unknown, decision: dangerous };
637 const roles = argumentRoles(program, args, pipedInput);
638 let cwd = requestCwd;
639 if (program === "git") {
640 const operationIndex = gitOperationIndex(args);
641 for (let index = 0; index < operationIndex; index += 1) {
642 const option = args[index];
643 if (option.startsWith("-c") || option.startsWith("--config-env")) return unknown;
644 if (option.startsWith("-C")) {
645 const target = option === "-C" ? (args[++index] ?? "") : option.slice(2);
646 cwd = physicalPath(isAbsolute(target) ? target : `${cwd}/${target}`);
647 } else if (["--git-dir", "--work-tree", "--namespace"].includes(option)) index += 1;
648 }
649 }
650 const checkedPaths = (value: string, directory = cwd) => pathsFor(value, directory);
651 if (program === "git" && args[gitOperationIndex(args)] === "checkout") {
652 const operands = args.slice(gitOperationIndex(args) + 1).filter((arg) => !arg.startsWith("-"));
653 if (
654 operands.some((path) => {
655 try {
656 statSync(resolve(cwd, path));
657 return true;
658 } catch {
659 return false;
660 }
661 })
662 )
663 return { ...unknown, decision: ask("Checking out files requires confirmation") };
664 }
665 if (roles.paths.some((value) => checkedPaths(value).some(secretPath)))
666 return { ...unknown, decision: ask("Command accesses a recognized secret path") };
667 if (roles.searchRoots?.some((path) => checkedPaths(path).some((root) => root === "/" || root === process.env.HOME)))
668 return {
669 ...unknown,
670 decision: ask("Searching the entire root filesystem or home directory requires confirmation"),
671 };
672 if (
673 program === "rm" &&
674 args
675 .filter((arg) => !arg.startsWith("-"))
676 .some((arg) =>
677 checkedPaths(arg).some((path) =>
678 ["/", "/etc", "/usr", "/var", "/home", "/root", process.env.HOME].includes(path),
679 ),
680 )
681 )
682 return { ...unknown, decision: ask("Wide file deletion requires confirmation") };
683 if (MUTATIONS.has(program) && roles.paths.some((value) => checkedPaths(value).some(systemPath)))
684 return { ...unknown, decision: ask("System file changes require confirmation") };
685 for (const redirect of command.redirects) {
686 const operator = redirect.operator.replace(/^\d+/, "");
687 const destination = redirect.destination;
688 if (!destination || destination.unresolved) return unknown;
689 if ([">&", "<&"].includes(operator) && /^(?:\d+|-)$/.test(destination.text) && !destination.glob) continue;
690 if (![">", ">>", ">|", "<", "<>", "&>", "&>>"].includes(operator)) return unknown;
691 const paths = resolveBashGlob(destination, requestCwd);
692 if (!paths || paths.length !== 1) return unknown;
693 const resolved = checkedPaths(paths[0], requestCwd);
694 if (resolved.some(secretPath)) return { ...unknown, decision: ask("Command accesses a recognized secret path") };
695 const writing = operator.includes(">");
696 if (writing && paths[0] !== "/dev/null" && resolved.some(systemPath))
697 return { ...unknown, decision: ask("System file changes require confirmation") };
698 }
699 if (optionLikeGlob || roles.review || inlineCode(program, args)) return unknown;
700 if (
701 command.assignments.some(
702 (word) =>
703 word.unresolved ||
704 !/^(?:(?:GIT_SEQUENCE_EDITOR|GIT_EDITOR|EDITOR)=(?::|true)|CI=(?:true|1)|NO_COLOR=1)$/.test(word.text),
705 )
706 )
707 return unknown;
708 if (!ALLOW_COMMANDS.has(program) && program !== "docker" && !specialAllow(program, args)) return unknown;
709 if (program === "cd") {
710 const target = args[0] === "--" ? args.slice(1) : args;
711 if (target.length > 1 || target[0]?.startsWith("-")) return unknown;
712 }
713 return {
714 decision: {
715 decision: "allow",
716 reason: `Allowed local command: ${program}`,
717 category: "config_allow",
718 },
719 };
720}
721
722function combinedReviewCommands(left: BashDeterministicResult, right: BashDeterministicResult): string[] | undefined {
723 const commands = [...(left.reviewCommands ?? []), ...(right.reviewCommands ?? [])];
724 return commands.length ? commands : undefined;
725}
726
727function combine(left: Decision | undefined, right: Decision | undefined): Decision | undefined {
728 if (left && left.decision !== "allow") return left;
729 if (right && right.decision !== "allow") return right;
730 return left && right ? left : undefined;
731}
732
733function evaluateNode(node: BashNode, cwd: string, pipedInput = false): BashDeterministicResult {
734 if (node.kind === "unsupported") return {};
735 if (node.kind === "command") return checkCommand(node.command, cwd, pipedInput);
736 if (node.kind === "scope") return evaluateNode(node.body, cwd, pipedInput);
737 const left = evaluateNode(node.left, cwd, pipedInput);
738 const right = evaluateNode(node.right, cwd, pipedInput || node.operator === "|");
739 return {
740 decision: combine(left.decision, right.decision),
741 reviewCommands: combinedReviewCommands(left, right),
742 };
743}
744
745export function evaluateParsedBash(parsed: BashParseResult, cwd = process.cwd()): BashDeterministicResult {
746 if (parsed.parserUnavailable)
747 return { decision: { decision: "deny", reason: "Bash parser is unavailable", category: "bash" } };
748 if (!parsed.parsed) return {};
749 if (!parsed.root) return { decision: { decision: "allow", reason: "No operations to evaluate", category: "empty" } };
750 try {
751 return evaluateNode(parsed.root, cwd);
752 } catch {
753 return {};
754 }
755}
756
757export function checkParsedBash(parsed: BashParseResult, cwd = process.cwd()): Decision | undefined {
758 return evaluateParsedBash(parsed, cwd).decision;
759}
760
761export async function checkDeterministic(
762 toolType: string,
763 input: Record<string, unknown>,
764 cwd = process.cwd(),
765): Promise<Decision | undefined> {
766 if (toolType === "bash")
767 return checkParsedBash(await parseBash(typeof input.command === "string" ? input.command : ""), cwd);
768 if (toolType === "webfetch" || toolType === "websearch")
769 return { decision: "allow", reason: `${toolType} is read-only`, category: "web_read" };
770 return undefined;
771}