fabro/.fabro/workflows/code-review/scripts/git_readonly.py
2026-08-28 15:07:39 -04:00

286 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Restricted read-only Git entry point for code-review agents.
Duplicated from the security-review workflow so this directory installs alone.
The wrapper does not invoke a shell, ignores global Git configuration, disables
external diff helpers, and accepts only the history-reading subcommands the
review agents need.
"""
from __future__ import annotations
import sys
safe_path = getattr(sys.flags, "safe_path", sys.flags.isolated)
if not sys.flags.isolated or not safe_path:
print(
"git_readonly.py: Python isolated mode is required; invoke with python3 -I",
file=sys.stderr,
)
sys.exit(2)
import os
import subprocess
from pathlib import Path
from typing import Dict, List, Sequence
ALLOWED_SUBCOMMANDS = {
"blame",
"diff",
"log",
"show",
}
FORBIDDEN_ARGUMENTS = {
"--config-env",
"--exec-path",
"--ext-diff",
"--no-index",
"--output",
"--paginate",
"--show-signature",
"--textconv",
"--upload-pack",
"--write",
"-c",
}
FORBIDDEN_BLAME_ARGUMENTS = {
"--contents",
"--ignore-revs-file",
"-S",
}
SAFE_GIT_CONFIGURATION = {
"blame.ignoreRevsFile": "",
"core.alternateRefsCommand": "false",
"core.askPass": "false",
"core.attributesFile": os.devnull,
"core.editor": "false",
"core.excludesFile": os.devnull,
"core.fsmonitor": "",
"core.gitProxy": "false",
"core.hooksPath": os.devnull,
"core.pager": "cat",
"core.sshCommand": "false",
"diff.external": "false",
"diff.orderFile": os.devnull,
"gpg.format": "openpgp",
"gpg.openpgp.program": "false",
"gpg.program": "false",
"gpg.ssh.allowedSignersFile": os.devnull,
"gpg.ssh.defaultKeyCommand": "false",
"gpg.ssh.program": "false",
"gpg.ssh.revocationFile": os.devnull,
"interactive.diffFilter": "false",
"mailmap.file": os.devnull,
"pager.blame": "false",
"pager.diff": "false",
"pager.log": "false",
"pager.show": "false",
"protocol.allow": "never",
"protocol.ext.allow": "never",
"protocol.file.allow": "never",
"submodule.recurse": "false",
}
SAFE_ENVIRONMENT = {
"GIT_ATTR_NOSYSTEM": "1",
"GIT_CONFIG": os.devnull,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_SYSTEM": os.devnull,
"GIT_PAGER": "cat",
"GIT_TERMINAL_PROMPT": "0",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"PAGER": "cat",
"PATH": os.defpath,
}
MAX_ARGUMENTS = 200
MAX_ARGUMENT_LENGTH = 4096
# Git runs these per-driver keys as commands. Their middle segment is chosen by
# the repository, so they cannot be disabled by a fixed key list and are instead
# neutralized for every driver the repository actually configures.
EXECUTABLE_DRIVER_KEYS = {
"filter": ("clean", "process", "smudge"),
"diff": ("command", "textconv"),
}
class GitWrapperError(RuntimeError):
"""A rejected wrapper request."""
def validate_arguments(argv: Sequence[str]) -> List[str]:
if not argv:
raise GitWrapperError(
"usage: git_readonly.py <diff|show|log|blame> [arguments]"
)
if len(argv) > MAX_ARGUMENTS:
raise GitWrapperError("too many Git arguments")
subcommand = argv[0]
if subcommand not in ALLOWED_SUBCOMMANDS:
raise GitWrapperError(f"Git subcommand is not allowed: {subcommand!r}")
validated: List[str] = []
for argument in argv[1:]:
if "\0" in argument or "\n" in argument or "\r" in argument:
raise GitWrapperError("Git arguments cannot contain control characters")
if len(argument) > MAX_ARGUMENT_LENGTH:
raise GitWrapperError("a Git argument exceeds the length limit")
option_name = argument.split("=", 1)[0]
if option_name in FORBIDDEN_ARGUMENTS or (
option_name.startswith("--")
and option_name != "--"
and any(
forbidden.startswith(option_name)
for forbidden in FORBIDDEN_ARGUMENTS
if forbidden.startswith("--")
)
):
raise GitWrapperError(f"Git option is not allowed: {option_name!r}")
if argument.startswith("-O"):
raise GitWrapperError("Git diff order files are not allowed")
if subcommand == "blame" and (
option_name in FORBIDDEN_BLAME_ARGUMENTS
or (
option_name.startswith("--")
and option_name != "--"
and any(
forbidden.startswith(option_name)
for forbidden in FORBIDDEN_BLAME_ARGUMENTS
if forbidden.startswith("--")
)
)
or argument.startswith("-S")
):
raise GitWrapperError(f"Git blame option is not allowed: {option_name!r}")
validated.append(argument)
if subcommand == "diff":
path_operands = [arg for arg in validated if not arg.startswith("-")]
if len(path_operands) >= 2 and all(
Path(operand).exists() for operand in path_operands[:2]
):
raise GitWrapperError(
"Git diff cannot compare two working-tree file operands"
)
for argument in validated:
reject_operand_outside_repository(argument)
return [subcommand, *validated]
def reject_operand_outside_repository(argument: str) -> None:
"""Keep every filesystem operand inside the repository."""
if argument.startswith("-"):
return
if argument.startswith("/") or argument.startswith("\\\\"):
raise GitWrapperError(
f"Git operand must be inside the repository: {argument!r}"
)
if ".." not in argument.split("/"):
return
root = Path.cwd().resolve()
try:
(root / argument).resolve().relative_to(root)
except (ValueError, OSError) as error:
raise GitWrapperError(
f"Git operand must be inside the repository: {argument!r}"
) from error
def configured_driver_keys(worktree: Path) -> List[str]:
"""Return the executable per-driver keys this repository configures.
Driver names live in the middle of the key, so a fixed override list cannot
reach them. Reading the names first lets every configured driver be
overridden by name. The probe itself runs no repository-supplied command.
"""
# Legacy GIT_CONFIG points `git config` at a single file, which would hide
# the repository's own driver names. It does not affect the history-reading
# subcommands, so only this probe drops it.
probe_environment = {
key: value for key, value in SAFE_ENVIRONMENT.items() if key != "GIT_CONFIG"
}
try:
listing = subprocess.run(
["git", "-C", str(worktree), "config", "--list", "--name-only", "-z"],
capture_output=True,
check=False,
env=probe_environment,
stdin=subprocess.DEVNULL,
text=True,
)
except OSError as error:
raise GitWrapperError(f"could not read Git configuration: {error}") from error
if listing.returncode != 0:
return []
keys: List[str] = []
for name in listing.stdout.split("\0"):
section, _, remainder = name.partition(".")
driver, _, key = remainder.rpartition(".")
if driver and key in EXECUTABLE_DRIVER_KEYS.get(section, ()):
keys.append(name)
return keys
def build_command(argv: Sequence[str]) -> List[str]:
subcommand, *arguments = validate_arguments(argv)
command = ["git"]
for key, value in SAFE_GIT_CONFIGURATION.items():
command.extend(["-c", f"{key}={value}"])
command.extend(
[
"-C",
str(Path.cwd().resolve()),
subcommand,
"--no-ext-diff",
"--no-textconv",
"--no-show-signature",
*arguments,
]
)
return command
def build_environment(driver_keys: Sequence[str]) -> Dict[str, str]:
"""Disable each configured driver at Git's highest configuration precedence.
These overrides travel in the environment rather than in `-c` arguments:
Git splits a `-c` pair on its first `=`, so a driver name containing `=`
cannot be overridden that way, while the key/value variables carry the name
exactly.
"""
environment = dict(SAFE_ENVIRONMENT)
for index, key in enumerate(driver_keys):
environment[f"GIT_CONFIG_KEY_{index}"] = key
environment[f"GIT_CONFIG_VALUE_{index}"] = ""
environment["GIT_CONFIG_COUNT"] = str(len(driver_keys))
return environment
def main(argv: Sequence[str]) -> int:
try:
command = build_command(argv)
environment = build_environment(
configured_driver_keys(Path.cwd().resolve())
)
except GitWrapperError as error:
print(f"git_readonly.py: {error}", file=sys.stderr)
return 2
try:
result = subprocess.run(
command,
env=environment,
stdin=subprocess.DEVNULL,
check=False,
)
except OSError as error:
print(f"git_readonly.py: could not run Git: {error}", file=sys.stderr)
return 2
return int(result.returncode)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))