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>
This commit is contained in:
1 parent
fb6183838b
commit
4c8b137449
13 files changed
+467
-22
No files matched your search
@@ -109,6 +109,7 @@ class GcFixture:
|
||||
scope: str = "all",
|
||||
activity: bool = False,
|
||||
hooks: dict | None = None,
|
||||
containers: list[gc.Container] | None = None,
|
||||
) -> gc.Repo:
|
||||
with (
|
||||
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
||||
@@ -116,7 +117,7 @@ class GcFixture:
|
||||
):
|
||||
return gc.plan_repo(
|
||||
self.repo,
|
||||
[],
|
||||
containers or [],
|
||||
host_ns,
|
||||
merged_into=merged_into or [],
|
||||
sizes=sizes,
|
||||
@@ -388,6 +389,97 @@ class TestBranchGc(GcFixture, unittest.TestCase):
|
||||
self.assertEqual(git(self.repo, "for-each-ref", "refs/heads/-evil"), "")
|
||||
self.assertEqual(git(other, "for-each-ref", "refs/heads/-evil"), "")
|
||||
|
||||
def _fetches(self) -> int:
|
||||
with patch.object(gc, "run", wraps=gc.run) as spy:
|
||||
self.plan = self._plan(host_ns=True, cwds={})
|
||||
return sum("fetch" in call.args[0] for call in spy.call_args_list)
|
||||
|
||||
def test_unsafe_repository_config_skips_fetch_and_remote_deletes(self) -> None:
|
||||
self.assertEqual(self._fetches(), 1)
|
||||
git(self.repo, "config", "core.sshCommand", "false")
|
||||
|
||||
self.assertEqual(self._fetches(), 0)
|
||||
plan = self.plan
|
||||
item = self._items(plan)["merged"]
|
||||
|
||||
self.assertIn(
|
||||
"fetch and remote branch deletes skipped: the repository config sets "
|
||||
"core.sshcommand",
|
||||
plan.warnings,
|
||||
)
|
||||
self.assertIsNone(item.skip)
|
||||
self.assertEqual(item.actions, ["worktree", "local-branch"])
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertFalse(os.path.exists(self.merged_wt))
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "merged")
|
||||
|
||||
def test_config_set_after_plan_stops_execute(self) -> None:
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
git(self.repo, "config", "credential.helper", "false")
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(
|
||||
item.result, "skipped: repository config sets credential.helper"
|
||||
)
|
||||
self.assertTrue(os.path.isdir(self.merged_wt))
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "merged")
|
||||
|
||||
def test_compose_project_with_containers_elsewhere_is_skipped(self) -> None:
|
||||
inside = gc.Container("proj", os.path.join(self.merged_wt, "app"), "img")
|
||||
elsewhere = gc.Container("proj", os.path.join(self.root, "other"), "img")
|
||||
|
||||
alone = self._items(self._plan(host_ns=True, cwds={}, containers=[inside]))
|
||||
shared = self._items(
|
||||
self._plan(host_ns=True, cwds={}, containers=[inside, elsewhere])
|
||||
)
|
||||
|
||||
self.assertIsNone(alone["merged"].skip)
|
||||
self.assertIn("compose-down:proj", alone["merged"].actions)
|
||||
self.assertEqual(
|
||||
shared["merged"].skip,
|
||||
"compose project proj also has containers outside the worktree",
|
||||
)
|
||||
self.assertFalse(shared["merged"].eligible)
|
||||
|
||||
def test_execute_runs_no_repository_hook(self) -> None:
|
||||
marker = os.path.join(self.root, "hooked")
|
||||
hook = os.path.join(self.repo, ".git", "hooks", "reference-transaction")
|
||||
with open(hook, "w") as fh:
|
||||
fh.write(f"#!/bin/sh\ntouch {marker}\n")
|
||||
os.chmod(hook, 0o755)
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["merged"]
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertEqual(git(self.origin, "branch", "--list", "merged"), "")
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_local_branch_delete_runs_no_config_hook(self) -> None:
|
||||
marker = os.path.join(self.root, "hooked")
|
||||
git(self.repo, "branch", "local-only", "merged")
|
||||
git(self.repo, "config", "hook.probe.command", f"touch {marker}")
|
||||
git(self.repo, "config", "hook.probe.event", "reference-transaction")
|
||||
plan = self._plan(host_ns=True, cwds={})
|
||||
item = self._items(plan)["local-only"]
|
||||
self.assertEqual(item.actions, ["local-branch"])
|
||||
|
||||
with patch.object(gc, "_process_cwds", return_value={}):
|
||||
gc.execute(plan, item)
|
||||
|
||||
self.assertEqual(item.result, "done")
|
||||
self.assertEqual(git(self.repo, "branch", "--list", "local-only"), "")
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_cleanup_json_and_refusal(self) -> None:
|
||||
out = io.StringIO()
|
||||
with (
|
||||
|
||||
Reference in new issue
Block a user