""" What a command runs underneath, read from the files on this machine before it runs, so the gateway can check those commands too: `make clean` is only as safe as the `clean` target's recipe. make [TARGET...] the recipe lines of each target and the targets it depends on (Makefile, -f, -C) npm run X / npm test / ... the package.json script, with its pre/post scripts (npm, pnpm, yarn, bun) a script, however it's run its lines: bash x.sh, ./x.sh, /abs/x.sh, source x.sh, . x.sh, exec ./x.sh, sudo bash x.sh, pwsh -File x.ps1 (shell and PowerShell scripts; any name or extension) Used by the hooks (claude_hook.py, agent_hook.py), which send the result as metadata["runs"]: [{"via": "Makefile target `clean`", "lines": ["rm -rf build/ ~/"]}] The gateway reads each line like the command itself (server.py command_signals). It can only make a decision stricter: what's found here is checked in addition to the command, never instead of it. Only reads files, never runs anything. Bounded: MAX_LINES lines and MAX_FILE_BYTES per file; anything it can't read is left out. Scripts in other languages (python x.py, node x.js, ruby x.rb, ...): the commands they hand to a shell, when written out in the file: os.system("..."), subprocess.run("..." or [...]), child_process.exec/execSync("..."), system("...") and `...` in Ruby, Perl and PHP. A command built at run time can't be read here; if the agent wrote or changed the file in this conversation, running it waits for a person (server.written_then_run). Also not read: recipes built at run time ($(shell ...), eval), make variables beyond simple `NAME = value` definitions. `include` files are followed (a few levels deep, cycles skipped) and conditionals are read on BOTH sides, so a recipe hidden in an included file or in either branch of an `ifeq` is still checked. """ from __future__ import annotations import json import os import re from pathlib import Path import commands MAX_LINES = 60 MAX_FILE_BYTES = 256 * 1024 MAKE_PROGRAMS = {"make", "gmake", "mingw32-make"} NODE_PM = {"npm", "pnpm", "yarn", "bun"} NPM_SHORTCUTS = {"test", "start", "stop", "restart"} # `npm test` runs scripts.test SHELLS = {"bash", "sh", "zsh", "dash"} _VAR_DEF = re.compile(r"^([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:\?|:|::)?=\s*(.*)$") _RULE = re.compile(r"^([^:=#\t][^:=#]*?)\s*::?(?!=)\s*(.*)$") _VAR_USE = re.compile(r"\$\(([A-Za-z_][A-Za-z0-9_.-]*)\)|\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}") _COND = re.compile(r"^(ifeq|ifneq|ifdef|ifndef)\b") # both branches are read, see _parse _COND_ELSE = re.compile(r"^else\b") _COND_END = re.compile(r"^endif\b") _INCLUDE = re.compile(r"^(-?)include\s+(.*)$") MAX_INCLUDE_DEPTH = 3 # "at most a few levels deep" def _read(path: Path) -> str | None: try: if path.is_file() and path.stat().st_size <= MAX_FILE_BYTES: return path.read_text(encoding="utf-8", errors="replace") except OSError: pass return None # --------------------------------------------------------------------------- make def _parse(text: str, folder: Path, depth: int, seen: set[Path]) -> tuple[dict[str, tuple[list[str], list[str]]], dict[str, str], str | None]: """-> (rules, simple variables, the default target). `include` files are followed and conditionals are read on BOTH sides: `ifeq ... else ... endif` keeps the recipe lines of either branch rather than working out which one make would pick. That can only make a gateway decision stricter — it holds what any branch would run — never looser. """ text = re.sub(r"[ \t]*\\\r?\n[ \t]*", " ", text) # joined continuation lines variables: dict[str, str] = {} rules: dict[str, tuple[list[str], list[str]]] = {} includes: list[str] = [] default, current = None, [] cond_depth = 0 for raw in text.splitlines(): if raw.startswith("\t"): line = raw[1:].strip() if current and line and not line.startswith("#"): for t in current: rules[t][1].append(line) # either branch is kept, see _parse continue line = raw.split("#", 1)[0].rstrip() stripped = line.strip() if not stripped: continue if _COND.match(stripped): cond_depth += 1 continue if _COND_END.match(stripped): cond_depth = max(0, cond_depth - 1) continue if _COND_ELSE.match(stripped): continue # the other branch keeps the same target if cond_depth == 0 and (m := _INCLUDE.match(stripped)): includes += m.group(2).split() current = [] continue if m := _VAR_DEF.match(stripped): variables[m.group(1)] = m.group(2).strip() current = [] continue if m := _RULE.match(line): targets = [t for t in m.group(1).split() if t] deps, _, inline = m.group(2).partition(";") current = [t for t in targets if not t.startswith(".")] for t in current: rules.setdefault(t, ([], [])) rules[t][0].extend(deps.split()) if inline.strip(): rules[t][1].append(inline.strip()) if default is None and current and "%" not in current[0]: default = current[0] continue current = [] for name in includes: if depth >= MAX_INCLUDE_DEPTH: # "at most a few levels deep" break path = folder / name if not path.is_file(): # a missing -include is ignored by make too continue try: resolved = path.resolve() except OSError: continue if resolved in seen: # include cycle: stop, don't hang the hook continue seen.add(resolved) included = _read(path) if included is None: continue inc_rules, inc_vars, inc_default = _parse(included, path.parent, depth + 1, seen) for t, (deps, recipe) in inc_rules.items(): # the includer's own rules win, both are kept if t not in rules: rules[t] = (deps, recipe) else: rules[t][0].extend(d for d in deps if d not in rules[t][0]) rules[t][1].extend(recipe) variables.update(inc_vars) if default is None and inc_default: default = inc_default return rules, variables, default def parse_makefile(text: str, folder: Path) -> tuple[dict[str, tuple[list[str], list[str]]], str | None]: """-> ({target: (prerequisites, recipe lines)}, the default target). Simple variables are expanded. `folder` is the Makefile's own directory: `include` paths are resolved against it. """ rules, variables, default = _parse(text, folder, 0, set()) def expand(s: str, depth: int = 0) -> str: if depth > 5: return s out = _VAR_USE.sub(lambda m: expand(variables.get(m.group(1) or m.group(2), m.group(0)), depth + 1), s) return out.replace("$$", "$") return ({t: ([expand(d) for d in deps], [expand(r) for r in recipe]) for t, (deps, recipe) in rules.items()}, default) def _recipe_line(line: str) -> str: return line.lstrip("@-+ \t") # @ silent, - ignore errors, + always run def _make(words: list[str], cwd: Path) -> list[dict]: directory, makefile, targets, args = cwd, None, [], iter(words[1:]) for w in args: if w in ("-C", "--directory"): directory = directory / next(args, ".") elif w.startswith("--directory="): directory = directory / w.split("=", 1)[1] elif w in ("-f", "--file", "--makefile"): makefile = next(args, None) elif w.startswith(("--file=", "--makefile=")): makefile = w.split("=", 1)[1] elif w.startswith("-C") and len(w) > 2: directory = directory / w[2:] elif w.startswith("-f") and len(w) > 2: makefile = w[2:] elif not w.startswith("-") and "=" not in w: targets.append(w) if makefile: path = directory / makefile else: # make's own order, by the names really on disk (Windows would match "makefile" to "Makefile") try: names = set(os.listdir(directory)) except OSError: return [] path = next((directory / n for n in ("GNUmakefile", "makefile", "Makefile") if n in names), None) if path is None: return [] text = _read(path) if text is None: return [] rules, default = parse_makefile(text, path.parent) found: list[dict] = [] seen: set[str] = set() def visit(t: str, depth: int) -> None: if t in seen or depth > 8 or t not in rules: return seen.add(t) deps, recipe = rules[t] for d in deps: visit(d, depth + 1) if recipe: found.append({"via": f"{path.name} target `{t}`", "lines": [_recipe_line(r) for r in recipe]}) for t in targets or ([default] if default else []): visit(t, 0) return found # --------------------------------------------------------------------------- package.json scripts def _node(words: list[str], cwd: Path) -> list[dict]: prog, rest = words[0], [w for w in words[1:] if not w.startswith("-")] if (not rest and prog == "yarn") or rest[:1] in (["install"], ["i"], ["ci"]): if "--ignore-scripts" in words: return [] # installing runs the project's own lifecycle scripts (a dependency's can't be read before it's downloaded) text = _read(cwd / "package.json") try: scripts = json.loads(text).get("scripts") if text else None except (ValueError, AttributeError): return [] return [{"via": f"package.json script `{k}` (runs on install)", "lines": [scripts[k]]} for k in ("preinstall", "install", "postinstall", "prepare") if isinstance(scripts, dict) and isinstance(scripts.get(k), str)] if not rest: return [] if rest[0] in ("run", "run-script"): name = rest[1] if len(rest) > 1 else None elif rest[0] in NPM_SHORTCUTS or prog in ("yarn", "pnpm", "bun"): name = rest[0] # yarn build, pnpm lint, bun dev else: return [] text = _read(cwd / "package.json") try: scripts = json.loads(text).get("scripts") if text else None except (ValueError, AttributeError): return [] if not isinstance(scripts, dict) or not isinstance(scripts.get(name), str): return [] found = [] for key in (f"pre{name}", name, f"post{name}"): if isinstance(scripts.get(key), str): found.append({"via": f"package.json script `{key}`", "lines": [scripts[key]]}) return found # --------------------------------------------------------------------------- shell scripts OTHER_LANGUAGES = re.compile(r"(?i)\.(py|pyw|js|mjs|cjs|ts|tsx|jsx|rb|pl|php|lua|r)$") # not read as shell lines # A command handed to a shell, written out: os.system("curl URL | sh"), subprocess.run(["sh", "-c", "..."]), execSync(`...`) _SHELL_CALL = re.compile(r"\b(?:os\.(?:system|popen)|subprocess\.(?:run|call|check_call|check_output|Popen|getoutput|" r"getstatusoutput)|(?:child_process\.)?(?:execSync|exec|spawnSync|spawn|execFileSync|execFile)|" r"system|shell_exec|passthru|popen|proc_open|Open3\.\w+|IO\.popen|Kernel\.system)\s*\(\s*" r"(?:[frbu]{0,2}(?P[\"'`])(?P(?:\\.|(?!(?P=q)).)*)(?P=q)|\[(?P[^\]]*)\])", re.S | re.I) _STRING = re.compile(r"(?P[\"'])(?P(?:\\.|(?!(?P=q)).)*)(?P=q)") _BACKTICKS = re.compile(r"`([^`\n]+)`|%x\{([^}\n]+)\}") # Ruby, Perl, PHP def _shell_outs(path_word: str, cwd: Path) -> list[dict]: """The commands a Python, Node, Ruby, Perl or PHP file hands to a shell, when they're written out in it.""" p = Path(os.path.expanduser(path_word.strip("'\""))) p = p if p.is_absolute() else cwd / p text = _read(p) if text is None: return [] lines = [] for m in _SHELL_CALL.finditer(text): if m.group("s") is not None: lines.append(m.group("s")) else: # ["sh", "-c", "curl URL | sh"] or ["curl", url]: the words as a line words = [w.group("s") for w in _STRING.finditer(m.group("l"))] lines.append(words[-1] if len(words) >= 3 and words[1] in ("-c", "/c") else " ".join(words)) if p.suffix.lower() in (".rb", ".pl", ".php"): lines += [a or b for a, b in _BACKTICKS.findall(text)] lines = [l.strip() for l in lines if l.strip()] return ([{"via": f"commands {p.name} hands to a shell", "lines": lines}] if lines else []) + _api_delete(text, p.name) def _api_delete(text: str, name: str) -> list[dict]: """A GraphQL delete in a file (requests.post / fetch with mutation { volumeDelete(...) }, a curl -d @body.json), as a curl line the gateway holds. Only the matched words leave this machine, never the file (it may hold keys).""" m = commands.API_DELETE.search(text) if not m: return [] # only the operation's name: the match can span whatever sits between `mutation` and `{` (variables, defaults) op = re.search(r"\{\s*([$\w{}]+)\s*\($", m.group(0)) sent = f"mutation {{ {op.group(1)}(" if op else m.group(0)[:80].replace("'", "") return [{"via": f"the API request in {name}", "lines": [f"curl -d '{sent}'"]}] def _request_bodies(line: str, cwd: Path) -> list[dict]: """Files an HTTP call sends: curl -d @q.json, --data-binary @q.json, cat q.json | curl -d @-, curl -T q.json.""" try: reading = commands.read(line) except Exception: return [] if not any(c.program in commands.HTTP_CLIS for c in reading.commands): return [] out = [] for word in dict.fromkeys(w.lstrip("@") for c in reading.commands for w in c.words[1:]): if not word or word.startswith("-") or "://" in word: continue p = Path(os.path.expanduser(word.strip("'\""))) text = _read(p if p.is_absolute() else cwd / p) out += _api_delete(text, p.name) if text else [] return out def _script(path_word: str, cwd: Path) -> list[dict]: p = Path(os.path.expanduser(path_word.strip("'\""))) p = p if p.is_absolute() else cwd / p text = _read(p) if text is None: return [] lines = [l.strip() for l in text.splitlines() if l.strip() and not l.strip().startswith("#")] return [{"via": f"script {p.name}", "lines": lines}] if lines else [] # --------------------------------------------------------------------------- entry point def expand(line: str, cwd: str | None = None) -> list[dict]: """What the command line runs underneath, as [{"via": ..., "lines": [...]}]. Empty when there's nothing to read.""" base = Path(cwd or os.getcwd()) out: list[dict] = [] try: reading = commands.read(line) except Exception: return out for cmd in reading.commands: words = cmd.words prog = cmd.program try: if prog in MAKE_PROGRAMS: out += _make(words, base) elif prog in NODE_PM: out += _node(words, base) except Exception: continue sourced = [c.words[1] for c in reading.commands if c.program in ("source", ".") and len(c.words) > 1] for path_word in dict.fromkeys(reading.scripts + sourced): out += _shell_outs(path_word, base) if OTHER_LANGUAGES.search(path_word) else _script(path_word, base) for inner in [line] + [l for item in out if item["via"].startswith("script ") for l in item["lines"]]: out += _request_bodies(inner, base) total, kept = 0, [] for item in out: # bounded, in order lines = [l[:1000] for l in item["lines"] if l][: MAX_LINES - total] if lines: kept.append({"via": item["via"][:200], "lines": lines}) total += len(lines) if total >= MAX_LINES: break return kept