Files
bot-bottle/docs/prds/0009-remove-ssh-gate.md
T

8.4 KiB

PRD 0009: Remove ssh-gate and bottle.ssh

  • Status: Active
  • 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.ssh is no longer a valid manifest field. Manifests carrying it parse-fail with an error message pointing at bottle.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 SshEntry or bottle.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 a Name and full upstream URL that bottle.ssh does 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 in Bottle.from_dict that dies on a ssh key with a one-line "move this to bottle.git (see PRD 0008)" hint.
  • Sidecar. Delete bot_bottle/ssh_gate.py and bot_bottle/backend/docker/ssh_gate.py. Drop the socat image build path.
  • Provisioner. Delete bot_bottle/backend/docker/provision/ssh.py and its ~/.ssh/config render.
  • Docker backend wiring. Drop DockerSSHGate from backend.py; drop its start / stop from launch.py's ExitStack; drop the plan field from bottle_plan.py.
  • Pipelock interaction. Drop the SSH-derived branch from pipelock's ssrf.ip_allowlist build. With no bottle.ssh there is no per-upstream IP carve-out to render; git-gate has its own egress network.
  • Tests. Delete the ssh-gate unit + integration suites, the ssh fixtures in tests/fixtures.py, and the shadow-route assertions in test_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: from bot-bottle.example.json.
  • PRD 0007. Add a Status: Superseded by PRD 0009 header 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 ssh entry, add a git entry pointing at the same upstream with a Name and 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:

  1. Manifest parse-error shape. Bottle.from_dict adds an explicit if "ssh" in d: branch that dies with a hint naming bottle.git and PRD 0008. Better than silent ignore — the migration is visible and one-shot — and better than a warning, which agents wouldn't see anyway.
  2. Pipelock allowlist build. Today pipelock pulls SSH upstream IPs into its ssrf.ip_allowlist so ssh-gate can dial out. With ssh-gate gone, that branch goes away; verify nothing else in the pipelock render touches bottle.ssh.
  3. Plan rendering. bottle_plan.py and the y/N preflight currently list the socat sidecar. Remove the field and any rendering branch; the dry-run output simplifies.
  4. 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 — delete SshEntry, 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 — drop DockerSSHGate instantiation.
  • bot_bottle/backend/docker/launch.py — drop the ssh-gate start / stop from the ExitStack.
  • bot_bottle/backend/docker/bottle_plan.py — drop the ssh-gate plan field.
  • bot_bottle/pipelock.py — drop the bottle.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 — drop fixture_with_ssh and its dict helper.
  • README.md — drop the socat image box from the diagram and the matching bullet; drop ssh: from the manifest example.
  • bot-bottle.example.json — drop the ssh field.
  • docs/prds/0007-ssh-egress-gate.md — add a Status: Superseded by PRD 0009 header at the top.

Data model changes

  • SshEntry removed.
  • Bottle.ssh: tuple[SshEntry, ...] = () removed.
  • _validate_no_shadow_route removed.

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 to bottle.git" hint. Migrating in-place is brittle — the entry has no Name field 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.