Files
hermes-agent/tools/self_repo_guard.py
T

679 lines
23 KiB
Python

"""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.<sub>`
# 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 '<script>'`` hides ``-c`` behind an operand). When it
finds no ``-c``, fall back to a permissive positional scan because
zsh/dash/ksh option letters (``zsh -yc``) fall outside bash's alphabet and
would otherwise make this block-guard fail open.
"""
has_c, payload = _bash_exec_payload(args)
if has_c:
return payload
for index, arg in enumerate(args):
if arg == "--":
break
if arg.startswith("-") and "c" in arg[1:]:
return args[index + 1] if index + 1 < len(args) else None
if not arg.startswith("-"):
break
return None
def _heredoc_specs(line: str) -> list[_Heredoc]:
"""Heredoc openers on one line; ``execute_as_shell`` when a bare shell consumes the body."""
specs: list[_Heredoc] = []
quote: str | None = None
index = 0
while index < len(line):
char = line[index]
if quote:
if char == "\\" and quote == '"' and index + 1 < len(line):
index += 2
continue
if char == quote:
quote = None
index += 1
continue
if char in {"'", '"'}:
quote = char
index += 1
continue
if not line.startswith("<<", index) or line.startswith("<<<", index):
index += 1
continue
operator_at = index
index += 2
strip_tabs = index < len(line) and line[index] == "-"
if strip_tabs:
index += 1
while index < len(line) and line[index] in {" ", "\t"}:
index += 1
if index >= len(line):
break
delimiter_quote = line[index] if line[index] in {"'", '"'} else None
if delimiter_quote:
index += 1
end = line.find(delimiter_quote, index)
if end == -1:
break
delimiter = line[index:end]
index = end + 1
else:
end = index
while (
end < len(line) and not line[end].isspace() and line[end] not in ";|&<>"
):
end += 1
delimiter = line[index:end]
index = end
if not delimiter:
continue
header = line[:operator_at]
starts = list(_iter_shell_command_starts(header))
words = _shell_words_at(header, starts[-1]) if starts else []
_, executable, args = _command_parts(words)
execute_as_shell = bool(
executable
and _executable_name(executable) in _SHELL_EXECUTABLES
and _shell_script_arg(args) is None
and not any(arg and not arg.startswith("-") for arg in args)
)
specs.append(_Heredoc(delimiter, strip_tabs, execute_as_shell))
return specs
def _masked_line(line: str) -> str:
return "".join(char if char in {"\r", "\n"} else " " for char in line)
def _mask_heredocs(command: str) -> tuple[str, list[str]]:
"""Blank heredoc bodies; return (masked command, bodies a bare shell would execute).
Unterminated heredocs run to end of input and are still reported.
"""
output: list[str] = []
pending: list[_Heredoc] = []
finished: list[_Heredoc] = []
for line in command.splitlines(keepends=True):
if pending:
current = pending[0]
candidate = line.rstrip("\r\n")
if current.strip_tabs:
candidate = candidate.lstrip("\t")
if candidate == current.delimiter:
finished.append(pending.pop(0))
else:
current.body.append(line)
output.append(_masked_line(line))
continue
output.append(line)
pending.extend(_heredoc_specs(line))
shell_scripts = ["".join(spec.body) for spec in finished + pending if spec.execute_as_shell]
return "".join(output), shell_scripts
def _record_alias(config: str, aliases: dict[str, str]) -> None:
"""Record an inline ``-c alias.<name>=<value>`` git config override."""
if config.lower().startswith("alias.") and "=" in config:
key, value = config.split("=", 1)
aliases[key[6:].lower()] = value
def _git_target_and_subcommand(
args: list[str],
current_dir: Path,
env: dict[str, str],
) -> tuple[Path, str | None, list[str], dict[str, str]]:
"""Parse git's global options -> (target dir, subcommand, sub args, inline aliases)."""
target = current_dir
work_tree: str | None = None
aliases: dict[str, str] = {}
index = 0
while index < len(args):
arg = args[index]
if arg == "--":
index += 1
break
if not arg.startswith("-"):
break
if arg in _GIT_GLOBAL_OPTIONS_WITH_ARG:
if index + 1 < len(args):
value = args[index + 1]
if arg == "-C":
target = _resolve(value, target)
elif arg == "--work-tree":
work_tree = value
elif arg == "-c":
_record_alias(value, aliases)
index += 2
continue
if arg.startswith("-C") and len(arg) > 2:
target = _resolve(arg[2:], target)
elif arg.startswith("--work-tree="):
work_tree = arg.split("=", 1)[1]
elif arg.startswith("-calias."):
_record_alias(arg[2:], aliases)
index += 1
explicit_work_tree = work_tree or env.get("GIT_WORK_TREE")
if explicit_work_tree:
target = _resolve(explicit_work_tree, target)
subcommand = args[index].lower() if index < len(args) else None
return target, subcommand, args[index + 1 :], aliases
def _has_short_flag(arg: str, letter: str) -> bool:
return arg.startswith("-") and letter in arg[1:]
def _reset_mutates(args: list[str]) -> bool:
return any(arg in _RESET_WORKTREE_MODES or _RESET_HARD_RE.fullmatch(arg) for arg in args)
def _stash_mutates(args: list[str]) -> bool:
action = next((arg for arg in args if not arg.startswith("-")), "push")
return action not in _STASH_SAFE_ACTIONS
def _clean_mutates(args: list[str]) -> bool:
return not any(
arg == "--dry-run" or (not arg.startswith("--") and _has_short_flag(arg, "n"))
for arg in args
)
def _restore_mutates(args: list[str]) -> bool:
staged = any(arg == "--staged" or _has_short_flag(arg, "S") for arg in args)
worktree = any(arg == "--worktree" or _has_short_flag(arg, "W") for arg in args)
return worktree or not staged
# Subcommands whose worktree impact depends on their arguments.
_CONDITIONAL_MUTATIONS: dict[str, Callable[[list[str]], bool]] = {
"reset": _reset_mutates,
"stash": _stash_mutates,
"clean": _clean_mutates,
"restore": _restore_mutates,
}
def _mutates_worktree(subcommand: str, args: list[str]) -> bool:
check = _CONDITIONAL_MUTATIONS.get(subcommand)
if check is not None:
return check(args)
return subcommand in _WORKTREE_MUTATIONS
def _inspect_git_worktree(args: list[str], cwd: Path, root: Path) -> str | None:
"""Block `worktree remove|move` aimed at the running root, from any directory."""
action_index = _consume_options(args, 0)
if action_index >= len(args):
return None
action = args[action_index].lower()
if action not in _WORKTREE_TARGET_ACTIONS:
return None
target_index = _consume_options(args, action_index + 1)
if target_index >= len(args):
return None
if _resolve(args[target_index], cwd) == root:
return f"git worktree {action}"
return None
def _read_git_alias(executable: str, target: Path, alias: str) -> str | None:
try:
result = subprocess.run(
[executable, "-C", str(target), "config", "--get", f"alias.{alias}"],
capture_output=True,
text=True,
timeout=1,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
value = result.stdout.strip()
return value if result.returncode == 0 and value else None
def _inspect_git(
executable: str,
args: list[str],
current_dir: Path,
env: dict[str, str],
root: Path,
depth: int,
) -> str | None:
target, subcommand, sub_args, inline_aliases = _git_target_and_subcommand(
args, current_dir, env
)
if subcommand is None:
return None
# `worktree` names its victim as an argument, so the cwd check does not apply.
if subcommand == "worktree":
return _inspect_git_worktree(sub_args, target, root)
if not _is_within(target, root):
return None
if _mutates_worktree(subcommand, sub_args):
return f"git {subcommand}"
if subcommand in _KNOWN_GIT_BUILTINS or depth >= _MAX_RECURSION:
return None
alias = inline_aliases.get(subcommand)
if alias is None:
alias = _read_git_alias(executable, target, subcommand)
if not alias:
return None
if alias.startswith("!"):
return _find_mutation(alias[1:], target, root, depth + 1)
try:
alias_args = shlex.split(alias, posix=True)
except ValueError:
return None
return _inspect_git(
executable,
[*alias_args, *sub_args],
target,
{},
root,
depth + 1,
)
def _inspect_github_cli(
executable: str,
args: list[str],
current_dir: Path,
env: dict[str, str],
root: Path,
depth: int,
) -> str | None:
if not _is_within(current_dir, root):
return None
name = _executable_name(executable)
index = _consume_options(args, 0, frozenset({"-R", "--repo", "--hostname"}))
if args[index : index + 2] == ["pr", "checkout"]:
return f"{name} pr checkout"
return None
def _inspect_shell(
executable: str,
args: list[str],
current_dir: Path,
env: dict[str, str],
root: Path,
depth: int,
) -> str | None:
script = _shell_script_arg(args)
if script:
return _find_mutation(script, current_dir, root, depth + 1)
return None
# executable name -> inspector(executable, args, current_dir, env, root, depth)
_INSPECTORS: dict[str, Callable[..., str | None]] = {
"git": _inspect_git,
"gh": _inspect_github_cli,
"hub": _inspect_github_cli,
**{shell: _inspect_shell for shell in _SHELL_EXECUTABLES},
}
def _find_mutation(command: str, cwd: Path, root: Path, depth: int = 0) -> str | None:
"""Name of the first command in ``command`` that would rewrite ``root``, else None."""
if depth > _MAX_RECURSION:
return None
masked_command, heredoc_scripts = _mask_heredocs(command)
for script in heredoc_scripts:
operation = _find_mutation(script, cwd, root, depth + 1)
if operation:
return operation
starts = sorted(set(_iter_shell_command_starts(masked_command)))
scopes = _scope_keys(masked_command, starts)
# cwd is tracked per subshell scope: `cd` only takes effect for the NEXT
# command when joined by `&&`, `;` or a newline (not `||` / `|`).
cwd_by_scope: dict[tuple[int, ...], Path] = {(): cwd}
pending_cd: dict[tuple[int, ...], Path] = {}
for start in starts:
scope = scopes[start]
if scope not in cwd_by_scope:
cwd_by_scope[scope] = cwd_by_scope.get(scope[:-1], cwd)
operator = _operator_before(masked_command, start)
pending = pending_cd.pop(scope, None)
if pending is not None and operator in {"&&", ";", "\n"}:
cwd_by_scope[scope] = pending
words = _shell_words_at(masked_command, start)
env, executable, args = _command_parts(words)
if executable is None:
continue
current_dir = cwd_by_scope[scope]
cd_target = _cd_target(executable, args, current_dir)
if cd_target is not None:
pending_cd[scope] = cd_target
continue
inspect = _INSPECTORS.get(_executable_name(executable))
if inspect is not None:
operation = inspect(executable, args, current_dir, env, root, depth)
if operation:
return operation
return None
def guard_active() -> bool:
"""Whether the self-repo git guard applies on this platform.
Windows-only: NTFS locks loaded .py/.pyd files, so an in-place overwrite of
the live checkout can corrupt the running process. On POSIX, open handles
keep the old inode alive, so already-imported modules keep running; the
mixed-module hazard is limited to later lazy imports — not worth blocking
every git workflow for.
"""
return os.name == "nt"
def detect_self_repo_git_mutation(
command: str,
cwd: str | None,
source_root: Path | None = None,
) -> tuple[bool, str | None]:
"""Return whether a command would rewrite the live source checkout."""
root = source_root if source_root is not None else get_running_source_root()
if root is None or not command:
return False, None
root = _resolve(str(root), Path("/"))
base = _resolve(cwd, Path("/")) if cwd else Path("/")
operation = _find_mutation(command, base, root)
if operation is None:
return False, None
return True, _block_message(operation, root)
def _block_message(operation: str, root: Path) -> str:
# Suggest a disk-backed scratch dir: /tmp is usually tmpfs (see message).
hermes_home = os.environ.get("HERMES_HOME", "").strip()
scratch = (Path(hermes_home).expanduser() if hermes_home else Path.home() / ".hermes") / "scratch"
return (
f"Blocked: `{operation}` would rewrite Hermes's live source checkout "
f"({root}) and can mix module versions in this running process. "
f"Use a separate worktree or a shared clone on real disk, e.g. "
f"`git clone --shared {root} {scratch}/<task>` — avoid /tmp for "
"clones that install node/python deps: /tmp is usually RAM-backed "
"tmpfs and a few dependency installs can fill it and ENOSPC other "
"work. Delete the clone when the branch is pushed. To change this "
"checkout, stop Hermes, run the command externally, then restart "
"Hermes."
)