Parent directory

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}