mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-02 02:13:49 +00:00
286 lines
9.1 KiB
Python
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:]))
|