Phase 3 of PRD 0013. Wires the supervise sidecar into bottle launch:
- Manifest: bottle.supervise (bool, default False). Opt-in for v1 so
existing bottles are unchanged.
- supervise.py: adds SupervisePlan + abstract Supervise(ABC) with a
prepare template that stages the per-bottle queue dir on the host
and the current-config dir under stage_dir (routes.json + allowlist
+ Dockerfile). Stdlib-only so it still runs as the in-container
shared helper.
- backend/docker/supervise.py: DockerSupervise concrete start/stop.
No egress network (the sidecar doesn't make outbound calls); just
the bottle's internal network with network-alias "supervise" and a
bind-mount of the host queue dir at /run/supervise/queue.
- Prepare wires supervise.prepare into the DockerBottlePlan, derives
routes_content from cred_proxy_plan, allowlist_content from
pipelock_effective_allowlist, and dockerfile_content from the
repo's Dockerfile. supervise sidecar added to the orphan probe.
- Launch starts the supervise sidecar after pipelock + cred-proxy
but before the agent (so DNS resolution for `supervise` is up on
the agent's first tool call).
- Agent container gets a read-only bind-mount of the current-config
dir at /etc/claude-bottle/current-config when supervise is enabled.
- bottle_plan print + to_dict surface the supervise state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The agent's HTTP_PROXY points at pipelock, so a request to
http://cred-proxy:9099/... arrives at pipelock; pipelock resolves
the host, sees an RFC1918 address (the bottle's internal Docker
network sits in 172.x), and 403's "SSRF blocked: cred-proxy
resolves to internal IP 172.20.0.4". Bypassing pipelock entirely
would also remove its body scanner from the agent->cred-proxy leg
— we want to keep that DLP coverage.
Pipelock has `ssrf.ip_allowlist` for exactly this: CIDRs that
override the built-in internal-IP block while api_allowlist + body
scanning + tls_interception keep firing.
Wiring:
- `pipelock_build_config` accepts `ssrf_ip_allowlist`; when
non-empty, emits an `ssrf: { ip_allowlist: [...] }` block.
- `pipelock_render_yaml` renders that block.
- `PipelockProxyPlan` gains `internal_network_cidr`.
- New `network_inspect_cidr(name)` helper reads the Docker-assigned
subnet via `docker network inspect`.
- launch.py: after `network_create_internal`, inspect the CIDR,
re-render the yaml with `ssrf_ip_allowlist=(cidr,)`, overwrite
the file in place; `DockerPipelockProxy.start` then docker-cp's
the updated content. Prepare's initial render stays unchanged
(CIDR isn't known yet at prepare time).
The exception scope is the bottle's own internal network only —
agent ↔ pipelock / git-gate / cred-proxy. Body scanning still
applies to the bytes flowing through pipelock; pipelock just no
longer treats those internal IPs as exfil targets.
Three leftovers from the manifest refactor:
1. provision/cred_proxy.py:223 referenced u.kind == 'gitea' for the
tea login count — kind was removed from the runtime class, so any
bottle with a tea-login route raised AttributeError at provision
time. Switch to `'tea-login' in r.roles`.
2. The runtime class CredProxyUpstream is renamed to CredProxyRoute
(its data is a route on the proxy, not an "upstream"; the field
route.upstream is the upstream URL). Module's own naming now
aligns with manifest.CredProxyRoute and routes.json.
3. cred_proxy_upstreams_for_bottle -> cred_proxy_routes_for_bottle;
CredProxyPlan.upstreams -> CredProxyPlan.routes; local
`upstreams` collections become `routes`. Callers in
backend.py, launch.py, prepare.py, bottle_plan.py,
provision/cred_proxy.py, and tests updated.
Also strips lingering `bottle.tokens` references from docstrings
(pipelock.py, cred_proxy.py prepare(), manifest._parse_https_host,
test_pipelock_allowlist.py module doc) and removes dead helpers
from the integration test (the _bottle helper used a tokens field
that no longer parses).
Three coupled fixes that close a documented bypass of git-gate's
gitleaks pre-receive hook:
1. cred-proxy refuses git smart-HTTP push at runtime. Any path
ending in /git-receive-pack or /info/refs?service=git-receive-pack
returns 403 with a pointer at the bottle.git SSH path. Fetch
(upload-pack) is still allowed — the bypass we're closing is
push, where gitleaks is the load-bearing scanner. Hard guarantee.
2. The provisioner suppresses the cred-proxy `~/.gitconfig` insteadOf
rewrite for any host already declared in bottle.git. git-gate is
the canonical git path there; we don't write a competing rule
that would let `git clone https://<host>/...` succeed in ways
that confuse on push. Defense in depth — (1) is the hard guarantee.
3. cred-proxy routes its outbound HTTPS through pipelock. The
sidecar's environ now sets HTTPS_PROXY=<pipelock-url>, and the
image's entrypoint runs `update-ca-certificates` over the
per-bottle pipelock CA (docker cp'd into
/usr/local/share/ca-certificates/pipelock.crt before start) so
the proxy's HTTPS client trusts pipelock's bumped certs.
Consequence: pipelock's allowlist + body scanner now sit in the
cred-proxy egress path the same way they sit in front of direct
agent traffic. The cred-proxy upstream hosts (api.github.com,
github.com, gitea hosts, registry.npmjs.org) come OFF
pipelock's passthrough_domains. Only api.anthropic.com remains
on passthrough (LLM body content legitimately trips DLP).
PRD 0010 updated to reflect all three. Tests adjusted: the
"cred-proxy hosts go on passthrough" assertion in
test_pipelock_allowlist flips to "they don't", a new
TestIsGitPushRequest exercises the smart-HTTP refusal predicate,
and the gitconfig renderer tests cover the per-host suppression
matrix.
- DockerBottleBackend instantiates DockerCredProxy alongside pipelock
and git-gate; threads it through prepare and launch.
- DockerBottlePlan gains cred_proxy_plan; preflight rendering shows
the declared kinds + TokenRefs and to_dict emits a cred_proxy
array matching the routing table.
- prepare.py: when bottle.tokens has an anthropic entry, route the
agent at the proxy via ANTHROPIC_BASE_URL, drop the agent-side
CLAUDE_CODE_OAUTH_TOKEN forward (the token goes to the sidecar's
environ instead, set a non-secret placeholder so claude-code's
startup check passes), and default the telemetry-off env vars.
- launch.py: bring up the cred-proxy sidecar in ExitStack before the
agent container so DNS resolution for `cred-proxy` succeeds on the
agent's first call.
- backend/__init__.py: add provision_cred_proxy to the provision
template (runs after provision_git so it can append to ~/.gitconfig).
- bottle_plan _view: env_names is derived from the forwarded_env dict,
so the preflight reflects the PRD 0010 switch without ad-hoc
branching on spec.forward_oauth_token.
Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar,
and the provision_ssh provisioner (~/.ssh/config + ssh-agent
wiring). Unwire the gate from the abstract BottleBackend
(provision orchestration drops the ssh step,
_validate_ssh_entries goes away) and from the Docker backend
(prepare/launch lose the `gate` kwarg, bottle_plan drops the
gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate
keys, y/N preflight drops the ssh-hosts block). cli/info now
prints declared git remotes instead of ssh hosts. pipelock's
docstring picks up the git-gate framing now that there's no
PRD-0007 boundary to call out.
BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys
are gone from `start --dry-run --format=json`. Consumers should
read `git_remotes` / `git_gate` instead.
DockerBottleBackend now instantiates a DockerGitGate alongside
DockerPipelockProxy and DockerSSHGate; the prepare step lifts
bottle.git into a GitGatePlan stored on DockerBottlePlan, and
launch starts/stops the sidecar in the same ExitStack as the
other two (only when bottle.git is non-empty).
bottle_plan.print now surfaces git remotes and per-upstream gate
forwards in the y/N preflight; to_dict adds git_remotes and
git_gate keys to the dry-run JSON payload for CLI consumers.
PRD: docs/prds/0008-git-gate.md
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.
Second step of PRD 0006. With pipelock now doing the bumping, the
agent's TLS library has to trust pipelock's per-bottle CA — or
every CONNECT to api.anthropic.com is a self-signed-cert error.
- BottleBackend.provision gains a non-abstract `provision_ca`
with a default no-op (so non-Docker backends aren't forced to
implement TLS interception) and orchestrates
ca → prompt → skills → ssh → git. CA install runs first so the
agent's trust store is rebuilt before anything else in the
agent makes a TLS call.
- New backend/docker/provision/ca.py: docker-cp's the CA cert
into the agent at /usr/local/share/ca-certificates/...,
`update-ca-certificates`, then emits a one-line stderr log
with the SHA-256 fingerprint (stdlib `ssl` + `hashlib`; no
subprocess for crypto). Module-level constants AGENT_CA_PATH
and AGENT_CA_BUNDLE are imported by launch.py so the env
trio set at docker run time matches the paths the provisioner
writes.
- launch.py: rebinds `plan` after `dataclasses.replace`s on the
pipelock proxy plan so provision_ca (which reads
`plan.proxy_plan.ca_cert_host_path`) sees the populated CA
paths. Three new -e flags on the agent's docker run for the
NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trio.
- Dockerfile: adds curl to the apt-get install line. curl
natively respects HTTPS_PROXY and sends CONNECT directly —
the agent doesn't need OS-level DNS for external hostnames
(pipelock resolves them on its side of the bumped tunnel).
This is the "simple HTTPS request" path the earlier turn
needed and Node's stdlib https.request couldn't provide.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First step of PRD 0006. Pipelock now does the CONNECT bumping that
PR #8's mitmproxy chain was supposed to provide — natively, in the
same single sidecar PRD 0001 wired up.
- claude_bottle/pipelock.py: pipelock_build_config grows optional
ca_cert_path / ca_key_path kwargs. When both are passed the
rendered YAML carries a `tls_interception: { enabled: true,
ca_cert, ca_key }` block. PipelockProxy gains class-level
CA_CERT_IN_CONTAINER / CA_KEY_IN_CONTAINER constants that
subclasses set to wherever they place the CA inside the
sidecar. PipelockProxyPlan gains ca_cert_host_path /
ca_key_host_path fields (default empty Path() — sentinel for
"not yet populated", filled by launch via dataclasses.replace).
- claude_bottle/backend/docker/pipelock.py: new
pipelock_tls_init(stage_dir) helper runs `pipelock tls init`
in a one-shot container against a host-mounted scratch dir.
DockerPipelockProxy sets its class constants to
/etc/pipelock-ca.pem and /etc/pipelock-ca-key.pem; .start
docker-cp's the cert + key into those paths between
`docker create` and `docker start`. Pipelock runs as root in
its distroless image, so no chown is needed (verified).
- claude_bottle/backend/docker/launch.py: calls pipelock_tls_init
between network creation and proxy.start. Prepare stays
side-effect-free on docker; the one-shot ca-init container
only runs on a real launch, not on `start --dry-run`.
- tests/unit/test_pipelock_yaml.py: new assertions that
pipelock_build_config emits the tls_interception block only
when both paths are supplied (and rejects a half-set pair),
plus a test that the docker proxy's prepare plumbs the
in-container paths through to the rendered YAML.
The end-to-end "bumping actually fires" assertion lands in
chunk 4 (HTTPS integration tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The helper is a thin subprocess wrapper over `container_exists` +
`docker rm -f`, so it belongs alongside the other docker primitives
in util.py rather than as a private in launch.py.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the resolution, bring-up, and orphan-cleanup logic out of
backend.py into three topic-named modules. DockerBottleBackend becomes
a thin façade that wires the per-instance pipelock proxy and the
provision orchestrator into the free functions.
backend.py drops from ~360 to ~70 lines and each topic now reads
end-to-end in one place. Mirrors the existing provision/ split.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>