diff --git a/Dockerfile b/Dockerfile index 06e2911..abe1c19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,11 @@ FROM node:22-slim # clarity in case the base ever drops it. socat is the privileged # forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent # runs as root and rejects non-root connections, so socat sits between -# node and the agent socket. +# node and the agent socket. curl is here so any HTTPS_PROXY-aware +# tool (curl itself, plus anything that shells out to it) works +# against pipelock's bumped TLS without the agent needing local DNS. RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates openssh-client socat \ + && apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl \ && rm -rf /var/lib/apt/lists/* # Install claude-code globally. Pinned to the version verified in the v1 diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 4c85366..8e0dc63 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -204,24 +204,36 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Build/run the bottle and yield a handle; tear down on exit.""" def provision(self, plan: PlanT, target: str) -> str | None: - """Copy host-side files (prompt, skills, SSH keys, .git) into - the running bottle. Called from `launch` after the container/ - machine is up. `target` identifies the running instance in - backend-specific terms (Docker: resolved container name; fly: - machine id). Returns the in-container prompt path if a prompt - was provisioned, else None — the Bottle handle uses it to - decide whether to add --append-system-prompt-file to claude's - argv. + """Copy host-side files (CA cert, prompt, skills, SSH keys, + .git) into the running bottle. Called from `launch` after the + container/machine is up. `target` identifies the running + instance in backend-specific terms (Docker: resolved + container name; fly: machine id). Returns the in-container + prompt path if a prompt was provisioned, else None — the + Bottle handle uses it to decide whether to add + --append-system-prompt-file to claude's argv. - Default orchestration: prompt → skills → ssh → git. Subclasses - typically don't override this; they implement the four - sub-methods below.""" + Default orchestration: ca → prompt → skills → ssh → git. + CA install runs first so the agent's trust store is rebuilt + before anything inside the agent makes a TLS call. Subclasses + typically don't override this; they implement the sub-methods + below.""" + self.provision_ca(plan, target) prompt_path = self.provision_prompt(plan, target) self.provision_skills(plan, target) self.provision_ssh(plan, target) self.provision_git(plan, target) return prompt_path + def provision_ca(self, plan: PlanT, target: str) -> None: + """Install pipelock's per-bottle CA into the agent's trust + store so the agent trusts the bumped CONNECT cert pipelock + presents. Default impl is a no-op so backends that don't + yet support TLS interception (every backend except Docker + today) aren't forced to implement it. The Docker backend + overrides to docker-cp the cert in and run + `update-ca-certificates`.""" + @abstractmethod def provision_prompt(self, plan: PlanT, target: str) -> str | None: """Copy the prompt file into the running bottle. Returns the diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 97d1344..0ba0a5c 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -24,6 +24,7 @@ from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan from .pipelock import DockerPipelockProxy +from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt from .provision import skills as _skills @@ -47,6 +48,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup with _launch.launch(plan, proxy=self._proxy, provision=self.provision) as bottle: yield bottle + def provision_ca(self, plan: DockerBottlePlan, target: str) -> None: + _ca.provision_ca(plan, target) + def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: return _prompt.provision_prompt(plan, target) diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 218100a..5e1d09d 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -23,6 +23,7 @@ from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init +from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH # Where the repo root lives, for `docker build` context. Computed once. @@ -77,7 +78,11 @@ def launch( ca_cert_host_path=ca_cert_host, ca_key_host_path=ca_key_host, ) - pipelock_name = proxy.start(proxy_plan) + # Re-bind the outer plan so provision_ca (which runs later + # from `provision(plan, container)`) can read the populated + # CA paths off plan.proxy_plan. + plan = dataclasses.replace(plan, proxy_plan=proxy_plan) + pipelock_name = proxy.start(plan.proxy_plan) stack.callback(proxy.stop, pipelock_name) container = _run_agent_container(plan, internal_network) @@ -102,6 +107,16 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str: "-e", f"HTTPS_PROXY={proxy_url}", "-e", f"HTTP_PROXY={proxy_url}", "-e", "NO_PROXY=localhost,127.0.0.1", + # CA trust trio for the agent process. Docker propagates + # run-time env into `docker exec`, so `claude` sees these + # without per-exec threading. NODE_EXTRA_CA_CERTS points at + # the cert file (Node appends it to its bundled roots); + # SSL_CERT_FILE / REQUESTS_CA_BUNDLE point at the system + # bundle that `update-ca-certificates` rebuilds in + # provision_ca. + "-e", f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", + "-e", f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", + "-e", f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", ] if plan.use_runsc: docker_args.extend(["--runtime", "runsc"]) diff --git a/claude_bottle/backend/docker/provision/ca.py b/claude_bottle/backend/docker/provision/ca.py new file mode 100644 index 0000000..1d30192 --- /dev/null +++ b/claude_bottle/backend/docker/provision/ca.py @@ -0,0 +1,79 @@ +"""Install pipelock's per-bottle CA into the agent container's trust +store (PRD 0006). + +By the time this provisioner runs, `pipelock_tls_init` has generated +a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar +is up with `tls_interception: { enabled: true }` referencing the +in-container CA paths. This step makes the agent trust certs signed +by that CA so the agent's TLS handshake with the bumped CONNECT +succeeds. + +Cert lands on Debian's standard source path +(`/usr/local/share/ca-certificates/`); `update-ca-certificates` +rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl, +Python `ssl`, and OpenSSL-based tools all read by default. The env +trio set on the agent's `docker run` covers Node +(`NODE_EXTRA_CA_CERTS`) and Python `requests` / +`SSL_CERT_FILE`-honoring libraries that don't load the system +bundle. + +The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert` ++ `hashlib.sha256`) and logged once to stderr. The private key +stays on the host (under `stage_dir`) until teardown wipes the +stage dir; nothing in the agent ever sees it.""" + +from __future__ import annotations + +import hashlib +import ssl +import subprocess + +from ....log import info +from ..bottle_plan import DockerBottlePlan + + +# Debian-family path for sources that `update-ca-certificates` reads. +# Bundle path is what the command rebuilds and what every standard +# TLS consumer in the image reads. +AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-pipelock-ca.crt" +AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" + + +def provision_ca(plan: DockerBottlePlan, target: str) -> None: + """Copy pipelock's CA cert into the agent, rebuild the trust + bundle, emit a one-line fingerprint log. Called from + `BottleBackend.provision` after the agent container is up.""" + container = target + cert_host_path = plan.proxy_plan.ca_cert_host_path + if not cert_host_path or not cert_host_path.is_file(): + # Defensive: provision runs after launch wires CA paths + # onto the plan via dataclasses.replace; an empty path here + # would mean that wiring was skipped. + from ....log import die + die( + f"pipelock CA cert missing at {cert_host_path or '(empty)'}; " + f"launch must have called pipelock_tls_init and re-bound " + f"the plan before provision" + ) + + subprocess.run( + ["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", container, "update-ca-certificates"], + stdout=subprocess.DEVNULL, + check=True, + ) + + # Stdlib SHA-256 of the cert's DER bytes — the standard + # fingerprint form. Never the private key. + der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text()) + fingerprint = hashlib.sha256(der).hexdigest() + info(f"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")