feat(git-gate): add platform-agnostic GitGate abstraction
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 17s

Mirrors the SSHGate/PipelockProxy shape: a host-side prepare that
lifts bottle.git into a tuple of GitGateUpstreams and renders two
shell scripts under stage_dir — the gate's entrypoint (which
initializes a bare repo per upstream and execs git daemon
--enable=receive-pack) and the shared pre-receive hook
(gitleaks-scan, then forward each accepted ref to the real
upstream using the per-repo credential).

Failure in either hook phase aborts the push so the agent sees a
real rejection, not a silent success. KnownHostKey absence is
fail-closed: the hook refuses to forward without a pinned key
rather than TOFU-trusting the upstream from inside the gate.

PRD: docs/prds/0008-git-gate.md
This commit is contained in:
2026-05-12 20:54:38 -04:00
parent 5c5e9f817e
commit 2fb90f2087
2 changed files with 405 additions and 0 deletions
+252
View File
@@ -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://<gate>/<name>.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/<name>.git`), the agent's URL after insteadOf rewrite
(`git://<gate>/<name>.git`), and the per-upstream credential
paths inside the gate (`/git-gate/creds/<name>-key` and
`/git-gate/creds/<name>-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/<name>-{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: <old> <new> <ref> 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."""
+153
View File
@@ -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()