"""Detect Git operations that can rewrite the checkout backing this process.""" from __future__ import annotations import contextlib import os import re import shlex import subprocess from dataclasses import dataclass, field from pathlib import Path from typing import Callable from tools.approval_detection import ( _bash_exec_payload, _deobfuscate_shell_word_for_detection, _iter_shell_command_starts, _read_shell_word) # bisect drives repeated checkouts of the running root — the exact skew hazard guarded here. _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 # runs first); listing them skips a pointless `git config --get alias.` subprocess. _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") # ``<<-`` opener + optional blanks + a quoted delimiter (closing quote required) or a bare word. _HEREDOC_OPENER_RE = re.compile( r"<<(?P-?)[ \t]*(?:(?P['\"])(?P.*?)(?P=q)|(?!['\"])(?P[^\s;|&<>]*))") _NO_OPTIONS: frozenset[str] = frozenset() # Wrapper executables skipped to reach the real command -> options that consume an 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, "nohup": _NO_OPTIONS, "setsid": _NO_OPTIONS, "exec": frozenset({"-a"}), "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: # one `(` / `$(` / backtick nesting level and its live quote state kind: str opener: int quote: str | None = None def get_running_source_root() -> Path | None: """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 = base / Path(os.path.expanduser(path_str)) # ``/`` keeps an absolute right operand with contextlib.suppress(OSError, RuntimeError, ValueError): return path.resolve() return path def _is_within(path: Path, root: Path) -> bool: with contextlib.suppress(OSError, RuntimeError, ValueError): return path == root or path.is_relative_to(root) 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]: """Deobfuscated words of the simple command at ``start`` (stops at a newline; max 64).""" 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 or (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) and words[index].startswith("-") and words[index] != "-": if words[index] == "--": return index + 1 index += 2 if "=" not in words[index] and words[index] in options_with_arg else 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 :] if executable == "command" and words[index + 1 : index + 2] in (["-v"], ["-V"]): break # `command -v/-V` only reports; nothing runs 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] closes = quote is None and len(contexts) > 1 # an unquoted closer may pop a scope if quote is not None and char == quote: context.quote = None elif quote == "'": pass # single quotes: no escapes, no substitutions elif char == "\\" and cursor + 1 < start: cursor += 1 elif quote is None and char in "'\"": context.quote = char # Unquoted or inside double quotes: substitutions still open scopes. elif command.startswith("$(", cursor): contexts.append(_ShellContext("$(", cursor)) cursor += 1 elif quote is None and char == "(": contexts.append(_ShellContext("(", cursor)) elif (char == ")" and closes and context.kind in {"(", "$("}) or ( char == "`" and closes and context.kind == "`"): contexts.pop() elif char == "`": 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: """The list/grouping operator (or newline) immediately preceding a command start.""" head = command[:start].rstrip() for tail in (head[-2:], head[-1:]): if tail in {"&&", "||", ";", "|", "&", "(", "{"}: return tail return "\n" if "\n" in command[len(head):start] else None def _cd_target(executable: str, args: list[str], cwd: Path) -> Path | None: """Directory a ``cd``/``pushd`` would land in (existing dirs only), else 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: """Script string owned by a shell's ``-c``, if present. ``_bash_exec_payload`` parses bash's real option grammar (``-o pipefail -c '