PRD 0007: SSH egress gate #10

Merged
didericis merged 10 commits from ssh-egress-gate into main 2026-05-12 16:21:12 -04:00
Owner

Summary

  • Drafts PRD 0007 for moving SSH egress off pipelock onto a dedicated per-agent TCP-forwarder sidecar built from bottle.ssh entries.
  • Lets pipelock keep full TLS interception (PRD 0006) on the HTTPS path with no SSH-related carve-outs — bottle.ssh stops appearing in pipelock's allowlist, SSRF rules, etc.
  • Unblocks git fetch from inside the implementer agent, which has been failing with kex_exchange_identification: Connection closed by remote host since #9 merged.

Why now

PRD 0006 enabled tls_interception unconditionally. Pipelock's SNI verification gate then rejects every SSH-over-CONNECT tunnel (banner is not a TLS ClientHello), so any bottle with an ssh entry can't reach its git host. Bandage fixes (passthrough_domains + sni_verification toggle) are intentionally skipped in favor of the architectural separation.

## Summary - Drafts PRD 0007 for moving SSH egress off pipelock onto a dedicated per-agent TCP-forwarder sidecar built from `bottle.ssh` entries. - Lets pipelock keep full TLS interception (PRD 0006) on the HTTPS path with no SSH-related carve-outs — `bottle.ssh` stops appearing in pipelock's allowlist, SSRF rules, etc. - Unblocks `git fetch` from inside the implementer agent, which has been failing with `kex_exchange_identification: Connection closed by remote host` since #9 merged. ## Why now PRD 0006 enabled `tls_interception` unconditionally. Pipelock's SNI verification gate then rejects every SSH-over-CONNECT tunnel (banner is not a TLS ClientHello), so any bottle with an `ssh` entry can't reach its git host. Bandage fixes (passthrough_domains + sni_verification toggle) are intentionally skipped in favor of the architectural separation.
didericis added 1 commit 2026-05-12 15:41:52 -04:00
docs(prd): 0007 SSH egress gate
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 15s
02a0fe679d
PRD 0006 enabled pipelock's native TLS interception, which broke
git fetch over SSH from inside the agent: pipelock's SNI gate
rejects the SSH banner that follows CONNECT. Document the
architectural fix — a dedicated per-agent TCP-forwarder sidecar
built from bottle.ssh entries — so pipelock can stay maximally
strict on the HTTPS path with no SSH carve-outs.
didericis added 1 commit 2026-05-12 15:48:58 -04:00
docs(prd): resolve gate-DNS open question on 0007
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 14s
cb0f0f133d
Spike: 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 — no design
change needed.
didericis added 1 commit 2026-05-12 15:50:38 -04:00
docs(prd): note gate image must be self-sufficient at boot on 0007
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 13s
b2927b1483
The gate's agent-facing leg sits on the `--internal` network, so
the forwarder image cannot rely on apk/apt at startup. Surfaced
by the DNS spike — a placeholder using `apk add socat` died
silently and gave a false-negative DNS-on-internal result.
didericis added 6 commits 2026-05-12 16:10:09 -04:00
First piece of PRD 0007: the per-agent SSH egress gate that will
let pipelock stop seeing SSH traffic. This commit only lands the
backend-agnostic surface — the SSHGate ABC, SSHGatePlan, the
listen-port assignment (BASE_LISTEN_PORT + index), and the
entrypoint-script renderer. Backend wiring lands in follow-up
commits.
PRD 0007: Docker-specific start/stop for the SSH egress gate.
Mirrors DockerPipelockProxy: docker create on the internal
network with /bin/sh entrypoint, docker cp the staged entrypoint
script in, attach to the egress network, docker start. Image is
alpine/socat pinned by digest — self-sufficient at boot so the
gate's agent-facing leg can stay on the --internal network.

Not yet wired into the bottle launch path; that lands next.
PRD 0007: thread the DockerSSHGate through the bottle lifecycle.

- DockerBottlePlan gains gate_plan: SSHGatePlan.
- prepare.resolve_plan accepts a gate and renders its entrypoint
  script next to the pipelock yaml.
- launch.launch starts the gate sidecar after pipelock (so it's on
  the same internal + egress networks) and registers its stop in
  the ExitStack. Skipped when the bottle has no ssh entries.
- DockerBottleBackend instantiates DockerSSHGate alongside the
  pipelock proxy.
- bottle_plan.print + to_dict surface the upstream table so
  --dry-run shows the per-host listen-port mapping.

ssh_config provisioning still points at pipelock; that swap lands
in the next commit so this one stays a pure wiring change.
PRD 0007: stop tunneling ssh through pipelock. Each Host block in
the agent's ~/.ssh/config now points at the gate container + the
per-entry listen port; HostKeyAlias preserves host-key validation
against the real upstream name, and CheckHostIP=no skips the
resolved-IP path (which would otherwise hit the gate's IP).
known_hosts collapses to a single entry per upstream keyed on the
alias.

The pipelock_proxy_host_port import is gone from this module; the
function itself becomes dead code and gets removed alongside the
broader pipelock SSH carve-outs in the next commit.
PRD 0007: SSH traffic now flows through the per-agent ssh-gate
sidecar, so pipelock should know nothing about bottle.ssh.

Removed:
- pipelock_bottle_ssh_hostnames, _trusted_domains, _ip_cidrs.
- The trusted_domains / ssrf blocks built from ssh entries.
- pipelock_proxy_host_port — its last caller (the ssh provisioner)
  is gone.
- is_ipv4_literal — only used to classify ssh hostnames into
  trusted_domains vs ssrf.ip_allowlist, both of which are gone.

api_allowlist now derives solely from baked-in defaults +
bottle.egress.allowlist. Tests updated to pin the new shape and
assert ssh hostnames do NOT leak into pipelock's config.
test(ssh-gate): assert SSHGate.stop is no-op on missing sidecar
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 13s
a7633977de
PRD 0007: the launch ExitStack calls gate.stop on every failure
path, so an early bring-up error (where the gate container was
never created) must not raise from teardown. Mirrors the existing
DockerPipelockProxy.stop assertion.

The orphan-container enumeration in cleanup.py already covers
ssh-gate containers via its `claude-bottle-` name prefix filter —
no code change there.
didericis added 1 commit 2026-05-12 16:19:11 -04:00
fix(ssh-gate): listen on the upstream port so URL-supplied ports work
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 12s
a3d77cd015
Bug: git fetch failed with "connect to host
claude-bottle-ssh-gate-implementer port 30009: Connection refused".
OpenSSH treats a URL-supplied port (the user's remote was
ssh://git@gitea.dideric.is:30009/...) as overriding the
~/.ssh/config Port directive, so even though the config wrote
Port 30000 the agent dialed :30009 — where nothing was listening
because the gate had been assigned BASE_LISTEN_PORT + index.

Fix: the gate's listen port now equals the upstream port. Same
script, same socat, just port = entry.Port. Two entries on the
same upstream port are rejected at prepare time (the gate is one
container with a flat port space).

Re-smoked: probe nc github.com via the gate at :22, banner came
back as expected.

PRD 0007 updated to record the design refinement.
didericis merged commit 9827b86063 into main 2026-05-12 16:21:12 -04:00
Sign in to join this conversation.