diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py new file mode 100644 index 0000000..7e49ee8 --- /dev/null +++ b/claude_bottle/git_gate.py @@ -0,0 +1,252 @@ +"""Per-agent git-gate (PRD 0008). + +A third per-agent sidecar that fronts the bottle's declared git +upstreams. Each `bottle.git` entry maps to a bare repo on the gate; +the gate runs `git daemon --enable=receive-pack` so the agent can +push to it via `git:///.git`. A pre-receive hook scans +the incoming refs with gitleaks; on clean, it forwards the refs to +the real upstream using a credential the gate holds. + +Why a third sidecar (not folded into pipelock or ssh-gate): the +gate is the only one of the three that holds upstream push +credentials. Mixing it with pipelock would put push creds in the +same blast radius as internet-facing TLS interception; mixing it +with ssh-gate would force ssh-gate above L4 and into git-protocol +land. See `docs/prds/0008-git-gate.md`. + +This module defines the abstract gate (`GitGate`) and its plan +dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is +backend-specific and lives on concrete subclasses (see +`claude_bottle/backend/docker/git_gate.py`).""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + +from .manifest import Bottle + + +@dataclass(frozen=True) +class GitGateUpstream: + """One bare repo on the gate. `name` drives the bare-repo path + (`/git/.git`), the agent's URL after insteadOf rewrite + (`git:///.git`), and the per-upstream credential + paths inside the gate (`/git-gate/creds/-key` and + `/git-gate/creds/-known_hosts`). + + `identity_file` is the host-side absolute path the gate's start + step will docker-cp into the container. `known_host_key` is the + KnownHostKey string from the manifest; the gate's start step + materialises it into a known_hosts file if non-empty.""" + + name: str + upstream_url: str + upstream_host: str + upstream_port: str + identity_file: str + known_host_key: str + + +@dataclass(frozen=True) +class GitGatePlan: + """Output of GitGate.prepare; consumed by .start. + + `upstreams` + `slug` + `entrypoint_script` + `hook_script` are + filled in at prepare time (host-side, side-effect-free on docker). + The network fields are populated by the backend's launch step via + `dataclasses.replace` once those networks exist. Empty defaults + are sentinels meaning "not yet set"; `.start` validates that + they are populated.""" + + slug: str + entrypoint_script: Path + hook_script: Path + upstreams: tuple[GitGateUpstream, ...] + internal_network: str = "" + egress_network: str = "" + + +def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]: + """Lift each `bottle.git` entry into a GitGateUpstream. Cross-entry + validation (unique Names, no shadow route with bottle.ssh) already + ran in `manifest.Bottle.from_dict`.""" + return tuple( + GitGateUpstream( + name=e.Name, + upstream_url=e.Upstream, + upstream_host=e.UpstreamHost, + upstream_port=e.UpstreamPort, + identity_file=e.IdentityFile, + known_host_key=e.KnownHostKey, + ) + for e in bottle.git + ) + + +def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: + """Format `host[:port] key` for OpenSSH's known_hosts. Non-default + ports use the bracketed `[host]:port` form (the form OpenSSH writes + on disk for hosts reached via a non-22 port).""" + if port and port != "22": + target = f"[{host}]:{port}" + else: + target = host + return f"{target} {key}\n" + + +def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: + """Posix-sh entrypoint (alpine ash). One `init_repo` call per + upstream, then `exec git daemon`. The function reads + `/git-gate/creds/-{key,known_hosts}` (laid down by + `DockerGitGate.start` via docker cp) and wires them into each + bare repo's config so the shared pre-receive hook can pick them + up at push time.""" + lines = [ + "#!/bin/sh", + "set -eu", + "", + "init_repo() {", + " name=$1", + " upstream_url=$2", + " keyfile=/git-gate/creds/${name}-key", + " hostsfile=/git-gate/creds/${name}-known_hosts", + "", + " chmod 600 \"$keyfile\"", + " if [ -f \"$hostsfile\" ]; then", + " chmod 600 \"$hostsfile\"", + " fi", + "", + " repo=/git/${name}.git", + " if [ ! -d \"$repo\" ]; then", + " git init --bare \"$repo\" >/dev/null", + " fi", + " git -C \"$repo\" config remote.upstream.url \"$upstream_url\"", + " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", + " git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"", + " git -C \"$repo\" config receive.denyCurrentBranch ignore", + " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", + "}", + "", + "mkdir -p /git", + ] + for u in upstreams: + # Single-quote args so URL/path content (containing : and /) + # passes through ash unmangled. Names came through the manifest + # validator so they don't contain a single quote. + lines.append(f"init_repo '{u.name}' '{u.upstream_url}'") + lines.extend([ + "", + "exec git daemon \\", + " --reuseaddr \\", + " --base-path=/git \\", + " --export-all \\", + " --enable=receive-pack \\", + " --verbose", + ]) + return "\n".join(lines) + "\n" + + +def git_gate_render_hook() -> str: + """The shared pre-receive hook: gitleaks-scan all incoming refs, + then forward each accepted ref to the real upstream using the + per-repo credential. Failure in either phase aborts the push so + the agent sees a real rejection. POSIX sh. + + Two phases (scan all, then push all) keeps a hit on ref N from + half-pushing refs 1..N-1; both phases re-read stdin from a temp + file because pre-receive's stdin is a one-shot stream.""" + return r"""#!/bin/sh +# git-gate pre-receive (PRD 0008). Stdin: per line. +set -u + +refs_file=$(mktemp) +trap 'rm -f "$refs_file"' EXIT +cat > "$refs_file" + +zero=0000000000000000000000000000000000000000 + +# Phase 1: gitleaks scan each ref's incoming commits. +while IFS=' ' read -r old new ref; do + [ -z "$ref" ] && continue + [ "$new" = "$zero" ] && continue + if [ "$old" = "$zero" ]; then + log_opts="$new" + else + log_opts="$old..$new" + fi + echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2 + if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then + echo "git-gate: gitleaks rejected push to $ref" >&2 + exit 1 + fi +done < "$refs_file" + +# Phase 2: forward each ref to the upstream. +keyfile=$(git config --get git-gate.identityFile) +hostsfile=$(git config --get git-gate.knownHosts) +if [ ! -f "$hostsfile" ]; then + echo "git-gate: no KnownHostKey configured for this upstream; refusing to push" >&2 + echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2 + exit 1 +fi +ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes" + +while IFS=' ' read -r old new ref; do + [ -z "$ref" ] && continue + if [ "$new" = "$zero" ]; then + refspec=":$ref" + else + refspec="$new:$ref" + fi + echo "git-gate: forwarding $ref to upstream" >&2 + if ! GIT_SSH_COMMAND="$ssh_cmd" git push upstream "$refspec" 1>&2; then + echo "git-gate: upstream push failed for $ref" >&2 + exit 1 + fi +done < "$refs_file" + +exit 0 +""" + + +class GitGate(ABC): + """The per-agent git-gate. Encapsulates the host-side prepare + (upstream lift + entrypoint/hook render); the sidecar's + start/stop lifecycle is backend-specific and lives on concrete + subclasses.""" + + def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> GitGatePlan: + """Compute the upstream table from `bottle.git` and write the + entrypoint + pre-receive scripts (mode 600) under `stage_dir`. + Pure host-side, no docker subprocess. + + Returned plan is incomplete: the launch step must fill + `internal_network` / `egress_network` via `dataclasses.replace` + before passing the plan to `.start`.""" + upstreams = git_gate_upstreams_for_bottle(bottle) + entrypoint = stage_dir / "git_gate_entrypoint.sh" + entrypoint.write_text(git_gate_render_entrypoint(upstreams)) + entrypoint.chmod(0o600) + hook = stage_dir / "git_gate_pre_receive.sh" + hook.write_text(git_gate_render_hook()) + hook.chmod(0o600) + return GitGatePlan( + slug=slug, + entrypoint_script=entrypoint, + hook_script=hook, + upstreams=upstreams, + ) + + @abstractmethod + def start(self, plan: GitGatePlan) -> str: + """Bring up the gate sidecar according to `plan`. Returns the + target string identifying the running instance — the same + value to pass to `.stop`. Backend-specific.""" + + @abstractmethod + def stop(self, target: str) -> None: + """Tear down the gate sidecar identified by `target` (the + value `.start` returned). Idempotent: a missing target is + success. Backend-specific.""" diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py new file mode 100644 index 0000000..5dc8d8b --- /dev/null +++ b/tests/unit/test_git_gate.py @@ -0,0 +1,153 @@ +"""Unit: GitGate prepare shape + entrypoint/hook render (PRD 0008).""" + +import os +import tempfile +import unittest +from pathlib import Path + +from claude_bottle.git_gate import ( + GitGate, + GitGatePlan, + GitGateUpstream, + git_gate_known_hosts_line, + git_gate_render_entrypoint, + git_gate_render_hook, + git_gate_upstreams_for_bottle, +) +from tests.fixtures import fixture_minimal, fixture_with_git + + +class _StubGate(GitGate): + def start(self, plan: GitGatePlan) -> str: + raise NotImplementedError + + def stop(self, target: str) -> None: + raise NotImplementedError + + +class TestUpstreamsForBottle(unittest.TestCase): + def test_one_upstream_per_git_entry(self): + bottle = fixture_with_git().bottles["dev"] + ups = git_gate_upstreams_for_bottle(bottle) + self.assertEqual(2, len(ups)) + self.assertEqual("claude-bottle", ups[0].name) + self.assertEqual("gitea.dideric.is", ups[0].upstream_host) + self.assertEqual("30009", ups[0].upstream_port) + self.assertEqual("foo", ups[1].name) + self.assertEqual("github.com", ups[1].upstream_host) + self.assertEqual("22", ups[1].upstream_port) + + def test_empty_bottle_yields_empty_upstreams(self): + bottle = fixture_minimal().bottles["dev"] + self.assertEqual((), git_gate_upstreams_for_bottle(bottle)) + + +class TestKnownHostsLine(unittest.TestCase): + def test_default_port_unbracketed(self): + line = git_gate_known_hosts_line("github.com", "22", "ssh-ed25519 AAAA") + self.assertEqual("github.com ssh-ed25519 AAAA\n", line) + + def test_non_default_port_bracketed(self): + line = git_gate_known_hosts_line("gitea.dideric.is", "30009", "ssh-ed25519 AAAA") + self.assertEqual("[gitea.dideric.is]:30009 ssh-ed25519 AAAA\n", line) + + +class TestEntrypointRender(unittest.TestCase): + def test_one_init_repo_call_per_upstream(self): + ups = ( + GitGateUpstream( + name="claude-bottle", + upstream_url="ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", + upstream_host="gitea.dideric.is", + upstream_port="30009", + identity_file="/host/path/key", + known_host_key="ssh-ed25519 AAAA", + ), + GitGateUpstream( + name="foo", + upstream_url="ssh://git@github.com/didericis/foo.git", + upstream_host="github.com", + upstream_port="22", + identity_file="/host/path/key2", + known_host_key="", + ), + ) + script = git_gate_render_entrypoint(ups) + self.assertIn("#!/bin/sh", script) + self.assertIn( + "init_repo 'claude-bottle' " + "'ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git'", + script, + ) + self.assertIn( + "init_repo 'foo' 'ssh://git@github.com/didericis/foo.git'", + script, + ) + # Daemon line is what keeps PID 1 alive. + self.assertIn("exec git daemon", script) + self.assertIn("--enable=receive-pack", script) + self.assertIn("--base-path=/git", script) + + def test_empty_upstreams_still_execs_daemon(self): + # A no-upstream gate is a no-op for repos but the daemon still + # has to start so the entrypoint doesn't exit. + script = git_gate_render_entrypoint(()) + self.assertNotIn("init_repo '", script) + self.assertIn("exec git daemon", script) + + +class TestHookRender(unittest.TestCase): + def test_hook_has_two_phases(self): + hook = git_gate_render_hook() + # Phase 1: gitleaks. Phase 2: forward. + self.assertIn("gitleaks git", hook) + self.assertIn("git push upstream", hook) + # KnownHostKey absence is fail-closed. + self.assertIn("refusing to push", hook) + # Stdin is buffered to a tempfile so both phases can re-read. + self.assertIn("refs_file=$(mktemp)", hook) + + +class TestPrepare(unittest.TestCase): + def setUp(self): + self.stage = Path(tempfile.mkdtemp()) + + def tearDown(self): + import shutil + + shutil.rmtree(self.stage, ignore_errors=True) + + def test_prepare_writes_entrypoint_and_hook_mode_600(self): + plan = _StubGate().prepare( + fixture_with_git().bottles["dev"], "demo", self.stage + ) + self.assertEqual( + self.stage / "git_gate_entrypoint.sh", plan.entrypoint_script + ) + self.assertEqual( + self.stage / "git_gate_pre_receive.sh", plan.hook_script + ) + self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777) + self.assertEqual(0o600, os.stat(plan.hook_script).st_mode & 0o777) + + def test_prepare_plan_carries_upstreams_and_slug(self): + plan = _StubGate().prepare( + fixture_with_git().bottles["dev"], "demo", self.stage + ) + self.assertEqual("demo", plan.slug) + self.assertEqual(2, len(plan.upstreams)) + self.assertEqual("", plan.internal_network) + self.assertEqual("", plan.egress_network) + + def test_prepare_with_no_git_writes_minimal_script(self): + plan = _StubGate().prepare( + fixture_minimal().bottles["dev"], "demo", self.stage + ) + self.assertEqual((), plan.upstreams) + content = plan.entrypoint_script.read_text() + self.assertNotIn("init_repo '", content) + self.assertIn("exec git daemon", content) + + +if __name__ == "__main__": + unittest.main()