# 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 and pulls in upstream resolution
  via `ExtraHosts` plus DNS.
- **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.
