Parent directory

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}