Assisted-by: Codex
8.4 KiB
PRD 0009: Remove ssh-gate and bottle.ssh
- Status: Draft
- Author: didericis
- Created: 2026-05-13
Summary
Delete the ssh-gate sidecar and the bottle.ssh manifest field.
Git-gate (PRD 0008) covers every current SSH use case in
bot-bottle: each declared upstream gets a per-bottle gate
with gitleaks scanning, an insteadOf rewrite that captures
push / fetch / clone / pull / ls-remote, and credential
isolation from the agent. ssh-gate is now redundant L4
forwarding with no gating value, and the only remaining users
of bottle.ssh were git remotes that git-gate handles better.
Problem
PRD 0007 introduced ssh-gate as an L4 SSH forwarder so an agent could reach declared SSH upstreams without a default route off-box. At the time that meant git. With PRD 0008 every git upstream now flows through git-gate, which:
- Holds the upstream credential in the gate, not in the agent
- Runs gitleaks against every incoming ref
- Refreshes from upstream on every fetch (fail-closed when the upstream is unreachable)
- Rewrites the agent's URLs via
insteadOf, so push, fetch, clone, pull, and ls-remote all route through the gate
ssh-gate does none of those — it's transport-only — and offers
no benefit over git-gate for git, which is the only upstream
class currently used. Carrying it means a dead manifest field,
a redundant sidecar lifecycle, a shadow-route validator between
bottle.ssh and bottle.git, and a third place to keep an SSH
identity in sync. The project's stated goal is minimum
credentials and minimum egress; the simpler answer is to drop
the unused path.
Goals / Success Criteria
bottle.sshis no longer a valid manifest field. Manifests carrying it parse-fail with an error message pointing atbottle.git.SSHGate/DockerSSHGate,provision_ssh, and the socat sidecar lifecycle are gone from the codebase.- Unit + integration tests pass without the ssh-gate suites;
no test references
SshEntryorbottle.ssh. - The y/N preflight no longer mentions the ssh sidecar; the
README's architecture diagram drops the socat box; the
example manifest drops
ssh:. - PRD 0007 carries a "Superseded by PRD 0009" header so the history of intent stays in the tree.
Non-goals
- Removing pipelock or git-gate. Both keep their current roles.
- Removing the SSH egress capability in general — git-gate uses SSH inside the gate to reach the real upstream.
- Building a generic L4 egress proxy as a replacement. If non-git SSH ever returns, design a fresh gate for the use case; don't resurrect ssh-gate's L4-only design.
- Preserving a "ssh: []" compatibility shim. If the field is dead it should error, not silently parse.
- Auto-migrating user manifests. The replacement (
bottle.git) needs aNameand full upstream URL thatbottle.sshdoes not carry; a hard error with a migration hint is cleaner than a partial rewrite.
Scope
In scope
- Manifest. Delete
SshEntry,Bottle.ssh, and_validate_no_shadow_route. Add an explicit branch inBottle.from_dictthat dies on asshkey with a one-line "move this tobottle.git(see PRD 0008)" hint. - Sidecar. Delete
bot_bottle/ssh_gate.pyandbot_bottle/backend/docker/ssh_gate.py. Drop the socat image build path. - Provisioner. Delete
bot_bottle/backend/docker/provision/ssh.pyand its~/.ssh/configrender. - Docker backend wiring. Drop
DockerSSHGatefrombackend.py; drop its start / stop fromlaunch.py'sExitStack; drop the plan field frombottle_plan.py. - Pipelock interaction. Drop the SSH-derived branch from
pipelock's
ssrf.ip_allowlistbuild. With nobottle.sshthere is no per-upstream IP carve-out to render; git-gate has its own egress network and pulls in upstream resolution viaExtraHostsplus DNS. - Tests. Delete the ssh-gate unit + integration suites,
the ssh fixtures in
tests/fixtures.py, and the shadow-route assertions intest_manifest_git.py. Adjust any tests that asserted on a "git + ssh" combined manifest to be git-only. - README. Drop the socat / ssh image box from the
architecture diagram and its bullet; drop
ssh:from the manifest example. - Example manifest. Drop
ssh:frombot-bottle.example.json. - PRD 0007. Add a
Status: Superseded by PRD 0009header at the top of the document. Do not delete the file; the history of intent matters for the audit trail. - Migration note. A short paragraph in the README explaining
the swap (drop the
sshentry, add agitentry pointing at the same upstream with aNameand full URL).
Out of scope
- A generic non-git SSH gate. If/when the use case returns, design a fresh sidecar with its own threat model.
- Migrating user manifests automatically. Parse-fail with a clear error is the path.
- Reworking git-gate to absorb non-git protocols. git-gate remains git-only.
Proposed Design
Most of the work is deletion. The substantive decisions are at the seams between ssh-gate and the rest of the system:
- Manifest parse-error shape.
Bottle.from_dictadds an explicitif "ssh" in d:branch that dies with a hint namingbottle.gitand PRD 0008. Better than silent ignore — the migration is visible and one-shot — and better than a warning, which agents wouldn't see anyway. - Pipelock allowlist build. Today pipelock pulls SSH
upstream IPs into its
ssrf.ip_allowlistso ssh-gate can dial out. With ssh-gate gone, that branch goes away; verify nothing else in the pipelock render touchesbottle.ssh. - Plan rendering.
bottle_plan.pyand the y/N preflight currently list the socat sidecar. Remove the field and any rendering branch; the dry-run output simplifies. - PRD 0007 marker. A header line at the top, not a delete. The PRD's rationale (why ssh-gate was built) is still useful context when reading PRD 0008 and PRD 0009.
Existing code touched
bot_bottle/manifest.py— deleteSshEntry,Bottle.ssh,_validate_no_shadow_route; add the parse-fail branch.bot_bottle/ssh_gate.py— delete.bot_bottle/backend/docker/ssh_gate.py— delete.bot_bottle/backend/docker/provision/ssh.py— delete.bot_bottle/backend/docker/backend.py— dropDockerSSHGateinstantiation.bot_bottle/backend/docker/launch.py— drop the ssh-gate start / stop from theExitStack.bot_bottle/backend/docker/bottle_plan.py— drop the ssh-gate plan field.bot_bottle/pipelock.py— drop thebottle.ssh-derived branch in the allowlist render.tests/unit/test_ssh_gate.py— delete.tests/integration/— delete any ssh-gate-specific tests.tests/unit/test_manifest_git.py— drop the shadow-route assertions.tests/fixtures.py— dropfixture_with_sshand its dict helper.README.md— drop the socat image box from the diagram and the matching bullet; dropssh:from the manifest example.bot-bottle.example.json— drop thesshfield.docs/prds/0007-ssh-egress-gate.md— add aStatus: Superseded by PRD 0009header at the top.
Data model changes
SshEntryremoved.Bottle.ssh: tuple[SshEntry, ...] = ()removed._validate_no_shadow_routeremoved.
External dependencies
Nothing added. The alpine/socat image is no longer pulled
by bot-bottle; the cleanup of any existing local image is
the user's choice (a single docker image rm if they care).
Future work
If a non-git SSH use case returns (deployment, rsync, remote management), build it as its own gate with its own threat model. Don't resurrect ssh-gate's L4-only design. The git-gate pattern (gate holds credentials, agent gets a rewritten URL, gate makes the upstream connection) is the template.
Open questions
- Error vs migrate on legacy
bottle.ssh. Default: hard error with a one-line "move this tobottle.git" hint. Migrating in-place is brittle — the entry has noNamefield and no upstream URL, so we'd be inventing both. - One PR or two. The removal touches enough files that a single big PR may be hard to review; splitting into "deprecate manifest field" and "remove sidecar code" reads cleaner, but the field can't be removed cleanly while the sidecar still references it. Default: single PR, deletion commit per layer (manifest, sidecar, provisioner, tests, docs).
References
- PRD 0007: SSH egress gate — the design being superseded.
- PRD 0008: Git gate — the design that subsumes ssh-gate's only current use case.