efcafae810
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.
207 lines
8.5 KiB
Markdown
207 lines
8.5 KiB
Markdown
# 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.
|