docs(prds): expand PRD 0008 to bidirectional mirror scope
test / unit (pull_request) Successful in 11s
test / integration (pull_request) Successful in 18s

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.
This commit is contained in:
2026-05-12 21:26:19 -04:00
parent bea433015f
commit ae7e22065f
+69 -39
View File
@@ -6,11 +6,18 @@
## Summary ## Summary
Per-bottle sidecar that fronts the agent's git remotes, runs Per-bottle sidecar that fronts the agent's git remotes as a
gitleaks against incoming refs via a `pre-receive` hook, and only transparent mirror. Push is gated: gitleaks scans incoming refs
forwards to the real upstream on a clean scan. Upstream push via a `pre-receive` hook, and only clean refs get forwarded to
credentials live in the gate, not the agent — so a misbehaving the real upstream. Fetch is mirrored: every `upload-pack` first
agent cannot push a secret-bearing commit past it. runs `git fetch origin --prune` against the upstream via the
daemon's `--access-hook`, so an agent fetch returns whatever the
upstream has *now* (fail-closed if the upstream is unreachable).
Upstream credentials live in the gate, not the agent — so a
misbehaving agent cannot push a secret-bearing commit past it
and cannot acquire push access by inspecting the agent's own
filesystem.
## Problem ## Problem
@@ -30,12 +37,19 @@ the agent can disable is not a gate.
## Goals / Success Criteria ## Goals / Success Criteria
Integration test: spin up a bottle whose only push path for a Two integration tests, both with the gate as the only git path
declared upstream is the gate. Drop a synthetic high-entropy for a declared upstream:
secret into a commit, run `git push` from inside the agent,
observe a non-zero exit and a gitleaks finding in the gate's 1. **Push:** drop a synthetic high-entropy secret into a commit,
stderr. Repeat with a clean commit, observe exit 0 and the commit run `git push` from inside the agent, observe a non-zero exit
landing on the real upstream. and a gitleaks finding in the response. Repeat with a clean
commit and observe exit 0 + the commit landing on the real
upstream.
2. **Fetch:** clone the upstream through the gate (`git clone`
against the gate URL), observe the upstream's content. Push
a new commit to the upstream out-of-band, refetch through the
gate, observe the new commit. The gate must never serve stale
data — every fetch refreshes from upstream first.
## Non-goals ## Non-goals
@@ -71,12 +85,19 @@ landing on the real upstream.
uses to push upstream. The agent gets no parallel `bottle.ssh` uses to push upstream. The agent gets no parallel `bottle.ssh`
entry for those upstreams. entry for those upstreams.
- **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig` - **Agent-side URL rewrite.** Provisioner emits `~/.gitconfig`
with `[url "<gate-url>"] insteadOf = <real-url>` so `git push with `[url "<gate-url>"] insteadOf = <real-url>` so every git
origin` from inside the agent transparently hits the gate. operation against the declared upstream (push, fetch, clone,
pull, ls-remote) transparently hits the gate.
- **Pre-receive gitleaks hook.** Baked into the gate image. On a - **Pre-receive gitleaks hook.** Baked into the gate image. On a
hit the hook exits non-zero and the push fails; on clean it hit the hook exits non-zero and the push fails; on clean it
shells out `git push <upstream> <ref>:<ref>` using the shells out `git push origin <ref>:<ref>` using the gate-resident
gate-resident credential. credential.
- **Access-hook upstream refresh.** `git daemon --access-hook` runs
`git fetch origin --prune` against the upstream before every
`upload-pack` request, so a fetch through the gate is observably
equivalent to a fetch against the real upstream. Failure to reach
the upstream is fail-closed: the access hook exits non-zero and
the agent's fetch fails.
- **Plan rendering / dry-run.** `bottle_plan.py` and the y/N - **Plan rendering / dry-run.** `bottle_plan.py` and the y/N
preflight surface the gate sidecar (name, listed upstreams, preflight surface the gate sidecar (name, listed upstreams,
which credential it holds per upstream). which credential it holds per upstream).
@@ -86,10 +107,10 @@ landing on the real upstream.
- Push policy beyond gitleaks. No commit-author allowlist, no - Push policy beyond gitleaks. No commit-author allowlist, no
branch-name policy, no signed-commit enforcement. gitleaks is branch-name policy, no signed-commit enforcement. gitleaks is
the single rule for v1. the single rule for v1.
- Fetch routing. Fetch can continue going through ssh-gate as - Fetch caching / stale-while-revalidate. Every `upload-pack`
today, with the agent holding a read-scoped deploy key. Routing refresh is a synchronous round-trip to the upstream; there is
fetch through the git-gate is a follow-up; this PRD is no TTL cache, no background refresh. If the upstream is slow,
push-side only. (Open question: revisit.) the agent's fetch is slow.
- Quarantine / replay. A rejected push is discarded; we do not - Quarantine / replay. A rejected push is discarded; we do not
stash it for the user to inspect. stash it for the user to inspect.
- Non-Docker backends. Implementation lands for Docker only; the - Non-Docker backends. Implementation lands for Docker only; the
@@ -116,20 +137,34 @@ Mirror the existing sidecar layout:
`stop` is idempotent `docker rm -f`. Container name: `stop` is idempotent `docker rm -f`. Container name:
`claude-bottle-git-gate-<slug>`. `claude-bottle-git-gate-<slug>`.
Gate image: a minimal `git` + `gitleaks` + `openssh-server` Gate image: `git-daemon` + `openssh-client` over a
image, pinned by digest (declared next to `PIPELOCK_IMAGE` and `zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
the socat image constant). For each declared upstream the gate For each declared upstream the gate hosts a bare repo at
hosts a bare repo at a stable local path (`/git/<name>.git`) `/git/<name>.git` with `remote.origin.url` set to the real
with `hooks/pre-receive` wired to gitleaks. On a clean scan the upstream (via `git remote add --mirror=fetch`), `hooks/pre-receive`
hook (or a `post-receive` companion) does `git push <upstream> wired to gitleaks-then-`git push origin`, and the bare repo's
<ref>:<ref>` using the credential the gate holds for that config carrying per-upstream credential paths.
upstream.
Inside the bottle, the agent's `.gitconfig` rewrites the real Inside the bottle, the agent's `.gitconfig` rewrites the real
upstream URL to the gate's local URL via `insteadOf`. A `git upstream URL to the gate's `git://` URL via `insteadOf`. Every
push origin main` therefore pushes to the gate; the gate scans; git operation against the declared upstream therefore hits the
on success the gate pushes to the real upstream. The agent never gate.
sees the upstream push credential.
For pushes, the pre-receive hook gitleaks-scans the incoming
refs and, on clean, pushes each accepted ref to the real
upstream using the credential the gate holds.
For fetches (clone, pull, fetch, ls-remote), `git daemon`'s
`--access-hook=<path>` runs `git fetch origin --prune` against
the real upstream before the upload-pack service serves the
client. The bare repo therefore reflects the upstream's current
state at the moment the agent's fetch begins; if the upstream
is unreachable, the access hook exits non-zero and the agent's
fetch fails — same observable behavior as if the agent were
talking to the upstream directly.
The agent never sees the upstream credential under either
operation.
### Existing code touched ### Existing code touched
@@ -167,17 +202,12 @@ exposes it as, and the credential the gate uses to push upstream
### External dependencies ### External dependencies
- A minimal `git` + `gitleaks` + `openssh-server` image, pinned - `zricethezav/gitleaks` base image, pinned by digest. The base
by digest. ships gitleaks + git; the gate Dockerfile adds `git-daemon` and
- `gitleaks` binary, version pinned in the image build. `openssh-client` on top.
- No new Python packages. - No new Python packages.
## Future work ## Future work
- **Fetch through the gate.** A v2 could route fetch through the
gate too, so the agent holds no upstream credentials at all.
Today fetch falls back to ssh-gate; pushing through git-gate
alone is the v1 win.
- **Smolmachines colocation.** The eventual smolmachines backend - **Smolmachines colocation.** The eventual smolmachines backend
may pack pipelock + ssh-gate + git-gate into a single microVM, may pack pipelock + ssh-gate + git-gate into a single microVM,
or split git-gate off because it holds push creds and the or split git-gate off because it holds push creds and the