From 824527497c4da83524b45d4ed1e1431e5430664c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 21:38:44 -0400 Subject: [PATCH] feat(git-gate): rewrite both fetch and push via insteadOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's ~/.gitconfig now uses insteadOf (not pushInsteadOf), so every git operation against a declared upstream — push, fetch, clone, pull, ls-remote — routes through the gate. Matches the gate's now-bidirectional design: fetch is mirrored via the access-hook, push is gated via gitleaks. --- claude_bottle/backend/docker/provision/git.py | 24 ++++++++++--------- tests/unit/test_provision_git.py | 17 ++++++------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/claude_bottle/backend/docker/provision/git.py b/claude_bottle/backend/docker/provision/git.py index 937d151..7dc91e0 100644 --- a/claude_bottle/backend/docker/provision/git.py +++ b/claude_bottle/backend/docker/provision/git.py @@ -6,10 +6,11 @@ Two concerns, both about git in the agent: into /home/node/workspace/.git so the agent operates on the user's repo. 2. If the bottle declares `git` entries (PRD 0008), write a - ~/.gitconfig with pushInsteadOf rules so a `git push ` - from inside the agent transparently hits the per-agent git-gate - instead of the real remote. Fetch keeps the original URL — v1 - gates push only. + ~/.gitconfig with insteadOf rules so every git operation + against a declared upstream (push, fetch, clone, pull, + ls-remote) transparently hits the per-agent git-gate. The + gate mirrors the upstream in both directions, so URL + rewriting is symmetric. """ from __future__ import annotations @@ -56,7 +57,7 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None: def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: - """Render the ~/.gitconfig content for git-gate `pushInsteadOf` + """Render the ~/.gitconfig content for git-gate `insteadOf` rewrites. Pure host-side, no docker; exposed for tests. Empty `entries` returns an empty string so callers can no-op @@ -65,19 +66,20 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: return "" gate = git_gate_host(slug) out = [ - "# claude-bottle git-gate (PRD 0008): pushes to declared upstreams\n", - "# transparently route through the gitleaks-scanning git-gate.\n", - "# Fetch keeps the original URL (v1 gates push only).\n", + "# claude-bottle git-gate (PRD 0008): every git operation against\n", + "# a declared upstream routes through the gate, which mirrors\n", + "# the upstream bidirectionally (gitleaks-scanned push;\n", + "# fetch-from-upstream-before-every-upload-pack via access-hook).\n", ] for entry in entries: out.append(f'[url "git://{gate}/{entry.Name}.git"]\n') - out.append(f"\tpushInsteadOf = {entry.Upstream}\n") + out.append(f"\tinsteadOf = {entry.Upstream}\n") return "".join(out) def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None: """Write ~/.gitconfig in the bottle with the git-gate - pushInsteadOf rules. No-op when the bottle has no `git` entries.""" + insteadOf rules. No-op when the bottle has no `git` entries.""" bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if not bottle.git: return @@ -90,7 +92,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None: config_file.write_text(content) config_file.chmod(0o600) - info(f"writing {container_gitconfig} with {len(bottle.git)} pushInsteadOf rule(s)") + info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)") subprocess.run( ["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"], stdout=subprocess.DEVNULL, diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 16d1eb3..8c2f6af 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -21,24 +21,25 @@ class TestGitGateGitconfigRender(unittest.TestCase): out, ) self.assertIn( - "\tpushInsteadOf = " + "\tinsteadOf = " "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", out, ) self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out) self.assertIn( - "\tpushInsteadOf = ssh://git@github.com/didericis/foo.git", + "\tinsteadOf = ssh://git@github.com/didericis/foo.git", out, ) - def test_pushInsteadOf_not_insteadOf(self): - # insteadOf would route fetch through the gate too; v1 only - # gates push. If this assertion ever fails we've inadvertently - # widened the gate's scope. + def test_insteadOf_not_pushInsteadOf(self): + # The gate mirrors fetch and push, so insteadOf (which rewrites + # both directions) is the right knob. pushInsteadOf would only + # gate push and leave fetch on the original URL — exactly the + # v1 design we've moved past. bottle = fixture_with_git().bottles["dev"] out = render_git_gate_gitconfig("demo", bottle.git) - self.assertIn("pushInsteadOf", out) - self.assertNotIn("\tinsteadOf", out) + self.assertIn("\tinsteadOf", out) + self.assertNotIn("pushInsteadOf", out) if __name__ == "__main__":