"""Detect Git operations that can rewrite the checkout backing this process.""" from __future__ import annotations import os import re import shlex import subprocess from dataclasses import dataclass, field from pathlib import Path from typing import Callable from tools.approval import ( _bash_exec_payload, _deobfuscate_shell_word_for_detection, _iter_shell_command_starts, _read_shell_word, ) # bisect is included: it drives repeated checkouts of the running root — the # exact module-version-skew hazard this guard exists for. _WORKTREE_MUTATIONS = frozenset({ "checkout", "switch", "rebase", "merge", "pull", "restore", "clean", "cherry-pick", "revert", "bisect", }) _WORKTREE_TARGET_ACTIONS = frozenset({"move", "remove"}) _STASH_SAFE_ACTIONS = frozenset({"list", "show", "create", "store", "drop", "clear"}) _RESET_WORKTREE_MODES = frozenset({"--hard", "--merge", "--keep"}) # `reset`/`stash`/`clean`/`restore` reach this set only in their SAFE forms # (_mutates_worktree classifies the dangerous forms first in _inspect_git); # listing them only avoids a pointless `git config --get alias.` # subprocess for `stash list`, `reset --soft`, `clean -n`, `restore --staged`. _KNOWN_GIT_BUILTINS = frozenset({ "add", "am", "apply", "blame", "branch", "bundle", "cat-file", "clean", "clone", "commit", "config", "describe", "diff", "fetch", "format-patch", "grep", "help", "init", "log", "ls-files", "ls-remote", "ls-tree", "maintenance", "merge-base", "mv", "notes", "push", "range-diff", "reflog", "remote", "repack", "replace", "reset", "restore", "rev-list", "rev-parse", "rm", "shortlog", "show", "show-ref", "stash", "status", "submodule", "tag", "worktree", }) _SHELL_EXECUTABLES = frozenset({"bash", "dash", "ksh", "sh", "zsh"}) _ASSIGNMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=(.*)", re.DOTALL) _RESET_HARD_RE = re.compile(r"--h(?:a(?:r(?:d)?)?)?\Z") _NO_OPTIONS: frozenset[str] = frozenset() # Wrapper executables that are skipped to reach the real command, mapped to # the options that consume a following argument. _WRAPPER_OPTIONS_WITH_ARG: dict[str, frozenset[str]] = { "sudo": frozenset({ "-C", "--chdir", "-c", "--close-from", "-g", "--group", "-h", "--host", "-p", "--prompt", "-R", "--chroot", "-T", "--command-timeout", "-u", "--user", }), "env": frozenset({ "-a", "--argv0", "-C", "--chdir", "-S", "--split-string", "-u", "--unset", }), "command": _NO_OPTIONS, "builtin": _NO_OPTIONS, "exec": frozenset({"-a"}), "nohup": _NO_OPTIONS, "setsid": _NO_OPTIONS, "time": frozenset({"-f", "--format", "-o", "--output"}), } _MAX_RECURSION = 4 # git global options that consume the next argument (-C/--work-tree/-c are acted on). _GIT_GLOBAL_OPTIONS_WITH_ARG = frozenset({ "-C", "-c", "--work-tree", "--git-dir", "--namespace", "--exec-path", }) @dataclass class _Heredoc: delimiter: str strip_tabs: bool execute_as_shell: bool body: list[str] = field(default_factory=list) @dataclass class _ShellContext: kind: str opener: int quote: str | None = None def get_running_source_root() -> Path | None: """Return the source checkout backing this process, if there is one.""" try: root = Path(__file__).resolve().parent.parent except (OSError, RuntimeError): return None return root if (root / ".git").exists() else None def _resolve(path_str: str, base: Path) -> Path: path = Path(os.path.expanduser(path_str)) if not path.is_absolute(): path = base / path try: return path.resolve() except (OSError, RuntimeError, ValueError): return path def _is_within(path: Path, root: Path) -> bool: try: return path == root or path.is_relative_to(root) except (OSError, RuntimeError, ValueError): return False def _executable_name(value: str) -> str: return Path(value.replace("\\", "/")).name.removesuffix(".exe").lower() def _shell_words_at(command: str, start: int) -> list[str]: words: list[str] = [] cursor = start for _ in range(64): word_start, word_end, raw_word = _read_shell_word(command, cursor) if word_start == word_end: break if words and "\n" in command[cursor:word_start]: break words.append(_deobfuscate_shell_word_for_detection(raw_word)) cursor = word_end return words def _consume_options( words: list[str], start: int, options_with_arg: frozenset[str] = _NO_OPTIONS, ) -> int: """Index of the first positional at/after ``start`` (``--`` ends options).""" index = start while index < len(words): option = words[index] if option == "--": return index + 1 if not option.startswith("-") or option == "-": break option_name = option.split("=", 1)[0] if "=" not in option and option_name in options_with_arg: index += 2 else: index += 1 return index def _command_parts(words: list[str]) -> tuple[dict[str, str], str | None, list[str]]: """Split leading VAR=value assignments and wrappers off -> (env, executable, args).""" env: dict[str, str] = {} index = 0 while index < len(words): if _ASSIGNMENT_RE.fullmatch(words[index]): name, value = words[index].split("=", 1) env[name] = value index += 1 continue executable = _executable_name(words[index]) wrapper_options = _WRAPPER_OPTIONS_WITH_ARG.get(executable) if wrapper_options is None: return env, words[index], words[index + 1 :] # `command -v/-V` only reports; nothing runs. if executable == "command" and words[index + 1 : index + 2] in (["-v"], ["-V"]): return env, None, [] index = _consume_options(words, index + 1, wrapper_options) return env, None, [] def _scope_keys(command: str, starts: list[int]) -> dict[int, tuple[int, ...]]: """Map each command start to the tuple of enclosing ``(``/``$(``/backtick openers.""" contexts = [_ShellContext("root", -1)] scopes: dict[int, tuple[int, ...]] = {} cursor = 0 for start in sorted(set(starts)): while cursor < start: context = contexts[-1] quote = context.quote char = command[cursor] if quote == "'": if char == "'": context.quote = None cursor += 1 continue # Unquoted or inside double quotes: substitutions still open scopes. if char == "\\" and cursor + 1 < start: cursor += 2 continue if quote == '"': if char == '"': context.quote = None cursor += 1 continue elif char in {"'", '"'}: context.quote = char cursor += 1 continue if command.startswith("$(", cursor): contexts.append(_ShellContext("$(", cursor)) cursor += 2 continue if quote is None: if char == "(": contexts.append(_ShellContext("(", cursor)) cursor += 1 continue if char == ")" and len(contexts) > 1 and contexts[-1].kind in {"(", "$("}: contexts.pop() cursor += 1 continue if char == "`": if quote is None and len(contexts) > 1 and contexts[-1].kind == "`": contexts.pop() else: contexts.append(_ShellContext("`", cursor)) cursor += 1 scopes[start] = tuple(item.opener for item in contexts[1:]) return scopes def _operator_before(command: str, start: int) -> str | None: index = start - 1 saw_newline = False while index >= 0 and command[index].isspace(): saw_newline = saw_newline or command[index] == "\n" index -= 1 if index < 0: return "\n" if saw_newline else None if index > 0 and command[index - 1 : index + 1] in {"&&", "||"}: return command[index - 1 : index + 1] if command[index] in {";", "|", "&", "(", "{"}: return command[index] return "\n" if saw_newline else None def _cd_target(executable: str, args: list[str], cwd: Path) -> Path | None: if _executable_name(executable) not in {"cd", "pushd"}: return None index = _consume_options(args, 0) if index >= len(args) or args[index] == "-": return None target = _resolve(args[index], cwd) return target if target.is_dir() else None def _shell_script_arg(args: list[str]) -> str | None: """Return the script string owned by a shell's ``-c``, if present. approval.py's ``_bash_exec_payload`` parses bash's real option grammar (``-o pipefail -c '