fix(pipelock): auto-allow cred-proxy hostname when routes are declared
The agent's HTTP_PROXY env points at pipelock, so an ANTHROPIC_BASE_URL like http://cred-proxy:9099/anthropic doesn't short-circuit through Docker's embedded DNS — it gets forwarded through pipelock, which then checks its api_allowlist for the hostname `cred-proxy` and 403's because the name isn't there. The agent surfaces the failure as "API Error: 403 blocked: domain not in allowlist: cred-proxy" on Claude's first call. Fix: pipelock_effective_allowlist auto-adds CRED_PROXY_HOSTNAME when bottle.cred_proxy.routes is non-empty (i.e., when the sidecar will actually be running and reachable). Move CRED_PROXY_HOSTNAME from backend/docker/cred_proxy.py to the backend-agnostic claude_bottle/cred_proxy.py so pipelock can reference it without a layering violation; the docker concrete imports it from the same place.
This commit is contained in:
@@ -10,6 +10,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...cred_proxy import (
|
from ...cred_proxy import (
|
||||||
|
CRED_PROXY_HOSTNAME,
|
||||||
CredProxy,
|
CredProxy,
|
||||||
CredProxyPlan,
|
CredProxyPlan,
|
||||||
cred_proxy_resolve_token_values,
|
cred_proxy_resolve_token_values,
|
||||||
@@ -30,13 +31,6 @@ CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy"
|
|||||||
# both reference it.
|
# both reference it.
|
||||||
CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099"))
|
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.
|
# In-container path the proxy server reads its route table from.
|
||||||
# Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the
|
# Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the
|
||||||
# file directly.
|
# file directly.
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ from .log import die
|
|||||||
from .manifest import Bottle
|
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)
|
@dataclass(frozen=True)
|
||||||
class CredProxyRoute:
|
class CredProxyRoute:
|
||||||
"""One resolved route on the cred-proxy sidecar. Maps a path
|
"""One resolved route on the cred-proxy sidecar. Maps a path
|
||||||
@@ -247,6 +257,7 @@ class CredProxy(ABC):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CRED_PROXY_HOSTNAME",
|
||||||
"CredProxy",
|
"CredProxy",
|
||||||
"CredProxyPlan",
|
"CredProxyPlan",
|
||||||
"CredProxyRoute",
|
"CredProxyRoute",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
from .cred_proxy import CRED_PROXY_HOSTNAME
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
# Baked-in default allowlist for hosts Claude Code itself needs.
|
# 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]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
||||||
and the cred-proxy upstream hosts derived from bottle.cred_proxy.routes.
|
the cred-proxy upstream hosts derived from bottle.cred_proxy.routes,
|
||||||
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
and the cred-proxy sidecar's own hostname when any cred_proxy route
|
||||||
contribute here — git traffic flows through the per-agent git-gate
|
is declared. Sorted for stability. Git upstreams declared in
|
||||||
sidecar (PRD 0008), not pipelock."""
|
`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] = {}
|
seen: dict[str, None] = {}
|
||||||
for h in DEFAULT_ALLOWLIST:
|
for h in DEFAULT_ALLOWLIST:
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
@@ -86,6 +94,8 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
for h in pipelock_token_hosts(bottle):
|
for h in pipelock_token_hosts(bottle):
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
|
if bottle.cred_proxy.routes:
|
||||||
|
seen.setdefault(CRED_PROXY_HOSTNAME, None)
|
||||||
return sorted(seen.keys())
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ class TestAllowlistWithTokens(unittest.TestCase):
|
|||||||
self.assertIn("registry.npmjs.org", eff)
|
self.assertIn("registry.npmjs.org", eff)
|
||||||
self.assertIn("api.github.com", 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):
|
class TestTlsPassthrough(unittest.TestCase):
|
||||||
def test_default_includes_api_anthropic(self):
|
def test_default_includes_api_anthropic(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user