"""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 tools.approval import ( _bash_exec_payload, _deobfuscate_shell_word_for_detection, _iter_shell_command_starts, _read_shell_word, ) _WORKTREE_MUTATIONS = frozenset({ "checkout", "switch", "rebase", "merge", "pull", "restore", "clean", "cherry-pick", "revert", # bisect drives repeated checkouts of the running root — the exact # module-version-skew hazard this guard exists for. "bisect", }) _WORKTREE_TARGET_ACTIONS = frozenset({"move", "remove"}) _STASH_SAFE_ACTIONS = frozenset({"list", "show", "create", "store", "drop", "clear"}) _RESET_WORKTREE_MODES = frozenset({"--hard", "--merge", "--keep"}) _KNOWN_GIT_BUILTINS = frozenset({ "add", "am", "apply", "blame", "branch", "bundle", "cat-file", # `reset`/`stash`/`clean`/`restore` reach this set only in their SAFE # forms — _mutates_worktree classifies the dangerous forms first (see # _inspect_git) — so listing them here only prevents a pointless # `git config --get alias.` subprocess for `stash list`, # `reset --soft`, `clean -n`, `restore --staged`, which agent dev # sessions run constantly inside the source repo. "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) _SUDO_OPTIONS_WITH_ARG = frozenset({ "-C", "--chdir", "-c", "--close-from", "-g", "--group", "-h", "--host", "-p", "--prompt", "-R", "--chroot", "-T", "--command-timeout", "-u", "--user", }) _ENV_OPTIONS_WITH_ARG = frozenset({ "-a", "--argv0", "-C", "--chdir", "-S", "--split-string", "-u", "--unset", }) _WRAPPER_OPTIONS_WITH_ARG = { "exec": frozenset({"-a"}), "time": frozenset({"-f", "--format", "-o", "--output"}), } _SIMPLE_WRAPPERS = frozenset({"builtin", "exec", "nohup", "setsid", "time"}) _MAX_RECURSION = 4 @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], ) -> int: 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]]: 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]) if executable == "sudo": index = _consume_options(words, index + 1, _SUDO_OPTIONS_WITH_ARG) continue if executable == "env": index = _consume_options(words, index + 1, _ENV_OPTIONS_WITH_ARG) continue if executable == "command": if index + 1 < len(words) and words[index + 1] in {"-v", "-V"}: return env, None, [] index = _consume_options(words, index + 1, frozenset()) continue if executable in _SIMPLE_WRAPPERS: index = _consume_options( words, index + 1, _WRAPPER_OPTIONS_WITH_ARG.get(executable, frozenset()), ) continue return env, words[index], words[index + 1 :] return env, None, [] def _scope_keys(command: str, starts: list[int]) -> dict[int, tuple[int, ...]]: 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 if quote == '"': if char == "\\" and cursor + 1 < start: cursor += 2 continue if char == '"': context.quote = None cursor += 1 continue if command.startswith("$(", cursor): contexts.append(_ShellContext("$(", cursor)) cursor += 2 continue if char == "`": contexts.append(_ShellContext("`", cursor)) cursor += 1 continue if char in {"'", '"'}: context.quote = char cursor += 1 continue if char == "\\" and cursor + 1 < start: cursor += 2 continue if command.startswith("$(", cursor): contexts.append(_ShellContext("$(", cursor)) cursor += 2 continue 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 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, frozenset()) 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. Tries approval.py's ``_bash_exec_payload`` first: it parses bash's real option grammar (``-O/-o`` consume the next argument, short-option bundles, ``--init-file``/``--rcfile``), catching payloads a naive scan misses — ``bash -o pipefail -c '