diff --git a/claude_bottle/backend/docker/cred_proxy.py b/claude_bottle/backend/docker/cred_proxy.py index 6da6fce..d0cfd69 100644 --- a/claude_bottle/backend/docker/cred_proxy.py +++ b/claude_bottle/backend/docker/cred_proxy.py @@ -10,6 +10,7 @@ import subprocess from pathlib import Path from ...cred_proxy import ( + CRED_PROXY_HOSTNAME, CredProxy, CredProxyPlan, cred_proxy_resolve_token_values, @@ -30,13 +31,6 @@ CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy" # both reference it. CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099")) -# DNS name agents use to reach the sidecar. Attached as a -# --network-alias on the internal docker network so the URL the -# provisioner writes into the agent's environ is stable across -# bottles (the container name carries the per-bottle slug; the alias -# does not). -CRED_PROXY_HOSTNAME = "cred-proxy" - # In-container path the proxy server reads its route table from. # Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the # file directly. diff --git a/claude_bottle/cred_proxy.py b/claude_bottle/cred_proxy.py index 79ee4e5..0856d85 100644 --- a/claude_bottle/cred_proxy.py +++ b/claude_bottle/cred_proxy.py @@ -31,6 +31,16 @@ from .log import die from .manifest import Bottle +# DNS name agents use to reach the per-bottle cred-proxy sidecar. +# Backend-agnostic by contract: every concrete backend (Docker today, +# others later) attaches this name to its sidecar on the bottle's +# internal network so the agent's manifest-driven URLs (`http:// +# cred-proxy:9099/...`) work without a backend-specific hostname. +# pipelock's allowlist also references this when adding the +# auto-allow entry for cred-proxy traffic from the agent. +CRED_PROXY_HOSTNAME = "cred-proxy" + + @dataclass(frozen=True) class CredProxyRoute: """One resolved route on the cred-proxy sidecar. Maps a path @@ -247,6 +257,7 @@ class CredProxy(ABC): __all__ = [ + "CRED_PROXY_HOSTNAME", "CredProxy", "CredProxyPlan", "CredProxyRoute", diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 52f5b23..67234ce 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast +from .cred_proxy import CRED_PROXY_HOSTNAME from .manifest import Bottle # Baked-in default allowlist for hosts Claude Code itself needs. @@ -74,10 +75,17 @@ def pipelock_token_hosts(bottle: Bottle) -> list[str]: def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: """Deduplicated union of: baked-in defaults, bottle.egress.allowlist, - and the cred-proxy upstream hosts derived from bottle.cred_proxy.routes. - Sorted for stability. Git upstreams declared in `bottle.git` do NOT - contribute here — git traffic flows through the per-agent git-gate - sidecar (PRD 0008), not pipelock.""" + the cred-proxy upstream hosts derived from bottle.cred_proxy.routes, + and the cred-proxy sidecar's own hostname when any cred_proxy route + is declared. Sorted for stability. Git upstreams declared in + `bottle.git` do NOT contribute here — git traffic flows through the + per-agent git-gate sidecar (PRD 0008), not pipelock. + + The cred-proxy hostname is auto-added because the agent's + HTTP_PROXY points at pipelock, so a manifest-driven URL like + `http://cred-proxy:9099/anthropic/...` arrives at pipelock as a + request for hostname `cred-proxy`. Without this auto-allow, + pipelock would 403 the request before it reached the sidecar.""" seen: dict[str, None] = {} for h in DEFAULT_ALLOWLIST: seen.setdefault(h, None) @@ -86,6 +94,8 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: seen.setdefault(h, None) for h in pipelock_token_hosts(bottle): seen.setdefault(h, None) + if bottle.cred_proxy.routes: + seen.setdefault(CRED_PROXY_HOSTNAME, None) return sorted(seen.keys()) diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py index 29125aa..a10fae1 100644 --- a/tests/unit/test_pipelock_allowlist.py +++ b/tests/unit/test_pipelock_allowlist.py @@ -75,6 +75,22 @@ class TestAllowlistWithTokens(unittest.TestCase): self.assertIn("registry.npmjs.org", eff) self.assertIn("api.github.com", eff) + def test_cred_proxy_hostname_auto_added_when_routes_exist(self): + # The agent's HTTP_PROXY points at pipelock, so a request for + # http://cred-proxy:9099/... arrives at pipelock as a request + # for hostname `cred-proxy`. pipelock must allow it or the + # agent can't reach its own sidecar. + eff = pipelock_effective_allowlist(_bottle(_routes([ + {"path": "/x/", "upstream": "https://x.example", + "auth_scheme": "Bearer", "token_ref": "T"}, + ]))) + self.assertIn("cred-proxy", eff) + + def test_cred_proxy_hostname_NOT_added_when_no_routes(self): + # No cred-proxy sidecar, no auto-allow. + eff = pipelock_effective_allowlist(_bottle({})) + self.assertNotIn("cred-proxy", eff) + class TestTlsPassthrough(unittest.TestCase): def test_default_includes_api_anthropic(self):