Files
pkgmgr/src/pkgmgr/core/git/run.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 4c8b137449 fix(git): refuse repository config that runs a program before push, release and gc
An agent that can write a repository's .git/config could make the operator's pkgmgr push, release or branch gc run a program or reroute the push. UNSAFE_CONFIG lists the local keys that do (core.sshCommand, gitProxy, askPass, fsmonitor, alternateRefsCommand, credential helpers, gpg programs, protocol.*, config hooks, gc.recentObjectsHook, http proxy/TLS/cookie keys, remote pack programs and proxy, url rewrites). push and release refuse up front; gc skips its fetch and keeps remote branches. Filter drivers, hooksPath, gpg.format and include stay allowed for git-lfs, husky and per-repo signing.

SCAN_FLAGS add --no-pager and switch off config-defined hooks (hook.<event>.enabled=false), which core.hooksPath does not cover. The signing amend, gc's fetch, delete push and branch -D run under them. Remote and ref names sit behind -- in every push, fetch, ls-remote and remote get-url, fetches and pushes no longer recurse into submodules, and a staged gitlink is no longer folded into the signed HEAD. gc skips a compose project that also has containers outside the worktree.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 91 %
⏱️ Time: 89 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:26:40 +00:00

156 lines
4.3 KiB
Python

from __future__ import annotations
import os
import subprocess
from .errors import GitNotRepositoryError, GitRunError
SCAN_FLAGS = (
"--no-pager",
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
# hook.<name>.command hooks from the config ignore core.hooksPath
"-c",
"hook.reference-transaction.enabled=false",
"-c",
"hook.prepare-commit-msg.enabled=false",
"-c",
"hook.post-commit.enabled=false",
"-c",
"hook.post-rewrite.enabled=false",
"-c",
"hook.post-index-change.enabled=false",
"-c",
"hook.pre-push.enabled=false",
"-c",
"hook.post-checkout.enabled=false",
"-c",
"core.pager=cat",
"-c",
"gc.auto=0",
"-c",
"maintenance.auto=false",
"-c",
"log.showSignature=false",
)
SCAN_ENV = {
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_NO_LAZY_FETCH": "1",
"GIT_NO_REPLACE_OBJECTS": "1",
"GIT_OPTIONAL_LOCKS": "0",
"GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": "/bin/false",
"LC_ALL": "C",
}
SCAN_UNSET = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_EXTERNAL_DIFF",
"GIT_PAGER",
"PAGER",
)
SCAN_TIMEOUT = 60
# ref and remote names come from repositories an agent may write: one named
# like --output=<path> would otherwise make rev-list create that file
END_OF_OPTIONS = "--end-of-options"
def _is_not_repo_error(stderr: str) -> bool:
msg = (stderr or "").lower()
return "not a git repository" in msg
def _scan_env() -> dict[str, str]:
env = {key: value for key, value in os.environ.items() if key not in SCAN_UNSET}
return {**env, **SCAN_ENV}
def run(
args: list[str],
*,
cwd: str = ".",
preview: bool = False,
scan: bool = False,
input_text: str | None = None,
) -> str:
"""
Run a Git command and return its stdout as a stripped string.
Args:
args: the git arguments.
cwd: directory to run in.
preview: print the command instead of running it.
scan: read a repository an agent may have written: no fsmonitor, hook,
pager, lazy fetch, replace object, system config or prompt, stdin
closed, a timeout.
input_text: text fed to stdin, e.g. for `cat-file --batch-check`;
replaces the closed stdin of scan mode.
Raises GitRunError (or a subclass) if execution fails or times out.
"""
cmd = ["git", *SCAN_FLAGS, *args] if scan else ["git"] + args
cmd_str = " ".join(cmd)
if preview:
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
return ""
hardened: dict = (
{"env": _scan_env(), "stdin": subprocess.DEVNULL, "timeout": SCAN_TIMEOUT}
if scan
else {}
)
if input_text is not None:
hardened.pop("stdin", None)
hardened["input"] = input_text
try:
result = subprocess.run(
cmd,
cwd=cwd,
check=True,
capture_output=True,
text=True,
**hardened,
)
except subprocess.TimeoutExpired as exc:
err = GitRunError(f"Git command timed out in {cwd!r}: {cmd_str}")
err.cwd, err.cmd, err.cmd_str, err.returncode = cwd, cmd, cmd_str, -1
err.stdout, err.stderr = "", ""
raise err from exc
except subprocess.CalledProcessError as exc:
stderr = exc.stderr or ""
stdout = exc.stdout or ""
if _is_not_repo_error(stderr):
err = GitNotRepositoryError(
f"Not a git repository: {cwd!r}\nCommand: {cmd_str}\nSTDERR:\n{stderr}"
)
# Attach details for callers who want to debug
err.cwd = cwd
err.cmd = cmd
err.cmd_str = cmd_str
err.returncode = exc.returncode
err.stdout = stdout
err.stderr = stderr
raise err from exc
err = GitRunError(
f"Git command failed in {cwd!r}: {cmd_str}\n"
f"Exit code: {exc.returncode}\n"
f"STDOUT:\n{stdout}\n"
f"STDERR:\n{stderr}"
)
# Attach details for callers who want to debug
err.cwd = cwd
err.cmd = cmd
err.cmd_str = cmd_str
err.returncode = exc.returncode
err.stdout = stdout
err.stderr = stderr
raise err from exc
return result.stdout.strip()