deterministic.ts
23730 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[]): 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 { paths, searchRoots: recursive ? (roots.length ? roots : ["."]) : undefined };
443}
444
445function argumentRoles(program: string, args: string[]): ArgumentRoles {
446 if (["echo", "printf", "dirname", "tr"].includes(program)) return { paths: [] };
447 if (program === "grep" || program === "rg") return searchPaths(program, args);
448 if (program === "find") {
449 const roots = findRoots(args);
450 return { paths: roots, searchRoots: roots.length ? roots : ["."] };
451 }
452 if (program === "sed") return { paths: args.slice(2) };
453 if (program === "jq") {
454 const paths: string[] = [];
455 let filter = false;
456 for (let index = 0; index < args.length; index += 1) {
457 const arg = args[index];
458 if (["--arg", "--argjson"].includes(arg)) {
459 index += 2;
460 continue;
461 }
462 if (["--rawfile", "--slurpfile"].includes(arg)) {
463 paths.push(args[index + 2] ?? "");
464 index += 2;
465 continue;
466 }
467 if (arg.startsWith("--from-file=") || /^-f./.test(arg)) {
468 paths.push(arg.startsWith("--") ? arg.slice("--from-file=".length) : arg.slice(2));
469 filter = true;
470 continue;
471 }
472 if (arg === "-f" || arg === "--from-file") {
473 paths.push(args[++index] ?? "");
474 filter = true;
475 continue;
476 }
477 if (arg.startsWith("-")) continue;
478 if (filter) paths.push(arg);
479 filter = true;
480 }
481 return { paths };
482 }
483 const paths: string[] = [];
484 for (let index = 0; index < args.length; index += 1) {
485 const arg = args[index];
486 if (program === "git" && ["-m", "--message"].includes(arg)) {
487 index += 1;
488 continue;
489 }
490 if (program === "git" && (arg.startsWith("--message=") || /^-m./.test(arg))) continue;
491 if (program === "git" && /^-F./.test(arg)) {
492 paths.push(arg.slice(2));
493 continue;
494 }
495 paths.push(...arg.split("="));
496 }
497 return { paths };
498}
499
500function gitOperationIndex(args: string[]): number {
501 let index = 0;
502 while (index < args.length && args[index].startsWith("-")) {
503 index += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"].includes(args[index]) ? 2 : 1;
504 }
505 return index;
506}
507
508function operationIndex(args: string[], valueOptions: ReadonlySet<string>): number {
509 let index = 0;
510 while (index < args.length && args[index].startsWith("-")) {
511 const option = args[index++];
512 if (!option.includes("=") && valueOptions.has(option)) index += 1;
513 }
514 return index;
515}
516
517function dangerousCommand(program: string, args: string[]): Decision | undefined {
518 if (["sudo", "doas", "su"].includes(program)) return ask("Privilege escalation requires confirmation");
519 if (["sh", "bash"].includes(program) && args[0] !== "-n") return ask("Shell execution requires confirmation");
520 if (program === "herdr" && args[0] === "server" && args[1] === "stop")
521 return ask("Stopping the Herdr server requires confirmation");
522 if (program === "kubectl") {
523 const index = operationIndex(args, KUBECTL_VALUE_OPTIONS);
524 const operation = args[index];
525 if (KUBECTL_MUTATIONS.has(operation) || KUBECTL_SUBCOMMAND_MUTATIONS.get(operation)?.has(args[index + 1]))
526 return ask("Kubernetes mutation requires confirmation");
527 }
528 if (program === "aws") {
529 const index = operationIndex(args, AWS_VALUE_OPTIONS);
530 const service = args[index];
531 const operation = args[index + 1];
532 if (
533 (service === "configure" && operation === "set") ||
534 AWS_MUTATION_PREFIXES.some((prefix) => operation?.startsWith(prefix))
535 )
536 return ask("AWS mutation requires confirmation");
537 }
538 if (program === "git") {
539 const index = gitOperationIndex(args);
540 const operation = args[index];
541 const options = args.slice(index + 1);
542 if (
543 (operation === "reset" && options.includes("--hard")) ||
544 (operation === "checkout" &&
545 options.some((arg) => ["--", "-f", "--force", "-B", "--ours", "--theirs", "-p", "--patch"].includes(arg))) ||
546 ["clean", "restore"].includes(operation) ||
547 (operation === "config" && options.includes("--system"))
548 )
549 return ask("Destructive or system Git operation requires confirmation");
550 }
551 if (program === "docker") {
552 let start = 0;
553 while (start < args.length && args[start].startsWith("-")) {
554 start += ["--context", "-c", "--host", "-H", "--config", "--log-level", "-l"].includes(args[start]) ? 2 : 1;
555 }
556 const index = args.findIndex((arg, position) => position >= start && ["rm", "down", "prune"].includes(arg));
557 if (
558 index >= 0 &&
559 ((args[start] === "volume" && ["rm", "prune"].includes(args[index])) ||
560 args.some((arg) => arg === "--volumes" || arg.startsWith("--volumes=") || /^-[^-]*v/.test(arg)))
561 )
562 return ask("Docker volume deletion requires confirmation");
563 }
564 return undefined;
565}
566
567function inlineCode(program: string, args: string[]): boolean {
568 if (["python", "python3"].includes(program)) return args.some((arg) => /^-c/.test(arg));
569 if (["ruby", "perl", "bun", "elixir", "scala", "swift", "node"].includes(program))
570 return args.some(
571 (arg) =>
572 /^-e/.test(arg) ||
573 arg === "--eval" ||
574 arg.startsWith("--eval=") ||
575 (program === "node" && (/^-p/.test(arg) || arg.startsWith("--print"))),
576 );
577 if (program === "php") return args.some((arg) => /^-r/.test(arg));
578 return (
579 (program === "mix" && args[0] === "run" && args.some((arg) => /^-e/.test(arg))) ||
580 (program === "deno" && args[0] === "eval")
581 );
582}
583
584function specialAllow(program: string, args: string[]): boolean {
585 if (["sh", "bash"].includes(program)) return args[0] === "-n" && args.slice(1).every((arg) => !/^[+-]/.test(arg));
586 if (program === "command") return args[0] === "-v";
587 if (program === "find") return safeFind(args);
588 if (program === "sed")
589 return (
590 args[0] === "-n" &&
591 (/^\d{1,7}(?:,\d{1,7})?p(?:;\d{1,7}(?:,\d{1,7})?p)*$/.test(args[1] ?? "") ||
592 /^\/(?:[^/\\\r\n]|\\.)*\/,\/(?:[^/\\\r\n]|\\.)*\/p$/.test(args[1] ?? "")) &&
593 args.slice(2).every((arg) => !arg.startsWith("-"))
594 );
595 if (program === "pacman") return ["-Q", "-Ql", "-Qo", "-Si"].includes(args[0]);
596 if (program === "rpm") return args[0] === "-qa";
597 if (program === "dpkg-query") return args[0] === "-W";
598 return program === "systemctl" && args[0] === "--user" && ["is-active", "is-enabled"].includes(args[1]);
599}
600
601export type BashDeterministicResult = {
602 decision?: Decision;
603 reviewCommands?: string[];
604};
605
606function checkCommand(command: BashCommand, requestCwd: string): BashDeterministicResult {
607 const unknown: BashDeterministicResult = { reviewCommands: [command.source] };
608 const literalWords = command.words.map((word) => word.text);
609 const literalDanger = dangerousCommand(literalWords[0], literalWords.slice(1));
610 if (literalDanger) return { ...unknown, decision: literalDanger };
611 if (
612 command.words[0]?.text === "rm" &&
613 command.words.some(
614 (word) =>
615 word.glob && !word.unresolved && ["/*", `${process.env.HOME}/*`].includes(resolve(requestCwd, word.text)),
616 )
617 )
618 return { ...unknown, decision: ask("Wide file deletion requires confirmation") };
619 const words: string[] = [];
620 let optionLikeGlob = false;
621 for (const word of command.words) {
622 const expanded = resolveBashGlob(word, requestCwd);
623 if (!expanded) return unknown;
624 if (
625 word.glob &&
626 (expanded.length !== 1 || expanded[0] !== word.text) &&
627 expanded.some((value) => value.startsWith("-"))
628 )
629 optionLikeGlob = true;
630 words.push(...expanded);
631 }
632 const [program, ...args] = words;
633 if (!program) return unknown;
634 const dangerous = dangerousCommand(program, args);
635 if (dangerous) return { ...unknown, decision: dangerous };
636 const roles = argumentRoles(program, args);
637 let cwd = requestCwd;
638 if (program === "git") {
639 const operationIndex = gitOperationIndex(args);
640 for (let index = 0; index < operationIndex; index += 1) {
641 const option = args[index];
642 if (option.startsWith("-c") || option.startsWith("--config-env")) return unknown;
643 if (option.startsWith("-C")) {
644 const target = option === "-C" ? (args[++index] ?? "") : option.slice(2);
645 cwd = physicalPath(isAbsolute(target) ? target : `${cwd}/${target}`);
646 } else if (["--git-dir", "--work-tree", "--namespace"].includes(option)) index += 1;
647 }
648 }
649 const checkedPaths = (value: string, directory = cwd) => pathsFor(value, directory);
650 if (program === "git" && args[gitOperationIndex(args)] === "checkout") {
651 const operands = args.slice(gitOperationIndex(args) + 1).filter((arg) => !arg.startsWith("-"));
652 if (
653 operands.some((path) => {
654 try {
655 statSync(resolve(cwd, path));
656 return true;
657 } catch {
658 return false;
659 }
660 })
661 )
662 return { ...unknown, decision: ask("Checking out files requires confirmation") };
663 }
664 if (roles.paths.some((value) => checkedPaths(value).some(secretPath)))
665 return { ...unknown, decision: ask("Command accesses a recognized secret path") };
666 if (roles.searchRoots?.some((path) => checkedPaths(path).some((root) => root === "/" || root === process.env.HOME)))
667 return {
668 ...unknown,
669 decision: ask("Searching the entire root filesystem or home directory requires confirmation"),
670 };
671 if (
672 program === "rm" &&
673 args
674 .filter((arg) => !arg.startsWith("-"))
675 .some((arg) =>
676 checkedPaths(arg).some((path) =>
677 ["/", "/etc", "/usr", "/var", "/home", "/root", process.env.HOME].includes(path),
678 ),
679 )
680 )
681 return { ...unknown, decision: ask("Wide file deletion requires confirmation") };
682 if (MUTATIONS.has(program) && roles.paths.some((value) => checkedPaths(value).some(systemPath)))
683 return { ...unknown, decision: ask("System file changes require confirmation") };
684 for (const redirect of command.redirects) {
685 const operator = redirect.operator.replace(/^\d+/, "");
686 const destination = redirect.destination;
687 if (!destination || destination.unresolved) return unknown;
688 if ([">&", "<&"].includes(operator) && /^(?:\d+|-)$/.test(destination.text) && !destination.glob) continue;
689 if (![">", ">>", ">|", "<", "<>", "&>", "&>>"].includes(operator)) return unknown;
690 const paths = resolveBashGlob(destination, requestCwd);
691 if (!paths || paths.length !== 1) return unknown;
692 const resolved = checkedPaths(paths[0], requestCwd);
693 if (resolved.some(secretPath)) return { ...unknown, decision: ask("Command accesses a recognized secret path") };
694 const writing = operator.includes(">");
695 if (writing && paths[0] !== "/dev/null" && resolved.some(systemPath))
696 return { ...unknown, decision: ask("System file changes require confirmation") };
697 }
698 if (optionLikeGlob || roles.review || inlineCode(program, args)) return unknown;
699 if (
700 command.assignments.some(
701 (word) =>
702 word.unresolved ||
703 !/^(?:(?:GIT_SEQUENCE_EDITOR|GIT_EDITOR|EDITOR)=(?::|true)|CI=(?:true|1)|NO_COLOR=1)$/.test(word.text),
704 )
705 )
706 return unknown;
707 if (!ALLOW_COMMANDS.has(program) && program !== "docker" && !specialAllow(program, args)) return unknown;
708 if (program === "cd") {
709 const target = args[0] === "--" ? args.slice(1) : args;
710 if (target.length > 1 || target[0]?.startsWith("-")) return unknown;
711 }
712 return {
713 decision: {
714 decision: "allow",
715 reason: `Allowed local command: ${program}`,
716 category: "config_allow",
717 },
718 };
719}
720
721function combinedReviewCommands(left: BashDeterministicResult, right: BashDeterministicResult): string[] | undefined {
722 const commands = [...(left.reviewCommands ?? []), ...(right.reviewCommands ?? [])];
723 return commands.length ? commands : undefined;
724}
725
726function combine(left: Decision | undefined, right: Decision | undefined): Decision | undefined {
727 if (left && left.decision !== "allow") return left;
728 if (right && right.decision !== "allow") return right;
729 return left && right ? left : undefined;
730}
731
732function evaluateNode(node: BashNode, cwd: string): BashDeterministicResult {
733 if (node.kind === "unsupported") return {};
734 if (node.kind === "command") return checkCommand(node.command, cwd);
735 if (node.kind === "scope") return evaluateNode(node.body, cwd);
736 const left = evaluateNode(node.left, cwd);
737 const right = evaluateNode(node.right, cwd);
738 return {
739 decision: combine(left.decision, right.decision),
740 reviewCommands: combinedReviewCommands(left, right),
741 };
742}
743
744export function evaluateParsedBash(parsed: BashParseResult, cwd = process.cwd()): BashDeterministicResult {
745 if (parsed.parserUnavailable)
746 return { decision: { decision: "deny", reason: "Bash parser is unavailable", category: "bash" } };
747 if (!parsed.parsed) return {};
748 if (!parsed.root) return { decision: { decision: "allow", reason: "No operations to evaluate", category: "empty" } };
749 try {
750 return evaluateNode(parsed.root, cwd);
751 } catch {
752 return {};
753 }
754}
755
756export function checkParsedBash(parsed: BashParseResult, cwd = process.cwd()): Decision | undefined {
757 return evaluateParsedBash(parsed, cwd).decision;
758}
759
760export async function checkDeterministic(
761 toolType: string,
762 input: Record<string, unknown>,
763 cwd = process.cwd(),
764): Promise<Decision | undefined> {
765 if (toolType === "bash")
766 return checkParsedBash(await parseBash(typeof input.command === "string" ? input.command : ""), cwd);
767 if (toolType === "webfetch" || toolType === "websearch")
768 return { decision: "allow", reason: `${toolType} is read-only`, category: "web_read" };
769 return undefined;
770}