Files
hermes-agent/tests/git_safety.py
ethernet 907b06fb28 test(git-safety): parse the git config key instead of substring-matching argv
The checkout guard decided whether `git config` writes a URL rewrite by
substring-matching every argument, so `url.<base>.pushInsteadOf` and the
Git 2.46 `git config set <key>` form slipped through, while a value that
merely contained "url." could trip it. Find the key positionally (skipping
option values and the subcommand) and match insteadOf/pushInsteadOf on it.
2026-09-24 14:08:22 -04:00

140 lines
5.8 KiB
Python

"""Classify accidental Git mutations before tests can touch their checkout."""
import os
import shlex
from pathlib import Path
_WRAPPERS = {"env", "nohup", "setsid", "timeout", "sudo", "xargs", "nice", "ionice", "stdbuf", "flock"}
# Guard accidental checkout writes by update tests, not arbitrary shell code.
_MUTATIONS = {
"pull", "reset", "stash", "checkout", "switch", "restore",
"clean", "rebase", "merge", "cherry-pick", "revert", "apply", "am",
"commit", "add", "rm", "update-ref", "branch", "worktree", "tag",
}
_TARGET_OPTIONS = {"--git-dir", "--work-tree"}
# `git config` flags that answer a query; any of them makes the call read-only.
_CONFIG_READ_FLAGS = {"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l"}
# `git config` options whose value is the next argv entry, so it is not the key.
_CONFIG_VALUE_OPTIONS = {"-f", "--file", "--blob", "--type", "--default", "--comment", "--value", "--url"}
# `git config <subcommand> <key>` (Git 2.46+): the key follows the subcommand.
_CONFIG_WRITE_SUBCOMMANDS = {"set", "unset", "rename-section", "remove-section"}
_VALUE_OPTIONS = _TARGET_OPTIONS | {"-C", "-c", "--namespace", "--super-prefix"}
def _command_name(token):
return str(token).replace("\\", "/").rsplit("/", 1)[-1].lower().removesuffix(".exe")
def _git_argv_tail(cmd):
if cmd is None:
# Popen(args=None, executable=...) and shell-less spawns without argv: nothing to classify.
return None
if isinstance(cmd, (list, tuple)):
tokens = [os.fsdecode(t) for t in cmd]
else:
try:
tokens = shlex.split(os.fsdecode(cmd), posix=os.name != "nt")
except ValueError:
return None
if os.name == "nt":
tokens = [t[1:-1] if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens]
if not tokens:
return None
head = _command_name(tokens[0])
if head == "git":
return tokens[1:]
if head in {"sh", "bash", "zsh", "dash", "cmd", "powershell", "pwsh"}:
for index, token in enumerate(tokens[1:], 1):
if token.lower() in {"-c", "-lc", "/c", "-command"} and index + 1 < len(tokens):
return _git_argv_tail(tokens[index + 1])
return None
if head in _WRAPPERS:
for index, token in enumerate(tokens[1:], 1):
if _command_name(token) == "git":
return tokens[index + 1:]
return None
def _git_verb_and_targets(tail, kwargs):
try:
cwd = Path(kwargs.get("cwd") or os.getcwd())
except FileNotFoundError:
# The process cwd was deleted (deferred kanban cleanup, #33774); git itself will
# resolve relative to whatever it finds, and nothing under a dead dir is protected.
cwd = Path("/nonexistent-deleted-cwd")
env = kwargs.get("env")
if env is None:
env = os.environ
explicit = {key: env[value] for key, value in
(("--git-dir", "GIT_DIR"), ("--work-tree", "GIT_WORK_TREE")) if env.get(value)}
index = 0
while index < len(tail):
token = tail[index]
index += 1
if not token.startswith("-"):
targets = [cwd, *(cwd / value for value in explicit.values())]
return token, targets, tail[index:]
name, separator, value = token.partition("=")
if token.startswith("-C") and token != "-C":
name, value, separator = "-C", token[2:], "="
if name in _VALUE_OPTIONS:
if not separator:
if index == len(tail):
break
value = tail[index]
index += 1
if name == "-C":
cwd = cwd / value
elif name in _TARGET_OPTIONS:
explicit[name] = value
return None, [], []
def _config_writes_url_rewrite(after):
"""True when `git config <after>` writes a url.<base>.insteadOf/pushInsteadOf key."""
positional = []
index = 0
while index < len(after):
arg = after[index]
index += 1
if arg in _CONFIG_READ_FLAGS:
return False
if arg.startswith("-"):
if arg in _CONFIG_VALUE_OPTIONS:
index += 1
continue
positional.append(arg)
if positional and positional[0] in {"get", "list"}:
return False
if positional and positional[0] in _CONFIG_WRITE_SUBCOMMANDS:
positional = positional[1:]
key = positional[0].lower() if positional else ""
return key.startswith("url.") and key.endswith((".insteadof", ".pushinsteadof"))
def blocked_git_mutation(cmd, kwargs, protected_roots):
tail = _git_argv_tail(cmd)
if tail is None:
return None
verb, targets, after = _git_verb_and_targets(tail, kwargs or {})
if verb == "config":
# Querying config is safe; writing URL rewrites into the checkout is not.
if not _config_writes_url_rewrite(after):
return None
elif verb not in _MUTATIONS:
return None
if verb == "stash" and after and after[0] in {"list", "show"}:
return None
if verb == "worktree" and after and after[0] == "list":
return None
if verb == "branch" and (not after or after[0] in {"--list", "--show-current", "-a", "-r", "-v", "-vv"}):
return None
if verb == "tag" and (not after or any(arg in {"--list", "-l"} for arg in after)):
# --merged/--contains can precede --list. Do not let a combined
# listing and write option bypass the checkout guard.
if not any(arg in {"-d", "--delete", "-f", "--force", "-a", "--annotate", "-s", "--sign", "-u", "--local-user"} for arg in after):
return None
for target in targets:
try:
resolved = target.resolve()
if any(resolved.is_relative_to(Path(root).resolve()) for root in protected_roots):
return verb
except (OSError, ValueError):
return verb
return None