# PRD 0007: SSH egress gate - **Status:** Superseded by PRD 0009 (2026-05-13) - **Author:** didericis - **Created:** 2026-05-12 > **Superseded.** The ssh-gate sidecar and `bottle.ssh` manifest field > described below were removed in PRD 0009. Every upstream this PRD > targeted has since been folded into PRD 0008's git-gate, which > covers the same use case with credential isolation and gitleaks > scanning instead of bare L4 forwarding. Kept in-tree for the > history of intent. ## Summary Per-agent TCP-forwarder sidecar built from `bottle.ssh` entries; SSH stops going through pipelock; pipelock keeps full TLS interception with no SSH carve-outs. ## Problem `git fetch` over SSH from inside an implementer-agent bottle is broken on `main`. The error surfaced after PRD 0006 enabled pipelock's native `tls_interception`: ``` kex_exchange_identification: Connection closed by remote host Connection closed by UNKNOWN port 65535 fatal: Could not read from remote repository. ``` The agent's ssh client tunnels through pipelock via a `ProxyCommand socat - PROXY:pipelock:%h:%p` and pipelock now bumps that CONNECT tunnel. SSH sends its banner instead of a TLS ClientHello; pipelock's SNI gate rejects it; the tunnel closes mid-kex. Every bottle with an `ssh` entry hits this — including the implementer agent used by the free-agent workflow, which can't pull or push. ## Goals / Success Criteria Integration test: spin up a bottle with an SSH entry, exec `git fetch` against a real-ish SSH host from inside the agent, observe exit 0. This is the same signal that's broken today; flipping it back to green is the test. ## Non-goals - Pluggable forwarder backend. One TCP forwarder image is baked in; abstracting over haproxy / nginx-stream / etc. is deferred. - SSH-protocol awareness. The gate stays at L4. No SSH-version sniffing, no kex inspection, no per-key gating beyond what ssh itself enforces inside the agent. - Replacing pipelock for anything else. HTTPS / HTTP traffic continues to flow through pipelock unchanged. This PRD adds a sidecar; it doesn't displace one. - Connection rate limits or quotas. No per-host or per-agent rate limiting on the gate; future PRD if it ever matters. ## Scope ### In scope - **Gate sidecar lifecycle.** `DockerSSHGate` class with `prepare` / `start` / `stop`, mirroring `DockerPipelockProxy`'s shape and network attachment story. - **ssh provisioner rewrite.** `provision/ssh.py` drops the socat `ProxyCommand`; `~/.ssh/config` points each `Host` at the gate container and the per-host listen port. - **Pipelock carve-out removal.** Strip `pipelock_bottle_ssh_trusted_domains`, `pipelock_bottle_ssh_ip_cidrs`, and the related code paths in `pipelock_build_config` + tests. After this PRD, pipelock has no knowledge of `bottle.ssh`. - **Plan rendering / dry-run.** `bottle_plan.py` and the y/N preflight surface the new gate sidecar (name, listen ports, upstream targets). ### Out of scope - SSH key generation / rotation. Bottle keys are still user-supplied via `IdentityFile`; the gate doesn't manage key material. - Per-host audit logging. The gate is dumb TCP forwarding; no in-band visibility into SSH session content. (Connection-level logs from socat are a nice-to-have, not a goal.) - Non-Docker backends. Implementation lands for Docker only; the `BottleBackend` abstraction can grow the hook but other backends are deferred. - Manifest schema changes. `bottle.ssh` stays exactly as it is today; this PRD is internals-only. ## Proposed Design ### New services / components Mirror the pipelock layout: - **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` + `SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free on docker; renders the forwarder config under `stage_dir`. - **`bot_bottle/backend/docker/ssh_gate.py`** (new): `DockerSSHGate` concrete subclass — `start` does `docker create` on the internal network, copies the config in, attaches the egress network, `docker start`. `stop` is idempotent `docker rm -f`. Container name: `bot-bottle-ssh-gate-`. Forwarder image: `alpine/socat`, pinned by digest. Must be self-sufficient at boot (no apk/apt pulls on first run) because the gate's agent-facing leg sits on the `--internal` network and has no internet at startup. One socat process per ssh entry, multiplexed inside the same gate container via an entrypoint script that backgrounds N socat invocations: ``` socat TCP-LISTEN:,reuseaddr,fork TCP:: ``` Listen ports mirror the upstream port (entry `Port`, default 22). That choice is load-bearing: OpenSSH treats a URL-supplied port (e.g. `ssh://git@host:30009/repo.git`) as overriding the config's `Port` directive, so the gate has to be reachable on the same port the URL names — otherwise git fetch hits "connection refused" on the URL's port even though the config block points elsewhere. Two ssh entries sharing an upstream port are a config error and rejected at prepare time. One container, N listeners, N upstreams. ### Existing code touched - **`bot_bottle/backend/docker/provision/ssh.py`**: drop the `ProxyCommand socat - PROXY:...` plumbing and the `pipelock_proxy_host_port` import. The rendered `~/.ssh/config` block per entry becomes: ``` Host HostName User Port IdentityAgent ``` `known_hosts` entries are keyed off `` and the new `[]:` form so OpenSSH's strict host-key checking still matches. - **`bot_bottle/pipelock.py`**: delete `pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`, `pipelock_bottle_ssh_ip_cidrs`, and the calls into them from `pipelock_effective_allowlist` and `pipelock_build_config`. The effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`. - **`bot_bottle/backend/docker/backend.py`**: instantiate `DockerSSHGate` alongside `DockerPipelockProxy`; thread its `prepare` / `start` / `stop` through `resolve_plan` / `launch`. - **`bot_bottle/backend/docker/launch.py`**: add gate start / stop to the `ExitStack` in the right order — gate must be up before `provision_ssh` runs so the agent can dial it on first boot. - **`bot_bottle/backend/docker/bottle_plan.py`**: new `SSHGatePlan` field on `DockerBottlePlan`; preflight rendering surfaces the gate sidecar (name, per-entry listen ports, upstream `Hostname:Port` targets). - **Tests**: update `tests/fixtures.py` callers; rewrite `tests/unit/test_pipelock_yaml.py::TestBuildConfig::test_ssh_shape` to assert pipelock no longer reflects ssh entries; add unit tests for `SSHGate.prepare` + render shape; add an integration test in `tests/integration/` for the `git fetch` round-trip. ### Data model changes None. `bottle.ssh` schema is unchanged; one new internal plan dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`. ### External dependencies - `alpine/socat` image, pinned by digest (declared next to the `PIPELOCK_IMAGE` constant). No new Python packages. ## Open questions - Network topology: does the gate need its own per-agent egress bridge, or can it share pipelock's egress network? Sharing is simpler; per-gate isolates failure modes. Decide during implementation; default to "share pipelock's egress network" unless a concrete reason emerges. - Socat container restart policy: a single socat that crashes takes one upstream offline; do we want a wrapper that restarts individual listeners, or just rely on `docker restart`? Default to no-restart for v1 (matches pipelock). - Connection-level audit log: socat's `-v` mode logs every connect/close. Worth piping into the bottle's stderr stream, or is that noise? Default off, reconsider if debugging gets hard. - ~~Docker DNS for the `` hostname inside the agent: works via Docker's embedded resolver on user-defined networks. Verify on the `--internal` network specifically before implementation.~~ **Resolved.** Spike confirmed: a container on a `--internal` user-defined network resolves another container's name via the embedded resolver at 127.0.0.11 and reaches it over TCP, while egress to the public internet remains blocked. The PRD's design assumption holds. ## References - PRD 0001: per-agent egress proxy via pipelock — the parent topology this PRD slots into. - PRD 0006: pipelock native TLS interception — the change that surfaced this regression by making pipelock incompatible with SSH-over-CONNECT. - `bot_bottle/backend/docker/provision/ssh.py` — current SSH provisioning that this PRD rewrites. - `bot_bottle/pipelock.py` — current pipelock config builder that gains the `bottle.ssh`-derived fields this PRD removes.