docs(prds): add PRD 0009 to remove ssh-gate and bottle.ssh
ssh-gate was built for non-git SSH (PRD 0007), but every upstream currently declared in any bottle is a git remote, and those now flow through git-gate (PRD 0008) with credential isolation, gitleaks scanning, and `insteadOf` URL rewrites. ssh-gate is left doing L4 forwarding with no gating value over git-gate's path; carrying it means 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. Goal is straightforward deletion: bottle.ssh becomes a parse error pointing at bottle.git, the SshEntry / SSHGate / socat provisioner / pipelock allowlist branch all go away, and PRD 0007 carries a "Superseded by PRD 0009" header so the rationale of the prior design stays in the tree.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user