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>
156 lines
4.3 KiB
Python
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()
|