diff --git a/docs/prds/0009-remove-ssh-gate.md b/docs/prds/0009-remove-ssh-gate.md new file mode 100644 index 0000000..a85052e --- /dev/null +++ b/docs/prds/0009-remove-ssh-gate.md @@ -0,0 +1,206 @@ +# 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 +claude-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 `claude_bottle/ssh_gate.py` and + `claude_bottle/backend/docker/ssh_gate.py`. Drop the socat + image build path. +- **Provisioner.** Delete + `claude_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 `claude-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 + +- `claude_bottle/manifest.py` — delete `SshEntry`, + `Bottle.ssh`, `_validate_no_shadow_route`; add the + parse-fail branch. +- `claude_bottle/ssh_gate.py` — delete. +- `claude_bottle/backend/docker/ssh_gate.py` — delete. +- `claude_bottle/backend/docker/provision/ssh.py` — delete. +- `claude_bottle/backend/docker/backend.py` — drop + `DockerSSHGate` instantiation. +- `claude_bottle/backend/docker/launch.py` — drop the + ssh-gate start / stop from the `ExitStack`. +- `claude_bottle/backend/docker/bottle_plan.py` — drop the + ssh-gate plan field. +- `claude_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. +- `claude-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 claude-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.