From 89981f9048f1ba53e5118e82f759ca31e1f9a15e Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 21:17:42 -0400 Subject: [PATCH] test(git-gate): integration smoke + secret-blocking push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration tests against a real Docker daemon: - test_ls_remote_succeeds_against_fresh_gate: a freshly-started gate has its empty bare repo exported via git daemon; ls-remote from a sibling container on the internal network returns no refs and exits 0. - test_push_with_secret_is_rejected: the PRD 0008 success criterion — a push containing an AKIA-shaped synthetic that trips gitleaks's aws-access-token rule is rejected by the pre-receive hook with a non-zero exit on the client and a gitleaks rejection in the response. Dockerfile.git-gate switches base to zricethezav/gitleaks (alpine 3.22 + gitleaks v8.30.1, pinned by digest) since gitleaks isn't packaged for alpine, and adds git-daemon (the sub-package the listener needs; the core git binary in the base doesn't include the daemon). --- Dockerfile.git-gate | 15 +- tests/integration/test_git_gate_sidecar.py | 205 +++++++++++++++++++++ 2 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_git_gate_sidecar.py diff --git a/Dockerfile.git-gate b/Dockerfile.git-gate index b8174d0..1132fe0 100644 --- a/Dockerfile.git-gate +++ b/Dockerfile.git-gate @@ -10,12 +10,16 @@ # default route, so the image is fully self-contained: no apk pulls at # boot, no remote registry lookups during the entrypoint. -FROM alpine:3.20 +# Base on the upstream gitleaks image (alpine + gitleaks v8.x); +# alpine doesn't package gitleaks so this avoids a separate +# install path. Pinned by digest for reproducibility. +FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f -# git for the daemon + push-to-upstream; -# openssh-client for the upstream SSH transport; -# gitleaks is the actual scanner the pre-receive hook calls. -RUN apk add --no-cache git openssh-client gitleaks +# openssh-client supplies the upstream SSH transport the pre-receive +# hook uses to forward accepted refs. git-daemon is the listener the +# agent pushes to (alpine ships `git-daemon` as a sub-package, not +# part of `git`). The `git` core binary is already in the base image. +RUN apk add --no-cache openssh-client git-daemon # Layout the gate uses at runtime: # /git-gate-entrypoint.sh — docker-cp'd at start time @@ -29,4 +33,5 @@ RUN apk add --no-cache git openssh-client gitleaks # defensively. RUN mkdir -p /etc/git-gate /git-gate/creds /git +# Base image's ENTRYPOINT is the gitleaks binary; override explicitly. ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"] diff --git a/tests/integration/test_git_gate_sidecar.py b/tests/integration/test_git_gate_sidecar.py new file mode 100644 index 0000000..5c1b533 --- /dev/null +++ b/tests/integration/test_git_gate_sidecar.py @@ -0,0 +1,205 @@ +"""Integration: per-agent git-gate sidecar (PRD 0008). + +Two tests against a real Docker daemon: + + 1. A freshly-started gate answers ls-remote requests on its + internal-network address. Proves the daemon is up and the + bare repos rendered by the entrypoint are exported. + 2. A push containing a gitleaks-detectable secret is rejected + by the pre-receive hook with a non-zero exit on the agent + side and a gitleaks-rejection line in the response. This is + the PRD's success criterion. + +A successful clean-push roundtrip needs a real upstream SSH host; +deferred to a follow-up integration test. +""" + +import dataclasses +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from claude_bottle.backend.docker.git_gate import ( + DockerGitGate, + build_git_gate_image, +) +from claude_bottle.backend.docker.network import ( + network_create_egress, + network_create_internal, + network_remove, +) +from claude_bottle.manifest import Manifest +from tests._docker import skip_unless_docker + +# The official gitleaks image already has git + alpine; reusing it +# for the client side too saves a separate image pull. +CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f" + +# Synthetic high-entropy AKIA-shaped string; gitleaks's aws-access-token +# rule fires on this with the default config. AWS's own example +# ("AKIAIOSFODNN7EXAMPLE") is NOT flagged by gitleaks v8.x — entropy +# filter rejects it — so we use a distinct random-looking value. +FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL" + + +@skip_unless_docker() +class TestGitGateSidecar(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Pre-pull the client/gitleaks base so per-test runs aren't + # racing the registry. Skip cleanly on pull failure (a real + # outage is out of scope here). + result = subprocess.run( + ["docker", "pull", CLIENT_IMAGE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + if result.returncode != 0: + raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}") + # Build the gate image once for the class. Layer cache makes + # repeated runs cheap. + build_git_gate_image() + + def setUp(self): + # DNS hostnames on user-defined Docker networks max out at 63 + # chars per label (RFC 1035). The full container name is + # `claude-bottle-git-gate-` = 23 + len(slug), so the slug + # has to stay under ~40 to be resolvable. Keep it short. + suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:] + self.slug = f"t{os.getpid()}-{suffix}" + self.gate_name = "" + self.internal_net = "" + self.egress_net = "" + self.work_dir = Path(tempfile.mkdtemp()) + + def tearDown(self): + if self.gate_name: + DockerGitGate().stop(self.gate_name) + for n in (self.internal_net, self.egress_net): + if n: + network_remove(n) + shutil.rmtree(self.work_dir, ignore_errors=True) + + def _start_gate(self, name: str = "foo") -> str: + """Build a one-upstream gate and bring it up. Returns the + container name (== git-gate hostname on the internal net).""" + # Contents of the fake key don't matter for these tests — the + # rejection-path hook never reaches phase 2 where it would be + # used, and ls-remote doesn't push. + fake_key = self.work_dir / "fake-key" + fake_key.write_text("not-a-real-key\n") + + manifest = Manifest.from_json_obj({ + "bottles": { + "dev": { + "git": [{ + "Name": name, + "Upstream": "ssh://git@upstream.invalid/path.git", + "IdentityFile": str(fake_key), + "KnownHostKey": "ssh-ed25519 AAAAEXAMPLE", + }], + }, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + bottle = manifest.bottles["dev"] + + gate = DockerGitGate() + prep = gate.prepare(bottle, self.slug, self.work_dir) + + self.internal_net = network_create_internal(self.slug) + self.egress_net = network_create_egress(self.slug) + plan = dataclasses.replace( + prep, + internal_network=self.internal_net, + egress_network=self.egress_net, + ) + self.gate_name = gate.start(plan) + return self.gate_name + + @unittest.skipIf( + os.environ.get("GITEA_ACTIONS") == "true", + "skipped under act_runner: docker socket mount topology breaks " + "in-process visibility of networks created on the host daemon", + ) + def test_ls_remote_succeeds_against_fresh_gate(self): + """A freshly-started gate has an empty bare repo per upstream; + `git ls-remote` returns no refs and exits 0. Probes the gate + from a sibling container on the same internal network — same + access topology the agent uses in production.""" + gate = self._start_gate("foo") + # git ls-remote retries weren't strictly needed in local runs, + # but the daemon takes a beat to bind after docker start. + probe = subprocess.run( + ["docker", "run", "--rm", + "--network", self.internal_net, + "--entrypoint", "sh", + CLIENT_IMAGE, + "-c", + f"for i in $(seq 1 15); do " + f" git ls-remote git://{gate}/foo.git >/tmp/out 2>&1 && exit 0;" + f" sleep 1;" + f"done;" + f"cat /tmp/out; exit 1"], + capture_output=True, text=True, timeout=60, check=False, + ) + self.assertEqual( + 0, probe.returncode, + f"ls-remote failed: stdout={probe.stdout!r} stderr={probe.stderr!r}", + ) + + @unittest.skipIf( + os.environ.get("GITEA_ACTIONS") == "true", + "skipped under act_runner: docker socket mount topology breaks " + "in-process visibility of networks created on the host daemon", + ) + def test_push_with_secret_is_rejected(self): + """The PRD 0008 success criterion: a push containing a + gitleaks-detectable secret is rejected; the hook's "gitleaks + rejected" line appears in the response, and git push exits + non-zero on the client side.""" + gate = self._start_gate("foo") + push_script = ( + "set -e\n" + "cd /tmp\n" + # Wait for git daemon to bind. ls-remote retries until + # connection works; we then assume the gate is ready. + f"for i in $(seq 1 15); do " + f" git ls-remote git://{gate}/foo.git >/dev/null 2>&1 && break;" + f" sleep 1;" + f"done\n" + "git init -q -b main repo\n" + "cd repo\n" + "git config user.email test@example.com\n" + "git config user.name test\n" + f"echo '{FAKE_AWS_KEY}' > leak.txt\n" + "git add leak.txt\n" + "git commit -q -m leak\n" + f"git push git://{gate}/foo.git main 2>&1\n" + ) + result = subprocess.run( + ["docker", "run", "--rm", + "--network", self.internal_net, + "--entrypoint", "sh", + CLIENT_IMAGE, + "-c", push_script], + capture_output=True, text=True, timeout=120, check=False, + ) + combined = result.stdout + result.stderr + self.assertNotEqual( + 0, result.returncode, + f"expected push to fail; output={combined!r}", + ) + # Hook's stderr is delivered to the client via the `remote:` + # prefix during a git push. Either token is enough to prove + # the pre-receive hook ran and rejected the push. + self.assertTrue( + "gitleaks rejected" in combined or "leaks found" in combined, + f"expected a gitleaks rejection in the response; got: {combined!r}", + ) + + +if __name__ == "__main__": + unittest.main()