PRD 0008: Git gate #11

Merged
didericis merged 13 commits from git-gate into main 2026-05-12 23:16:45 -04:00
Owner

Summary

Adds a per-bottle git-gate sidecar that fronts the agent's declared git upstreams as a bidirectional mirror. Push is gated: a pre-receive hook runs gitleaks against incoming refs and, on clean, forwards each ref to the real upstream using a credential the gate holds. Fetch is mirrored: git daemon's --access-hook runs git fetch origin --prune against the upstream before every upload-pack, so an agent fetch returns whatever the upstream has now (fail-closed if the upstream is unreachable). The agent never holds the upstream credential — a misbehaving agent cannot push a secret-bearing commit past the gate, and cannot acquire push access by reading its own filesystem.

The agent-side rewrite is one [url "git://<gate>/<name>.git"] insteadOf = <real-url> block in ~/.gitconfig, so push, fetch, clone, pull, and ls-remote all route through the gate transparently. The three sidecars keep distinct blast radii: ssh-gate is L4-dumb, pipelock terminates internet-facing TLS, and the git-gate is the only one that holds upstream push credentials. Smolmachines / microVM colocation is left to the backend.

bottle.git entries take an optional ExtraHosts: { hostname: ip } map the docker backend surfaces as --add-host on the gate sidecar. This is for upstreams whose default container DNS doesn't resolve to the reachable IP (e.g. a Tailscale-only host whose public A record points elsewhere): the gate's /etc/hosts gets the override while the agent's insteadOf rewrite still keys off the original hostname, so Upstream URLs in the manifest stay human-readable.

Covered by unit tests (manifest parsing + validation, script-render shape, ExtraHosts aggregator conflict detection) and integration tests against a real Docker daemon (fail-closed ls-remote, secret-in-commit push rejection, bidirectional round-trip against a sibling sshd-based upstream).

## Summary Adds a per-bottle git-gate sidecar that fronts the agent's declared git upstreams as a bidirectional mirror. Push is gated: a `pre-receive` hook runs gitleaks against incoming refs and, on clean, forwards each ref to the real upstream using a credential the gate holds. Fetch is mirrored: `git daemon`'s `--access-hook` runs `git fetch origin --prune` against the upstream before every `upload-pack`, so an agent fetch returns whatever the upstream has now (fail-closed if the upstream is unreachable). The agent never holds the upstream credential — a misbehaving agent cannot push a secret-bearing commit past the gate, and cannot acquire push access by reading its own filesystem. The agent-side rewrite is one `[url "git://<gate>/<name>.git"] insteadOf = <real-url>` block in `~/.gitconfig`, so push, fetch, clone, pull, and ls-remote all route through the gate transparently. The three sidecars keep distinct blast radii: ssh-gate is L4-dumb, pipelock terminates internet-facing TLS, and the git-gate is the only one that holds upstream push credentials. Smolmachines / microVM colocation is left to the backend. `bottle.git` entries take an optional `ExtraHosts: { hostname: ip }` map the docker backend surfaces as `--add-host` on the gate sidecar. This is for upstreams whose default container DNS doesn't resolve to the reachable IP (e.g. a Tailscale-only host whose public A record points elsewhere): the gate's `/etc/hosts` gets the override while the agent's `insteadOf` rewrite still keys off the original hostname, so `Upstream` URLs in the manifest stay human-readable. Covered by unit tests (manifest parsing + validation, script-render shape, ExtraHosts aggregator conflict detection) and integration tests against a real Docker daemon (fail-closed ls-remote, secret-in-commit push rejection, bidirectional round-trip against a sibling sshd-based upstream).
didericis added 1 commit 2026-05-12 18:25:17 -04:00
docs(prds): add PRD 0008 git gate
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
c91395425c
Per-bottle sidecar that fronts the agent's git remotes, runs
gitleaks via a pre-receive hook, and only forwards to the real
upstream on a clean scan. Upstream push credentials live in the
gate, not the agent — so a misbehaving agent cannot push a
secret-bearing commit past it.
didericis added 1 commit 2026-05-12 18:48:18 -04:00
feat(manifest): add bottle.git field for git-gate upstreams
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 15s
5c5e9f817e
Each entry pairs a Name (local alias the gate exposes) with an
ssh:// Upstream URL, an IdentityFile the gate uses to push to
that upstream, and an optional KnownHostKey for upstream
host-key pinning. The Upstream URL is parsed at construction
into UpstreamUser/Host/Port/Path so downstream code doesn't
re-parse.

Two cross-validation rules: Names must be unique within a
bottle (each maps to a distinct bare repo), and no git entry's
(host, port) may overlap an ssh entry's (Hostname, Port) — the
same upstream reachable two ways would let a misbehaving agent
route around the gitleaks-bearing git-gate via the L4 ssh-gate.

PRD: docs/prds/0008-git-gate.md
didericis added 1 commit 2026-05-12 20:54:43 -04:00
feat(git-gate): add platform-agnostic GitGate abstraction
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 17s
2fb90f2087
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
didericis added 1 commit 2026-05-12 20:58:57 -04:00
feat(git-gate): add DockerGitGate sidecar lifecycle + image
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 15s
2d955a5512
Dockerfile.git-gate builds a small alpine image with git,
openssh-client, and gitleaks; the directory layout the entrypoint
and per-upstream cp's expect is pre-created in the image so docker
cp can target paths beneath /etc/git-gate and /git-gate/creds at
container-create time (cp doesn't create intermediate dirs).

DockerGitGate.start mirrors DockerSSHGate's shape: build, create,
cp the rendered entrypoint + hook + per-upstream identity files
(plus a known_hosts file synthesized from KnownHostKey when set),
attach the egress network, start. build_image gains an optional
dockerfile= argument so the gate can build from its own
Dockerfile in the shared context.

PRD: docs/prds/0008-git-gate.md
didericis added 1 commit 2026-05-12 21:01:05 -04:00
feat(git-gate): provision ~/.gitconfig pushInsteadOf in the bottle
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 14s
509b1b61e2
provision_git now does two things: copy the host cwd's .git (when
--cwd is set, existing behavior) and write ~/.gitconfig with
pushInsteadOf rules for each bottle.git entry. A 'git push <real
upstream URL>' from inside the agent transparently rewrites to
'git://<gate>/<name>.git' so the gate gets first crack at the
incoming refs.

pushInsteadOf (not insteadOf) keeps fetch on the original URL —
v1 of the git-gate is push-only scope per PRD 0008. The render
helper is exposed for testing without docker.
didericis added 1 commit 2026-05-12 21:06:12 -04:00
feat(git-gate): wire DockerGitGate through prepare/launch/plan
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
f787edb861
DockerBottleBackend now instantiates a DockerGitGate alongside
DockerPipelockProxy and DockerSSHGate; the prepare step lifts
bottle.git into a GitGatePlan stored on DockerBottlePlan, and
launch starts/stops the sidecar in the same ExitStack as the
other two (only when bottle.git is non-empty).

bottle_plan.print now surfaces git remotes and per-upstream gate
forwards in the y/N preflight; to_dict adds git_remotes and
git_gate keys to the dry-run JSON payload for CLI consumers.
PRD: docs/prds/0008-git-gate.md
didericis added 1 commit 2026-05-12 21:17:46 -04:00
test(git-gate): integration smoke + secret-blocking push
test / unit (pull_request) Successful in 15s
test / integration (pull_request) Successful in 36s
89981f9048
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).
didericis added 1 commit 2026-05-12 21:19:26 -04:00
docs(readme): add git-gate to architecture diagram
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 17s
bea433015f
Bumps the sidecar count from two to up to three; the diagram and
bullet list now cover the git-gate alongside pipelock and ssh-gate,
including the ~/.gitconfig pushInsteadOf wiring that fires the
agent's git push through the gate.
didericis added 1 commit 2026-05-12 21:26:21 -04:00
docs(prds): expand PRD 0008 to bidirectional mirror scope
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 18s
ae7e22065f
The gate now fronts every git operation, not just push. Fetch
(clone, pull, ls-remote) is mirrored via git daemon's
--access-hook running 'git fetch origin --prune' against the
real upstream before each upload-pack; fail-closed if upstream
is unreachable so the agent never serves stale data.

Push path is unchanged in concept (gitleaks gate → forward) but
the hook now pushes to 'origin' rather than 'upstream', matching
the remote name the entrypoint configures.
didericis added 1 commit 2026-05-12 21:37:07 -04:00
feat(git-gate): mirror fetch through access-hook (bidirectional)
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 14s
fdd06c54d2
The gate is now a transparent mirror, not push-only. Per-repo
init now runs `git remote add --mirror=fetch origin <url>` so a
later `git fetch origin` mirrors the upstream's full ref graph at
canonical paths. The pre-receive hook forwards accepted refs via
`git push origin` (renamed from upstream).

New: an access-hook script wired via `git daemon --access-hook`
runs `git fetch origin --prune` against the real upstream before
every upload-pack request (clone, fetch, pull, ls-remote). On
upstream error the hook exits non-zero — the agent's fetch fails
rather than the gate serving stale data.

The pre-existing smoke test (ls-remote against unreachable
upstream returns refs) had to invert: under the bidirectional
design any ls-remote success is necessarily a success against
the upstream, so the unreachable-upstream case now correctly
fails closed.
didericis added 1 commit 2026-05-12 21:38:46 -04:00
feat(git-gate): rewrite both fetch and push via insteadOf
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 16s
824527497c
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.
didericis added 1 commit 2026-05-12 22:34:40 -04:00
test(git-gate): bidirectional mirror round-trip
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 34s
f9d9e9cf33
A pair of integration tests against a real sshd-based "upstream"
sibling container that prove every operation through the gate is
observably equivalent to the same operation against the upstream:

  - test_clone_and_refetch_reflect_upstream: clone via gate
    returns the upstream's current commit; an out-of-band commit
    on the upstream shows up via the gate on the next ls-remote.
  - test_push_through_gate_lands_on_upstream: a clean push routed
    through the gate lands on the upstream's bare repo.

The upstream container is a tiny inline-built alpine image with
openssh-server, a `git` user (passwd -u so sshd doesn't reject
the locked account), and a baked bare repo seeded with one
commit. Host keys are baked in at build so the test can pin
KnownHostKey on the manifest entry before the container starts.

While wiring this up the access-hook gained a one-shot HEAD
sync: `git init --bare` defaults HEAD to refs/heads/master, and
upstreams that use main would leave the bare repo's HEAD
unresolvable — clones came through but the working tree was
empty. The hook now does a `rev-parse --verify HEAD` check
after the first fetch and runs `ls-remote --symref` to repoint
HEAD if it doesn't resolve. One extra round-trip on first
fetch only.
didericis added 1 commit 2026-05-12 22:36:20 -04:00
docs(readme): git-gate is now a bidirectional mirror
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 21s
76a56c0700
Architecture diagram + bullet now reflect that the gate fronts
every git operation, not just push: pre-receive gitleaks-gates
the push path; an access-hook refreshes from upstream before each
upload-pack so fetch / clone / pull / ls-remote see whatever the
upstream has at that moment (fail-closed if unreachable).
didericis merged commit a37441961d into main 2026-05-12 23:16:45 -04:00
Sign in to join this conversation.