feat(egress-proxy): cutover from cred-proxy (PRD 0017 chunk 2) #29
@@ -1,50 +0,0 @@
|
|||||||
# Per-bottle cred-proxy sidecar image (PRD 0010).
|
|
||||||
#
|
|
||||||
# Holds API tokens (Anthropic OAuth, GitHub PAT, Gitea PAT, npm) in
|
|
||||||
# this container's environ, strips inbound Authorization headers, and
|
|
||||||
# injects the configured one before forwarding to the real upstream
|
|
||||||
# over HTTPS. The agent's environ carries only URLs pointing at this
|
|
||||||
# sidecar — the upstream credentials never reach the agent container.
|
|
||||||
#
|
|
||||||
# Stdlib-only Python; no pip install layer. The route table lands at
|
|
||||||
# /run/cred-proxy/routes.json via `docker cp` from the backend's
|
|
||||||
# start step.
|
|
||||||
|
|
||||||
# python:3.13-alpine. Pinned by digest for reproducibility — the
|
|
||||||
# proxy script is stdlib-only so a Python minor-version drift would
|
|
||||||
# only affect the runtime, not API surface, but pinning makes the
|
|
||||||
# image bytes deterministic.
|
|
||||||
FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc
|
|
||||||
|
|
||||||
# `ca-certificates` ships /usr/sbin/update-ca-certificates and the
|
|
||||||
# system trust store. The backend's start step `docker cp`s the
|
|
||||||
# per-bottle pipelock CA into /usr/local/share/ca-certificates/ so
|
|
||||||
# the entrypoint's update-ca-certificates picks it up — cred-proxy's
|
|
||||||
# outbound HTTPS then trusts pipelock's bumped certs and outbound
|
|
||||||
# traffic routes through pipelock (HTTPS_PROXY in the environ).
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
|
|
||||||
# The proxy script ships as a single file. Tests in tests/unit/ import
|
|
||||||
# it as `claude_bottle.cred_proxy_server`; the container runs it
|
|
||||||
# directly as a script. No package install, no other modules pulled.
|
|
||||||
COPY claude_bottle/cred_proxy_server.py /app/cred_proxy_server.py
|
|
||||||
|
|
||||||
# Pre-create the runtime directory the backend's start step will
|
|
||||||
# `docker cp` routes.json into. docker cp does not create
|
|
||||||
# intermediate dirs, so the mkdir must be baked into the image.
|
|
||||||
RUN mkdir -p /run/cred-proxy
|
|
||||||
|
|
||||||
# Listening port. The agent's environ resolves the cred-proxy host
|
|
||||||
# via Docker's embedded DNS on the per-bottle internal network and
|
|
||||||
# dials this port. Surfaced as EXPOSE for documentation; not required
|
|
||||||
# for the internal network to route to it.
|
|
||||||
EXPOSE 9099
|
|
||||||
|
|
||||||
# Entry runs update-ca-certificates so the per-bottle pipelock CA
|
|
||||||
# docker-cp'd by the backend's start step is folded into
|
|
||||||
# /etc/ssl/certs/ca-certificates.crt before python comes up. Then
|
|
||||||
# exec into the server so PID 1 is python (clean signal handling
|
|
||||||
# and exit codes). Output of update-ca-certificates is silenced —
|
|
||||||
# the entry script prints one line per cert under normal operation,
|
|
||||||
# which the test suite would otherwise treat as a log smell.
|
|
||||||
ENTRYPOINT ["sh", "-c", "update-ca-certificates >/dev/null 2>&1 && exec python3 /app/cred_proxy_server.py"]
|
|
||||||
+26
-16
@@ -13,7 +13,7 @@
|
|||||||
# upstream proxy) is chunk 2.
|
# upstream proxy) is chunk 2.
|
||||||
|
|
||||||
# mitmproxy base image. mitmdump + addon API are already there; we
|
# mitmproxy base image. mitmdump + addon API are already there; we
|
||||||
# only need to drop our addon in. TODO(chunk-2): pin by digest.
|
# only need to drop our addon in. TODO: pin by digest.
|
||||||
FROM mitmproxy/mitmproxy:11.1.3
|
FROM mitmproxy/mitmproxy:11.1.3
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
@@ -25,23 +25,33 @@ USER root
|
|||||||
COPY claude_bottle/egress_proxy_addon_core.py /app/egress_proxy_addon_core.py
|
COPY claude_bottle/egress_proxy_addon_core.py /app/egress_proxy_addon_core.py
|
||||||
COPY claude_bottle/egress_proxy_addon.py /app/egress_proxy_addon.py
|
COPY claude_bottle/egress_proxy_addon.py /app/egress_proxy_addon.py
|
||||||
|
|
||||||
# Pre-create the runtime directory the backend's start step will
|
# Pre-create the runtime directories the backend's start step will
|
||||||
# `docker cp` routes.yaml into. docker cp does not create
|
# `docker cp` into. docker cp does not create intermediate dirs, so
|
||||||
# intermediate dirs, so the mkdir must be baked into the image.
|
# the mkdir must be baked into the image.
|
||||||
# Ownership lets the unprivileged mitmproxy user read the file.
|
# /etc/egress-proxy routes.yaml lands here
|
||||||
RUN mkdir -p /etc/egress-proxy \
|
# ~/.mitmproxy mitmproxy CA (cert+key concat) + the
|
||||||
&& chown -R mitmproxy:mitmproxy /etc/egress-proxy /app
|
# pipelock CA (cert only, for upstream
|
||||||
|
# trust on the HTTPS_PROXY=pipelock leg)
|
||||||
|
# Ownership lets the unprivileged mitmproxy user read the files.
|
||||||
|
RUN mkdir -p /etc/egress-proxy /home/mitmproxy/.mitmproxy \
|
||||||
|
&& chown -R mitmproxy:mitmproxy /etc/egress-proxy /home/mitmproxy/.mitmproxy /app
|
||||||
|
|
||||||
USER mitmproxy
|
USER mitmproxy
|
||||||
|
|
||||||
# Listening port. Agents will dial egress-proxy on this port via
|
# Listening port. Agents dial egress-proxy on this port via their
|
||||||
# their HTTP_PROXY env (chunk 2). Surfaced as EXPOSE for
|
# HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required
|
||||||
# documentation; not required for the internal network to route to it.
|
# for the internal network to route to it.
|
||||||
EXPOSE 9099
|
EXPOSE 9099
|
||||||
|
|
||||||
# --mode regular@9099: standard HTTP/HTTPS forward proxy on :9099.
|
# Entrypoint:
|
||||||
# -s /app/egress_proxy_addon.py: loads our addon, which reads the
|
# --mode regular@9099 standard HTTP/HTTPS forward proxy on :9099.
|
||||||
# route table from /etc/egress-proxy/routes.yaml.
|
# --set ssl_verify_upstream_trusted_ca=... only when
|
||||||
# (Upstream-trust + CA-cert hooks land in chunk 2 when the per-bottle
|
# EGRESS_PROXY_UPSTREAM_CA env is set (the backend's start step
|
||||||
# pipelock CA wiring moves over from cred-proxy.)
|
# sets it to the in-container pipelock-CA path when pipelock is
|
||||||
ENTRYPOINT ["mitmdump", "--mode", "regular@9099", "-s", "/app/egress_proxy_addon.py"]
|
# present, so the upstream leg trusts pipelock's MITM). The
|
||||||
|
# ${VAR:+expansion} form omits the flag when the var is unset
|
||||||
|
# or empty — useful for standalone runs of the image (e.g. unit
|
||||||
|
# tests) where no upstream CA is mounted.
|
||||||
|
# -s /app/egress_proxy_addon.py loads our addon, which reads the
|
||||||
|
# route table from /etc/egress-proxy/routes.yaml.
|
||||||
|
ENTRYPOINT ["sh", "-c", "exec mitmdump --mode regular@9099 ${EGRESS_PROXY_UPSTREAM_CA:+--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA} -s /app/egress_proxy_addon.py"]
|
||||||
|
|||||||
@@ -219,26 +219,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
argv.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → git →
|
Default orchestration: ca → prompt → skills → git →
|
||||||
cred_proxy. CA install runs first so the agent's trust store
|
supervise. CA install runs first so the agent's trust store
|
||||||
is rebuilt before anything inside the agent makes a TLS call.
|
is rebuilt before anything inside the agent makes a TLS call.
|
||||||
cred_proxy runs last because it appends to ~/.gitconfig (which
|
Subclasses typically don't override this; they implement the
|
||||||
provision_git writes). Subclasses typically don't override
|
sub-methods below.
|
||||||
this; they implement the sub-methods below."""
|
|
||||||
|
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||||
|
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||||
|
on the agent's HTTP_PROXY path so every tool that respects
|
||||||
|
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||||
|
intercepted without per-tool reconfiguration."""
|
||||||
self.provision_ca(plan, target)
|
self.provision_ca(plan, target)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, target)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, target)
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, target)
|
||||||
self.provision_cred_proxy(plan, target)
|
|
||||||
self.provision_supervise(plan, target)
|
self.provision_supervise(plan, target)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
def provision_ca(self, plan: PlanT, target: str) -> None:
|
def provision_ca(self, plan: PlanT, target: str) -> None:
|
||||||
"""Install pipelock's per-bottle CA into the agent's trust
|
"""Install the per-bottle CA into the agent's trust store so
|
||||||
store so the agent trusts the bumped CONNECT cert pipelock
|
the agent trusts the bumped CONNECT cert egress-proxy (was
|
||||||
presents. Default impl is a no-op so backends that don't
|
pipelock, pre-PRD-0017) presents. Default impl is a no-op so
|
||||||
yet support TLS interception (every backend except Docker
|
backends that don't yet support TLS interception (every backend
|
||||||
today) aren't forced to implement it. The Docker backend
|
except Docker today) aren't forced to implement it. The Docker
|
||||||
overrides to docker-cp the cert in and run
|
backend overrides to docker-cp the cert in and run
|
||||||
`update-ca-certificates`."""
|
`update-ca-certificates`."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -258,12 +262,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""Copy the host's cwd `.git` directory into the running
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
bottle if the user requested --cwd. No-op otherwise."""
|
||||||
|
|
||||||
def provision_cred_proxy(self, plan: PlanT, target: str) -> None:
|
|
||||||
"""Drop the cred-proxy agent-side dotfiles (.npmrc,
|
|
||||||
.gitconfig insteadOf, ~/.config/tea/config.yml) per PRD 0010.
|
|
||||||
Default impl is a no-op for backends that don't yet support
|
|
||||||
the cred-proxy sidecar; the Docker backend overrides."""
|
|
||||||
|
|
||||||
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
def provision_supervise(self, plan: PlanT, target: str) -> None:
|
||||||
"""Write the in-bottle Claude Code MCP config so the agent
|
"""Write the in-bottle Claude Code MCP config so the agent
|
||||||
discovers the per-bottle supervise sidecar (PRD 0013).
|
discovers the per-bottle supervise sidecar (PRD 0013).
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ from . import prepare as _prepare
|
|||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .cred_proxy import DockerCredProxy
|
from .egress_proxy import DockerEgressProxy
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .provision import ca as _ca
|
from .provision import ca as _ca
|
||||||
from .provision import cred_proxy as _cred_proxy
|
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
from .provision import prompt as _prompt
|
||||||
from .provision import skills as _skills
|
from .provision import skills as _skills
|
||||||
@@ -44,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._git_gate = DockerGitGate()
|
self._git_gate = DockerGitGate()
|
||||||
self._cred_proxy = DockerCredProxy()
|
self._egress_proxy = DockerEgressProxy()
|
||||||
self._supervise = DockerSupervise()
|
self._supervise = DockerSupervise()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
@@ -53,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
cred_proxy=self._cred_proxy,
|
egress_proxy=self._egress_proxy,
|
||||||
supervise=self._supervise,
|
supervise=self._supervise,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
plan,
|
plan,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
cred_proxy=self._cred_proxy,
|
egress_proxy=self._egress_proxy,
|
||||||
supervise=self._supervise,
|
supervise=self._supervise,
|
||||||
provision=self.provision,
|
provision=self.provision,
|
||||||
) as bottle:
|
) as bottle:
|
||||||
@@ -81,9 +80,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, target)
|
||||||
|
|
||||||
def provision_cred_proxy(self, plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
_cred_proxy.provision_cred_proxy(plan, target)
|
|
||||||
|
|
||||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_supervise_prov.provision_supervise(plan, target)
|
_supervise_prov.provision_supervise(plan, target)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...cred_proxy import CredProxyPlan
|
from ...egress_proxy import EgressProxyPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
@@ -58,7 +58,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
cred_proxy_plan: CredProxyPlan
|
egress_proxy_plan: EgressProxyPlan
|
||||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||||
# is opt-in via the manifest's bottle.supervise field.
|
# is opt-in via the manifest's bottle.supervise field.
|
||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan | None
|
||||||
@@ -72,10 +72,11 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
# The agent sees the union of literal env names (rendered into
|
# The agent sees the union of literal env names (rendered into
|
||||||
# --env-file) and forwarded env names (`-e NAME` with the value
|
# --env-file) and forwarded env names (`-e NAME` with the value
|
||||||
# arriving via subprocess env). The forwarded set already
|
# arriving via subprocess env). The forwarded set holds the
|
||||||
# reflects PRD 0010's switch — when cred-proxy holds the
|
# OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
|
||||||
# anthropic token, CLAUDE_CODE_OAUTH_TOKEN is absent and
|
# interpolations from the manifest; egress-proxy holds upstream
|
||||||
# ANTHROPIC_BASE_URL is present.
|
# tokens in its own environ, so no token forwarding from the
|
||||||
|
# agent to the proxy is needed.
|
||||||
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
|
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
|
||||||
return _PlanView(
|
return _PlanView(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
@@ -120,16 +121,25 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
info(f" git gate : {'; '.join(git_lines)}")
|
info(f" git gate : {'; '.join(git_lines)}")
|
||||||
else:
|
else:
|
||||||
info(" git remotes : (none)")
|
info(" git remotes : (none)")
|
||||||
if self.cred_proxy_plan.routes:
|
if self.egress_proxy_plan.routes:
|
||||||
lines = [f"{r.path}→{r.upstream}" for r in self.cred_proxy_plan.routes]
|
lines = []
|
||||||
refs = sorted({r.token_ref for r in self.cred_proxy_plan.routes})
|
for r in self.egress_proxy_plan.routes:
|
||||||
info(f" cred-proxy : {len(lines)} route(s); tokens: {', '.join(refs)}")
|
paths = (
|
||||||
|
" " + ",".join(r.path_allowlist) if r.path_allowlist else ""
|
||||||
|
)
|
||||||
|
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||||
|
lines.append(f"{r.host}{auth}{paths}")
|
||||||
|
refs = sorted({r.token_ref for r in self.egress_proxy_plan.routes if r.token_ref})
|
||||||
|
tokens_part = (
|
||||||
|
f"; tokens: {', '.join(refs)}" if refs else ""
|
||||||
|
)
|
||||||
|
info(f" egress-proxy : {len(lines)} route(s){tokens_part}")
|
||||||
for line in lines:
|
for line in lines:
|
||||||
info(f" {line}")
|
info(f" {line}")
|
||||||
else:
|
else:
|
||||||
info(" cred-proxy : (none)")
|
info(" egress-proxy : (none)")
|
||||||
info(f" egress : {self.allowlist_summary}")
|
info(f" egress : {self.allowlist_summary}")
|
||||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
info(" tls intercept : egress-proxy (per-bottle ephemeral CA, generated at launch)")
|
||||||
if self.supervise_plan is not None:
|
if self.supervise_plan is not None:
|
||||||
info(
|
info(
|
||||||
f" supervise : enabled; queue at {self.supervise_plan.queue_dir}"
|
f" supervise : enabled; queue at {self.supervise_plan.queue_dir}"
|
||||||
@@ -166,23 +176,22 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
}
|
}
|
||||||
for u in self.git_gate_plan.upstreams
|
for u in self.git_gate_plan.upstreams
|
||||||
],
|
],
|
||||||
"cred_proxy": [
|
"egress_proxy": [
|
||||||
{
|
{
|
||||||
"path": r.path,
|
"host": r.host,
|
||||||
"upstream": r.upstream,
|
"path_allowlist": list(r.path_allowlist),
|
||||||
"auth_scheme": r.auth_scheme,
|
"auth_scheme": r.auth_scheme,
|
||||||
"token_ref": r.token_ref,
|
"token_ref": r.token_ref,
|
||||||
"roles": list(r.roles),
|
|
||||||
}
|
}
|
||||||
for r in self.cred_proxy_plan.routes
|
for r in self.egress_proxy_plan.routes
|
||||||
],
|
],
|
||||||
"egress": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
# PRD 0006: pipelock's `tls_interception` block is on
|
# PRD 0017: TLS interception moved from pipelock to
|
||||||
# for every launched bottle. ca_fingerprint is always
|
# egress-proxy. ca_fingerprint is always null at
|
||||||
# null at dry-run because the CA doesn't exist yet —
|
# dry-run because the CA doesn't exist yet — real
|
||||||
# real launches print the fingerprint to stderr from
|
# launches print the fingerprint to stderr from
|
||||||
# provision_ca. Reserved field for forward-compat.
|
# provision_ca. Reserved field for forward-compat.
|
||||||
"tls_interception": {
|
"tls_interception": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle
|
|
||||||
cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare
|
|
||||||
step (route lift + routes.json render + token-env-map derivation)
|
|
||||||
from `CredProxy`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...cred_proxy import (
|
|
||||||
CRED_PROXY_HOSTNAME,
|
|
||||||
CredProxy,
|
|
||||||
CredProxyPlan,
|
|
||||||
cred_proxy_resolve_token_values,
|
|
||||||
)
|
|
||||||
from ...log import die, info, warn
|
|
||||||
from . import util as docker_mod
|
|
||||||
|
|
||||||
|
|
||||||
CRED_PROXY_IMAGE = os.environ.get(
|
|
||||||
"CLAUDE_BOTTLE_CRED_PROXY_IMAGE",
|
|
||||||
"claude-bottle-cred-proxy:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy"
|
|
||||||
|
|
||||||
# Listening port inside the sidecar. The agent dials cred-proxy on
|
|
||||||
# this port; surfaced as a constant so the provisioner and tests can
|
|
||||||
# both reference it.
|
|
||||||
CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099"))
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
|
|
||||||
|
|
||||||
# In-container path for the per-bottle pipelock CA. Alpine's
|
|
||||||
# update-ca-certificates picks anything ending in `.crt` under
|
|
||||||
# /usr/local/share/ca-certificates/ and folds it into the system
|
|
||||||
# trust store at boot — so cred-proxy's HTTPS client trusts
|
|
||||||
# pipelock's bumped certs when pipelock MITMs the outbound leg.
|
|
||||||
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER = "/usr/local/share/ca-certificates/pipelock.crt"
|
|
||||||
|
|
||||||
# Repo root, for `docker build` context. Resolved from this file's
|
|
||||||
# location: claude_bottle/backend/docker/cred_proxy.py → repo root.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_container_name(slug: str) -> str:
|
|
||||||
return f"claude-bottle-cred-proxy-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_url() -> str:
|
|
||||||
"""Base URL the agent dials. Stable across bottles because the
|
|
||||||
sidecar attaches `--network-alias cred-proxy` on the internal
|
|
||||||
network; the container name (which carries the slug) is not
|
|
||||||
referenced by agent-side config."""
|
|
||||||
return f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_cred_proxy_image() -> None:
|
|
||||||
"""Build the cred-proxy image from `Dockerfile.cred-proxy`.
|
|
||||||
Called by `DockerCredProxy.start`; exposed at module level so
|
|
||||||
integration tests can build it without running the full launch
|
|
||||||
pipeline."""
|
|
||||||
docker_mod.build_image(CRED_PROXY_IMAGE, _REPO_DIR, dockerfile=CRED_PROXY_DOCKERFILE)
|
|
||||||
|
|
||||||
|
|
||||||
class DockerCredProxy(CredProxy):
|
|
||||||
"""Brings the cred-proxy sidecar up and down via Docker."""
|
|
||||||
|
|
||||||
def start(self, plan: CredProxyPlan) -> str:
|
|
||||||
"""Boot the cred-proxy sidecar:
|
|
||||||
1. Resolve every host TokenRef env var into a concrete
|
|
||||||
value. Fails early if any are unset.
|
|
||||||
2. Build the cred-proxy image (no-op when cache is hot).
|
|
||||||
3. `docker create` on the internal network with
|
|
||||||
`--network-alias cred-proxy` and one `-e CRED_PROXY_TOKEN_N`
|
|
||||||
flag per route. The values arrive via subprocess env, so
|
|
||||||
they never land on argv.
|
|
||||||
4. `docker cp` the routes.json into the container.
|
|
||||||
5. Attach to the per-agent egress network so the proxy can
|
|
||||||
reach the real upstream over HTTPS.
|
|
||||||
6. `docker start`.
|
|
||||||
Returns the container name (the target passed to `.stop`)."""
|
|
||||||
if not plan.routes:
|
|
||||||
die("DockerCredProxy.start called with no routes; caller should skip")
|
|
||||||
if not plan.internal_network or not plan.egress_network:
|
|
||||||
die(
|
|
||||||
"DockerCredProxy.start: internal_network / egress_network must be "
|
|
||||||
"populated on the plan before start"
|
|
||||||
)
|
|
||||||
if not plan.routes_path.is_file():
|
|
||||||
die(
|
|
||||||
f"cred-proxy routes file missing at {plan.routes_path}; "
|
|
||||||
f"CredProxy.prepare must run first"
|
|
||||||
)
|
|
||||||
# pipelock fields are populated by launch.py in production; both
|
|
||||||
# must be present (URL + CA) or both absent. Mixing is a wiring
|
|
||||||
# bug. Both-absent is supported only as a test escape hatch:
|
|
||||||
# the integration tests in tests/integration/ exercise header
|
|
||||||
# injection in isolation and do not bring pipelock up.
|
|
||||||
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
|
|
||||||
if route_via_pipelock:
|
|
||||||
if not plan.pipelock_proxy_url:
|
|
||||||
die(
|
|
||||||
"DockerCredProxy.start: pipelock_ca_host_path is set but "
|
|
||||||
"pipelock_proxy_url is empty; populate both or neither."
|
|
||||||
)
|
|
||||||
if not plan.pipelock_ca_host_path.is_file():
|
|
||||||
die(
|
|
||||||
f"DockerCredProxy.start: pipelock CA missing at "
|
|
||||||
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve host env vars into concrete values. This must
|
|
||||||
# happen at start time (not prepare) — the values flow into
|
|
||||||
# the sidecar's environ via subprocess env. The plan never
|
|
||||||
# holds them.
|
|
||||||
token_values = cred_proxy_resolve_token_values(plan.token_env_map, dict(os.environ))
|
|
||||||
|
|
||||||
build_cred_proxy_image()
|
|
||||||
|
|
||||||
name = cred_proxy_container_name(plan.slug)
|
|
||||||
info(f"starting cred-proxy sidecar {name} on network {plan.internal_network}")
|
|
||||||
|
|
||||||
create_args = [
|
|
||||||
"docker", "create",
|
|
||||||
"--name", name,
|
|
||||||
"--network", plan.internal_network,
|
|
||||||
"--network-alias", CRED_PROXY_HOSTNAME,
|
|
||||||
]
|
|
||||||
if route_via_pipelock:
|
|
||||||
# Route cred-proxy's outbound HTTPS through pipelock so
|
|
||||||
# the egress allowlist + DLP body scanner apply to its
|
|
||||||
# traffic. Pipelock MITMs each handshake with the
|
|
||||||
# per-bottle CA we docker cp in below.
|
|
||||||
create_args.extend([
|
|
||||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
|
||||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
|
||||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
|
||||||
])
|
|
||||||
# One -e flag per token slot; values arrive via subprocess env.
|
|
||||||
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
|
||||||
# current process env at create time. We pass `env=child_env`
|
|
||||||
# to subprocess.run so the value comes from token_values, not
|
|
||||||
# the host's os.environ directly — keeps the resolver in one
|
|
||||||
# place and lets cred_proxy_resolve_token_values surface
|
|
||||||
# missing-env errors with a clear hint.
|
|
||||||
for token_env in sorted(plan.token_env_map.keys()):
|
|
||||||
create_args.extend(["-e", token_env])
|
|
||||||
create_args.append(CRED_PROXY_IMAGE)
|
|
||||||
|
|
||||||
child_env: dict[str, str] = {**os.environ, **token_values}
|
|
||||||
|
|
||||||
create_result = subprocess.run(
|
|
||||||
create_args, capture_output=True, text=True, env=child_env, check=False,
|
|
||||||
)
|
|
||||||
if create_result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"failed to create cred-proxy sidecar {name}: "
|
|
||||||
f"{create_result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cps: list[tuple[str, str, str]] = [
|
|
||||||
(str(plan.routes_path), CRED_PROXY_ROUTES_IN_CONTAINER, "routes.json"),
|
|
||||||
]
|
|
||||||
if route_via_pipelock:
|
|
||||||
# CA must land BEFORE `docker start` so the entrypoint's
|
|
||||||
# update-ca-certificates picks it up. Docker cp's the
|
|
||||||
# file in even on the stopped container — that's the
|
|
||||||
# whole reason this works without a custom build step.
|
|
||||||
cps.append((
|
|
||||||
str(plan.pipelock_ca_host_path),
|
|
||||||
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER,
|
|
||||||
"pipelock CA",
|
|
||||||
))
|
|
||||||
for src, dst, label in cps:
|
|
||||||
cp_result = subprocess.run(
|
|
||||||
["docker", "cp", src, f"{name}:{dst}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if cp_result.returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
die(
|
|
||||||
f"failed to copy {label} into {name}: "
|
|
||||||
f"{cp_result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
connect_result = subprocess.run(
|
|
||||||
["docker", "network", "connect", plan.egress_network, name],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if connect_result.returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
die(
|
|
||||||
f"failed to attach cred-proxy sidecar {name} to egress network "
|
|
||||||
f"{plan.egress_network}: {connect_result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
start_result = subprocess.run(
|
|
||||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if start_result.returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
die(
|
|
||||||
f"failed to start cred-proxy sidecar {name}: "
|
|
||||||
f"{start_result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return name
|
|
||||||
|
|
||||||
def stop(self, target: str) -> None:
|
|
||||||
"""Idempotent: missing container is success. `target` is the
|
|
||||||
container name returned by `.start`."""
|
|
||||||
if subprocess.run(
|
|
||||||
["docker", "inspect", target],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
).returncode == 0:
|
|
||||||
if subprocess.run(
|
|
||||||
["docker", "rm", "-f", target],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
).returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"failed to remove cred-proxy sidecar {target}; "
|
|
||||||
f"clean up with 'docker rm -f {target}'"
|
|
||||||
)
|
|
||||||
@@ -22,15 +22,25 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .cred_proxy import (
|
# Constants inlined from the deleted `claude_bottle.backend.docker.
|
||||||
CRED_PROXY_ROUTES_IN_CONTAINER,
|
# cred_proxy` module (PRD 0017 chunk 2 cutover). Chunk 3 retargets
|
||||||
cred_proxy_container_name,
|
# this file at egress-proxy and gets rid of these.
|
||||||
)
|
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _cred_proxy_container_name(slug: str) -> str:
|
||||||
|
return f"claude-bottle-cred-proxy-{slug}"
|
||||||
|
|
||||||
|
|
||||||
class CredProxyApplyError(RuntimeError):
|
class CredProxyApplyError(RuntimeError):
|
||||||
"""Raised when fetch / apply fails. Caller renders to the
|
"""Raised when fetch / apply fails. Caller renders to the
|
||||||
operator; does not crash the dashboard."""
|
operator; does not crash the dashboard.
|
||||||
|
|
||||||
|
PRD 0017 chunk 2 deletes the cred-proxy sidecar; this module's
|
||||||
|
docker-exec calls now hit a non-existent container and raise
|
||||||
|
CredProxyApplyError with a "container not running" message,
|
||||||
|
which the dashboard surfaces to the operator. Chunk 3 retargets
|
||||||
|
everything at egress-proxy."""
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
def fetch_current_routes(slug: str) -> str:
|
||||||
@@ -38,7 +48,7 @@ def fetch_current_routes(slug: str) -> str:
|
|||||||
for `slug`. Returns the file content as a string. Raises
|
for `slug`. Returns the file content as a string. Raises
|
||||||
CredProxyApplyError if the sidecar isn't reachable or the read
|
CredProxyApplyError if the sidecar isn't reachable or the read
|
||||||
fails."""
|
fails."""
|
||||||
container = cred_proxy_container_name(slug)
|
container = _cred_proxy_container_name(slug)
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
|
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
@@ -80,7 +90,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
|||||||
sidecar are unchanged if the failure is before docker cp, and
|
sidecar are unchanged if the failure is before docker cp, and
|
||||||
are reverted in spirit if SIGHUP fails (cp landed but reload
|
are reverted in spirit if SIGHUP fails (cp landed but reload
|
||||||
didn't fire — caller's next attempt will SIGHUP again)."""
|
didn't fire — caller's next attempt will SIGHUP again)."""
|
||||||
container = cred_proxy_container_name(slug)
|
container = _cred_proxy_container_name(slug)
|
||||||
before = fetch_current_routes(slug)
|
before = fetch_current_routes(slug)
|
||||||
validate_routes_json(new_content)
|
validate_routes_json(new_content)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform-
|
|||||||
agnostic prepare step (route lift + routes.yaml render + token-env
|
agnostic prepare step (route lift + routes.yaml render + token-env
|
||||||
map derivation) from `EgressProxy`.
|
map derivation) from `EgressProxy`.
|
||||||
|
|
||||||
Chunk 1 of the PRD: the lifecycle is implemented but not yet called
|
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
|
||||||
from `launch.py`. Tests build the image and exercise start/stop
|
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
|
||||||
directly. Chunk 2 wires this in alongside the cred-proxy removal."""
|
block remediation flow (PRD 0014)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ from ...log import die, info, warn
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
EGRESS_PROXY_IMAGE = os.environ.get(
|
EGRESS_PROXY_IMAGE = os.environ.get(
|
||||||
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE",
|
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE",
|
||||||
"claude-bottle-egress-proxy:latest",
|
"claude-bottle-egress-proxy:latest",
|
||||||
@@ -32,9 +34,19 @@ EGRESS_PROXY_IMAGE = os.environ.get(
|
|||||||
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
|
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
|
||||||
|
|
||||||
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
||||||
# (chunk 2) will resolve to `http://egress-proxy:<port>`.
|
# resolves to `http://egress-proxy:<port>`.
|
||||||
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
|
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
|
||||||
|
|
||||||
|
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||||
|
# file holding BOTH the cert and the private key, concatenated. The
|
||||||
|
# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream
|
||||||
|
# leg) is a separate file because pipelock keeps a different CA on
|
||||||
|
# its end.
|
||||||
|
EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
|
||||||
|
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = (
|
||||||
|
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
||||||
|
)
|
||||||
|
|
||||||
# Repo root, for `docker build` context. Resolved from this file's
|
# Repo root, for `docker build` context. Resolved from this file's
|
||||||
# location: claude_bottle/backend/docker/egress_proxy.py → repo root.
|
# location: claude_bottle/backend/docker/egress_proxy.py → repo root.
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
@@ -62,6 +74,55 @@ def build_egress_proxy_image() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
|
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
|
||||||
|
binary's `tls init` subcommand — a known-good RSA CA minter we
|
||||||
|
already pin and run on this host.
|
||||||
|
|
||||||
|
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||||
|
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||||
|
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
|
||||||
|
- `cert_only_pem` is the cert alone — installed into the agent's
|
||||||
|
trust store by `provision_ca` so the agent trusts the bumped
|
||||||
|
CONNECT cert egress-proxy presents.
|
||||||
|
|
||||||
|
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
|
||||||
|
Private keys never leave the host stage dir until
|
||||||
|
`DockerEgressProxy.start` docker-cps the concat file into the
|
||||||
|
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
|
||||||
|
after teardown.
|
||||||
|
|
||||||
|
Imported lazily inside the function so test patchers in
|
||||||
|
pipelock-land don't need to know about us."""
|
||||||
|
# Local import keeps the module-import graph free of a hard
|
||||||
|
# pipelock-image dependency at top of file (we don't actually
|
||||||
|
# need pipelock's *runtime* here, just its tls-init subcommand).
|
||||||
|
from .pipelock import PIPELOCK_IMAGE
|
||||||
|
work = stage_dir / "egress-proxy-ca"
|
||||||
|
work.mkdir(exist_ok=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "run", "--rm",
|
||||||
|
"-v", f"{work}:/h",
|
||||||
|
"-e", "PIPELOCK_HOME=/h",
|
||||||
|
PIPELOCK_IMAGE, "tls", "init"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
|
||||||
|
cert = work / "ca.pem"
|
||||||
|
key = work / "ca-key.pem"
|
||||||
|
if not cert.is_file() or not key.is_file():
|
||||||
|
die(f"egress-proxy tls init did not produce ca files in {work}")
|
||||||
|
cert.chmod(0o600)
|
||||||
|
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||||
|
mitm = work / "mitmproxy-ca.pem"
|
||||||
|
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
|
||||||
|
mitm.chmod(0o600)
|
||||||
|
return (mitm, cert)
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressProxy(EgressProxy):
|
class DockerEgressProxy(EgressProxy):
|
||||||
"""Brings the egress-proxy sidecar up and down via Docker."""
|
"""Brings the egress-proxy sidecar up and down via Docker."""
|
||||||
|
|
||||||
@@ -71,13 +132,16 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
value. Fails early if any are unset.
|
value. Fails early if any are unset.
|
||||||
2. Build the egress-proxy image (no-op when cache is hot).
|
2. Build the egress-proxy image (no-op when cache is hot).
|
||||||
3. `docker create` on the internal network with
|
3. `docker create` on the internal network with
|
||||||
`--network-alias egress-proxy` and one `-e EGRESS_PROXY_TOKEN_N`
|
`--network-alias egress-proxy`, the `HTTPS_PROXY=pipelock`
|
||||||
flag per token slot. The values arrive via subprocess env, so
|
env (so the upstream leg traverses pipelock), the
|
||||||
they never land on argv.
|
`EGRESS_PROXY_UPSTREAM_CA` env pointing at the in-container
|
||||||
4. `docker cp` the routes.yaml into the container.
|
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
|
||||||
|
and one `-e EGRESS_PROXY_TOKEN_N` flag per token slot.
|
||||||
|
Secret values arrive via subprocess env, never argv.
|
||||||
|
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
|
||||||
|
concat), and pipelock CA (cert only) into the container.
|
||||||
5. Attach to the per-agent egress network so the proxy can
|
5. Attach to the per-agent egress network so the proxy can
|
||||||
reach pipelock (chunk 2 turns this into the pipelock leg
|
reach pipelock.
|
||||||
via HTTPS_PROXY).
|
|
||||||
6. `docker start`.
|
6. `docker start`.
|
||||||
Returns the container name (the target passed to `.stop`)."""
|
Returns the container name (the target passed to `.stop`)."""
|
||||||
if not plan.routes:
|
if not plan.routes:
|
||||||
@@ -92,6 +156,27 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
f"egress-proxy routes file missing at {plan.routes_path}; "
|
f"egress-proxy routes file missing at {plan.routes_path}; "
|
||||||
f"EgressProxy.prepare must run first"
|
f"EgressProxy.prepare must run first"
|
||||||
)
|
)
|
||||||
|
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
|
||||||
|
die(
|
||||||
|
f"DockerEgressProxy.start: mitmproxy CA missing at "
|
||||||
|
f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first"
|
||||||
|
)
|
||||||
|
# pipelock CA + upstream proxy URL: both must be present (we
|
||||||
|
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
|
||||||
|
# upstream leg) or both absent (egress-proxy goes direct, for
|
||||||
|
# standalone integration tests that don't bring pipelock up).
|
||||||
|
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
|
||||||
|
if route_via_pipelock:
|
||||||
|
if not plan.pipelock_proxy_url:
|
||||||
|
die(
|
||||||
|
"DockerEgressProxy.start: pipelock_ca_host_path is set but "
|
||||||
|
"pipelock_proxy_url is empty; populate both or neither."
|
||||||
|
)
|
||||||
|
if not plan.pipelock_ca_host_path.is_file():
|
||||||
|
die(
|
||||||
|
f"DockerEgressProxy.start: pipelock CA missing at "
|
||||||
|
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
||||||
|
)
|
||||||
|
|
||||||
# Resolve host env vars into concrete values. Must happen at
|
# Resolve host env vars into concrete values. Must happen at
|
||||||
# start time (not prepare) — the values flow into the sidecar's
|
# start time (not prepare) — the values flow into the sidecar's
|
||||||
@@ -111,15 +196,19 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
"--network", plan.internal_network,
|
"--network", plan.internal_network,
|
||||||
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
||||||
]
|
]
|
||||||
if plan.pipelock_proxy_url:
|
if route_via_pipelock:
|
||||||
# Route egress-proxy's outbound HTTPS through pipelock so
|
# Route egress-proxy's outbound HTTPS through pipelock so
|
||||||
# the egress allowlist + DLP body scanner apply to its
|
# the egress allowlist + DLP body scanner apply to its
|
||||||
# traffic on the egress-proxy → upstream leg. Wiring lands
|
# traffic on the egress-proxy → upstream leg. Pipelock
|
||||||
# in chunk 2.
|
# MITMs each handshake with its per-bottle CA, which is
|
||||||
|
# docker-cp'd in below and pointed to via the
|
||||||
|
# EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally
|
||||||
|
# adds the matching --set flag).
|
||||||
create_args.extend([
|
create_args.extend([
|
||||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||||
|
"-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}",
|
||||||
])
|
])
|
||||||
# One -e flag per token slot; values arrive via subprocess env.
|
# One -e flag per token slot; values arrive via subprocess env.
|
||||||
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
# docker create with `-e NAME` (no =VALUE) reads NAME from the
|
||||||
@@ -143,24 +232,34 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
f"{create_result.stderr.strip()}"
|
f"{create_result.stderr.strip()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
cp_result = subprocess.run(
|
cps: list[tuple[Path, str, str]] = [
|
||||||
["docker", "cp", str(plan.routes_path),
|
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
|
||||||
f"{name}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"],
|
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
|
||||||
capture_output=True,
|
]
|
||||||
text=True,
|
if route_via_pipelock:
|
||||||
check=False,
|
cps.append((
|
||||||
)
|
plan.pipelock_ca_host_path,
|
||||||
if cp_result.returncode != 0:
|
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER,
|
||||||
subprocess.run(
|
"pipelock CA",
|
||||||
["docker", "rm", "-f", name],
|
))
|
||||||
stdout=subprocess.DEVNULL,
|
for src, dst, label in cps:
|
||||||
stderr=subprocess.DEVNULL,
|
cp_result = subprocess.run(
|
||||||
|
["docker", "cp", str(src), f"{name}:{dst}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
die(
|
if cp_result.returncode != 0:
|
||||||
f"failed to copy routes.yaml into {name}: "
|
subprocess.run(
|
||||||
f"{cp_result.stderr.strip()}"
|
["docker", "rm", "-f", name],
|
||||||
)
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
die(
|
||||||
|
f"failed to copy {label} into {name}: "
|
||||||
|
f"{cp_result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
connect_result = subprocess.run(
|
connect_result = subprocess.run(
|
||||||
["docker", "network", "connect", plan.egress_network, name],
|
["docker", "network", "connect", plan.egress_network, name],
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ from . import network as network_mod
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .cred_proxy import DockerCredProxy
|
from .egress_proxy import (
|
||||||
|
DockerEgressProxy,
|
||||||
|
egress_proxy_tls_init,
|
||||||
|
egress_proxy_url,
|
||||||
|
)
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import (
|
from .pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
@@ -47,7 +51,7 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
cred_proxy: DockerCredProxy,
|
egress_proxy: DockerEgressProxy,
|
||||||
supervise: DockerSupervise,
|
supervise: DockerSupervise,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
@@ -83,19 +87,28 @@ def launch(
|
|||||||
|
|
||||||
# Docker assigns a CIDR to the new internal network. Pipelock's
|
# Docker assigns a CIDR to the new internal network. Pipelock's
|
||||||
# SSRF guard otherwise rejects any destination resolving into
|
# SSRF guard otherwise rejects any destination resolving into
|
||||||
# RFC1918 space — which includes the cred-proxy / git-gate /
|
# RFC1918 space — which includes the sibling sidecars
|
||||||
# pipelock sidecars themselves. Allowlist the bottle's own
|
# (egress-proxy → pipelock on the upstream leg, etc.).
|
||||||
# internal subnet so the agent can reach its sidecars via
|
# Allowlist the bottle's own internal subnet so internal
|
||||||
# pipelock; api_allowlist + body-scanning still apply.
|
# traffic passes through pipelock; api_allowlist + body-scanning
|
||||||
|
# still apply.
|
||||||
internal_cidr = network_mod.network_inspect_cidr(internal_network)
|
internal_cidr = network_mod.network_inspect_cidr(internal_network)
|
||||||
|
|
||||||
# Per-bottle ephemeral CA for pipelock's TLS interception
|
# Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two
|
||||||
# (PRD 0006). One-shot pipelock container writes ca.pem +
|
# separate CAs:
|
||||||
# ca-key.pem under plan.stage_dir; .start docker-cp's them
|
# - pipelock CA: signs MITM certs pipelock presents on the
|
||||||
# into the sidecar. The private key never leaves the host
|
# egress-proxy → upstream leg.
|
||||||
# stage dir, which start.py's outer finally `shutil.rmtree`s
|
# - egress-proxy CA: signs MITM certs egress-proxy presents
|
||||||
# after the sidecar is torn down.
|
# to the agent on the agent → egress-proxy leg.
|
||||||
|
# Both are minted by one-shot pipelock containers (pipelock's
|
||||||
|
# `tls init` is a known-good RSA CA minter) under stage_dir;
|
||||||
|
# the .start steps docker-cp the files in. Private keys never
|
||||||
|
# leave the host stage dir, which start.py's outer finally
|
||||||
|
# `shutil.rmtree`s after the sidecars are torn down.
|
||||||
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
|
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
|
||||||
|
egress_proxy_ca_host, egress_proxy_ca_cert_only = egress_proxy_tls_init(
|
||||||
|
plan.stage_dir,
|
||||||
|
)
|
||||||
|
|
||||||
# Re-render the pipelock yaml with the SSRF allowlist now that
|
# Re-render the pipelock yaml with the SSRF allowlist now that
|
||||||
# we know the internal CIDR. Prepare wrote the yaml without
|
# we know the internal CIDR. Prepare wrote the yaml without
|
||||||
@@ -141,26 +154,28 @@ def launch(
|
|||||||
git_gate_name = git_gate.start(plan.git_gate_plan)
|
git_gate_name = git_gate.start(plan.git_gate_plan)
|
||||||
stack.callback(git_gate.stop, git_gate_name)
|
stack.callback(git_gate.stop, git_gate_name)
|
||||||
|
|
||||||
# Cred-proxy (PRD 0010). One sidecar per bottle when
|
# Egress-proxy (PRD 0017). One sidecar per bottle when
|
||||||
# bottle.cred_proxy.routes is non-empty. Must come up AFTER pipelock
|
# bottle.egress_proxy.routes is non-empty. Must come up AFTER
|
||||||
# — cred-proxy routes its outbound HTTPS through pipelock
|
# pipelock — egress-proxy routes its outbound HTTPS through
|
||||||
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
|
# pipelock (HTTPS_PROXY in environ + the pipelock CA in its
|
||||||
# store) so the egress allowlist + body scanner sit in the
|
# trust store) so the egress allowlist + body scanner sit on
|
||||||
# cred-proxy path too. Must come up BEFORE the agent so DNS
|
# the egress-proxy → upstream leg. Must come up BEFORE the
|
||||||
# resolution for `cred-proxy` succeeds on the agent's first
|
# agent so DNS resolution for `egress-proxy` succeeds on the
|
||||||
# call; tokens flow from the host env into the sidecar's
|
# agent's first call; tokens flow from the host env into the
|
||||||
# environ, not the agent's.
|
# sidecar's environ, not the agent's.
|
||||||
if plan.cred_proxy_plan.routes:
|
if plan.egress_proxy_plan.routes:
|
||||||
cred_proxy_plan = dataclasses.replace(
|
egress_proxy_plan = dataclasses.replace(
|
||||||
plan.cred_proxy_plan,
|
plan.egress_proxy_plan,
|
||||||
internal_network=internal_network,
|
internal_network=internal_network,
|
||||||
egress_network=egress_network,
|
egress_network=egress_network,
|
||||||
|
mitmproxy_ca_host_path=egress_proxy_ca_host,
|
||||||
|
mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only,
|
||||||
pipelock_ca_host_path=ca_cert_host,
|
pipelock_ca_host_path=ca_cert_host,
|
||||||
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
||||||
)
|
)
|
||||||
plan = dataclasses.replace(plan, cred_proxy_plan=cred_proxy_plan)
|
plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan)
|
||||||
cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan)
|
egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan)
|
||||||
stack.callback(cred_proxy.stop, cred_proxy_name)
|
stack.callback(egress_proxy.stop, egress_proxy_name)
|
||||||
|
|
||||||
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
|
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
|
||||||
# Internal-network only — the sidecar makes no outbound calls.
|
# Internal-network only — the sidecar makes no outbound calls.
|
||||||
@@ -208,11 +223,23 @@ def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
|||||||
return ",".join(hosts)
|
return ",".join(hosts)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||||
|
"""Pick the proxy URL the agent's HTTP_PROXY env points at. PRD
|
||||||
|
0017: when an egress-proxy is declared, the agent goes through
|
||||||
|
egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its
|
||||||
|
outbound leg). Otherwise the agent talks straight to pipelock —
|
||||||
|
keeps the network surface minimal for bottles that don't need
|
||||||
|
path filtering or credential injection."""
|
||||||
|
if plan.egress_proxy_plan.routes:
|
||||||
|
return egress_proxy_url()
|
||||||
|
return pipelock_proxy_url(plan.slug)
|
||||||
|
|
||||||
|
|
||||||
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
||||||
"""Build the `docker run` argv and execute it, handling name-
|
"""Build the `docker run` argv and execute it, handling name-
|
||||||
conflict races by incrementing the suffix (unless the name was
|
conflict races by incrementing the suffix (unless the name was
|
||||||
user-pinned). Returns the resolved container name."""
|
user-pinned). Returns the resolved container name."""
|
||||||
proxy_url = pipelock_proxy_url(plan.slug)
|
proxy_url = _agent_proxy_url(plan)
|
||||||
docker_args: list[str] = [
|
docker_args: list[str] = [
|
||||||
"--rm", "-d",
|
"--rm", "-d",
|
||||||
"--name", plan.container_name,
|
"--name", plan.container_name,
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ... import pipelock
|
from ... import pipelock
|
||||||
from ...cred_proxy import cred_proxy_render_routes
|
from ...egress_proxy import egress_proxy_render_routes
|
||||||
from ...env import ResolvedEnv, resolve_env
|
from ...env import ResolvedEnv, resolve_env
|
||||||
from ...log import die
|
from ...log import die
|
||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .cred_proxy import (
|
from .egress_proxy import DockerEgressProxy, egress_proxy_container_name
|
||||||
DockerCredProxy,
|
|
||||||
cred_proxy_container_name,
|
|
||||||
cred_proxy_url,
|
|
||||||
)
|
|
||||||
from .git_gate import DockerGitGate, git_gate_container_name
|
from .git_gate import DockerGitGate, git_gate_container_name
|
||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
@@ -46,7 +42,7 @@ def resolve_plan(
|
|||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
cred_proxy: DockerCredProxy,
|
egress_proxy: DockerEgressProxy,
|
||||||
supervise: DockerSupervise,
|
supervise: DockerSupervise,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
@@ -127,15 +123,15 @@ def resolve_plan(
|
|||||||
# surface as a docker-create conflict deep inside launch() with no
|
# surface as a docker-create conflict deep inside launch() with no
|
||||||
# actionable hint. Fail fast here with a cleanup pointer instead.
|
# actionable hint. Fail fast here with a cleanup pointer instead.
|
||||||
# Only probe sidecars this launch will actually try to create:
|
# Only probe sidecars this launch will actually try to create:
|
||||||
# pipelock always; git-gate when bottle.git is non-empty; cred-proxy
|
# pipelock always; git-gate when bottle.git is non-empty;
|
||||||
# when bottle.cred_proxy.routes is non-empty.
|
# egress-proxy when bottle.egress_proxy.routes is non-empty.
|
||||||
sidecar_probes: list[tuple[str, str]] = [
|
sidecar_probes: list[tuple[str, str]] = [
|
||||||
("pipelock", pipelock_container_name(slug)),
|
("pipelock", pipelock_container_name(slug)),
|
||||||
]
|
]
|
||||||
if bottle.git:
|
if bottle.git:
|
||||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
||||||
if bottle.cred_proxy.routes:
|
if bottle.egress_proxy.routes:
|
||||||
sidecar_probes.append(("cred-proxy", cred_proxy_container_name(slug)))
|
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug)))
|
||||||
if bottle.supervise:
|
if bottle.supervise:
|
||||||
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
||||||
for label, sidecar_name in sidecar_probes:
|
for label, sidecar_name in sidecar_probes:
|
||||||
@@ -154,10 +150,13 @@ def resolve_plan(
|
|||||||
|
|
||||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||||
cred_proxy_plan = cred_proxy.prepare(bottle, slug, stage_dir)
|
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
|
||||||
supervise_plan = None
|
supervise_plan = None
|
||||||
if bottle.supervise:
|
if bottle.supervise:
|
||||||
routes_content = cred_proxy_render_routes(cred_proxy_plan.routes) if cred_proxy_plan.routes else ""
|
routes_content = (
|
||||||
|
egress_proxy_render_routes(egress_proxy_plan.routes)
|
||||||
|
if egress_proxy_plan.routes else ""
|
||||||
|
)
|
||||||
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
|
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
|
||||||
# Current Dockerfile for the agent image. Read from the repo
|
# Current Dockerfile for the agent image. Read from the repo
|
||||||
# root; for `--cwd` derived images the base Dockerfile is what
|
# root; for `--cwd` derived images the base Dockerfile is what
|
||||||
@@ -176,36 +175,21 @@ def resolve_plan(
|
|||||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||||
# mutates the host os.environ.
|
# mutates the host os.environ.
|
||||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||||
# Find the (at most one) cred-proxy route claiming the
|
# When the bottle declares an egress-proxy route for the Anthropic
|
||||||
# anthropic-base-url role. Manifest validation enforces the
|
# OAuth flow, claude-code's outbound Authorization gets stripped +
|
||||||
# singleton constraint. cred-proxy is the only path the Anthropic
|
# re-injected by egress-proxy. The agent's environ still needs
|
||||||
# OAuth token reaches the bottle — there is no fallback that
|
# *something* claude-code recognises as a credential or it refuses
|
||||||
# forwards it into the agent's environ directly. Bottles that
|
# to start; ship a non-secret placeholder. The placeholder is not
|
||||||
# need claude-code to authenticate must declare an
|
# any real `auth.token_ref` value, so leaking it would tell an
|
||||||
# anthropic-base-url route.
|
# attacker only that egress-proxy is in front.
|
||||||
anthropic_route = next(
|
has_anthropic_auth = any(
|
||||||
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles),
|
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
|
||||||
None,
|
for r in egress_proxy_plan.routes
|
||||||
)
|
)
|
||||||
if anthropic_route is not None:
|
if has_anthropic_auth:
|
||||||
# Point claude-code at the cred-proxy. The sidecar holds the
|
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
|
||||||
# OAuth token; the agent's environ does not. Strip the
|
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||||
# trailing slash so claude-code's path-join produces e.g.
|
# error reporting) that egress-proxy can't gate by auth.
|
||||||
# http://cred-proxy:9099/anthropic/v1/messages.
|
|
||||||
forwarded_env["ANTHROPIC_BASE_URL"] = (
|
|
||||||
f"{cred_proxy_url()}{anthropic_route.path}".rstrip("/")
|
|
||||||
)
|
|
||||||
# claude-code refuses to start without *some* credential in
|
|
||||||
# its env. The proxy strips inbound Authorization on every
|
|
||||||
# request and injects the real one — so a non-secret
|
|
||||||
# placeholder is sufficient and the SC1 test still holds
|
|
||||||
# (the placeholder is not a `cred_proxy.routes[].TokenRef`
|
|
||||||
# value). The agent cannot exfiltrate this string because
|
|
||||||
# it carries no meaning to api.anthropic.com.
|
|
||||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder"
|
|
||||||
# Belt-and-braces: turn off telemetry endpoints that don't
|
|
||||||
# route through ANTHROPIC_BASE_URL (statsig, error reporting).
|
|
||||||
# PRD 0010 open question default.
|
|
||||||
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||||
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
||||||
_write_env_file(resolved, env_file)
|
_write_env_file(resolved, env_file)
|
||||||
@@ -229,7 +213,7 @@ def resolve_plan(
|
|||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
cred_proxy_plan=cred_proxy_plan,
|
egress_proxy_plan=egress_proxy_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""Install pipelock's per-bottle CA into the agent container's trust
|
"""Install the per-bottle MITM CA into the agent container's trust
|
||||||
store (PRD 0006).
|
store.
|
||||||
|
|
||||||
By the time this provisioner runs, `pipelock_tls_init` has generated
|
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target:
|
||||||
a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar
|
|
||||||
is up with `tls_interception: { enabled: true }` referencing the
|
- Bottle declares `egress_proxy.routes[]` → agent's HTTP_PROXY
|
||||||
in-container CA paths. This step makes the agent trust certs signed
|
points at egress-proxy; the cert the agent must trust is the
|
||||||
by that CA so the agent's TLS handshake with the bumped CONNECT
|
one egress-proxy mints leaf certs with (the egress-proxy CA).
|
||||||
succeeds.
|
- No egress_proxy routes → agent's HTTP_PROXY points straight at
|
||||||
|
pipelock; the cert the agent must trust is pipelock's CA (the
|
||||||
|
pre-cutover behavior).
|
||||||
|
|
||||||
|
By the time this provisioner runs, the corresponding `tls_init`
|
||||||
|
helper has generated the chosen CA under `plan.stage_dir`, and the
|
||||||
|
sidecar (pipelock or egress-proxy) is up referencing the
|
||||||
|
in-container CA paths.
|
||||||
|
|
||||||
Cert lands on Debian's standard source path
|
Cert lands on Debian's standard source path
|
||||||
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
|
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
|
||||||
@@ -27,6 +34,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from ....log import info
|
from ....log import info
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
@@ -35,26 +43,42 @@ from ..bottle_plan import DockerBottlePlan
|
|||||||
# Debian-family path for sources that `update-ca-certificates` reads.
|
# Debian-family path for sources that `update-ca-certificates` reads.
|
||||||
# Bundle path is what the command rebuilds and what every standard
|
# Bundle path is what the command rebuilds and what every standard
|
||||||
# TLS consumer in the image reads.
|
# TLS consumer in the image reads.
|
||||||
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-pipelock-ca.crt"
|
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
|
||||||
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
|
||||||
|
|
||||||
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
|
||||||
"""Copy pipelock's CA cert into the agent, rebuild the trust
|
"""Pick the CA cert (and a short label for the log line) that
|
||||||
bundle, emit a one-line fingerprint log. Called from
|
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
|
||||||
`BottleBackend.provision` after the agent container is up."""
|
wins when the bottle declares any routes (it sits in front of
|
||||||
container = target
|
pipelock); else pipelock."""
|
||||||
cert_host_path = plan.proxy_plan.ca_cert_host_path
|
if plan.egress_proxy_plan.routes:
|
||||||
if not cert_host_path or not cert_host_path.is_file():
|
cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path
|
||||||
# Defensive: provision runs after launch wires CA paths
|
if cert == Path() or not cert.is_file():
|
||||||
# onto the plan via dataclasses.replace; an empty path here
|
from ....log import die
|
||||||
# would mean that wiring was skipped.
|
die(
|
||||||
|
f"egress-proxy CA cert missing at {cert or '(empty)'}; "
|
||||||
|
f"launch must have called egress_proxy_tls_init and "
|
||||||
|
f"re-bound the plan before provision"
|
||||||
|
)
|
||||||
|
return cert, "egress-proxy"
|
||||||
|
cert = plan.proxy_plan.ca_cert_host_path
|
||||||
|
if not cert or not cert.is_file():
|
||||||
from ....log import die
|
from ....log import die
|
||||||
die(
|
die(
|
||||||
f"pipelock CA cert missing at {cert_host_path or '(empty)'}; "
|
f"pipelock CA cert missing at {cert or '(empty)'}; "
|
||||||
f"launch must have called pipelock_tls_init and re-bound "
|
f"launch must have called pipelock_tls_init and re-bound "
|
||||||
f"the plan before provision"
|
f"the plan before provision"
|
||||||
)
|
)
|
||||||
|
return cert, "pipelock"
|
||||||
|
|
||||||
|
|
||||||
|
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
"""Copy the agent-facing 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, label = _select_ca_cert(plan)
|
||||||
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
||||||
@@ -76,4 +100,4 @@ def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
# fingerprint form. Never the private key.
|
# fingerprint form. Never the private key.
|
||||||
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
|
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
|
||||||
fingerprint = hashlib.sha256(der).hexdigest()
|
fingerprint = hashlib.sha256(der).hexdigest()
|
||||||
info(f"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")
|
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
"""Cred-proxy provisioning inside a running Docker bottle (PRD 0010).
|
|
||||||
|
|
||||||
Writes the agent-side configuration that points each tool at the
|
|
||||||
per-bottle cred-proxy sidecar:
|
|
||||||
|
|
||||||
- ~/.npmrc — `registry=` pointing at /npm/
|
|
||||||
- ~/.gitconfig (appended) — `insteadOf` rules for the
|
|
||||||
github / gitea hosts the bottle
|
|
||||||
declared a token for
|
|
||||||
- ~/.config/tea/config.yml — per-gitea login pointing at
|
|
||||||
/gitea/<host>/
|
|
||||||
|
|
||||||
The ANTHROPIC_BASE_URL env var is set at `docker run -e` time by the
|
|
||||||
backend's launch step, not here — it has to be in the agent's environ
|
|
||||||
before claude starts, and there is no point in writing it to a dotfile
|
|
||||||
the agent would have to source. See `prepare.py` for that.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ....cred_proxy import CredProxyRoute
|
|
||||||
from ....log import info
|
|
||||||
from .. import util as docker_mod
|
|
||||||
from ..bottle_plan import DockerBottlePlan
|
|
||||||
from ..cred_proxy import cred_proxy_url
|
|
||||||
|
|
||||||
|
|
||||||
def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
|
|
||||||
"""Drop the agent-side dotfiles for each declared cred-proxy
|
|
||||||
route. No-op when the bottle has no routes."""
|
|
||||||
routes = plan.cred_proxy_plan.routes
|
|
||||||
if not routes:
|
|
||||||
return
|
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
|
||||||
git_gate_hosts = {g.UpstreamHost for g in bottle.git}
|
|
||||||
_provision_npmrc(plan, target, routes)
|
|
||||||
_provision_gitconfig(plan, target, routes, git_gate_hosts)
|
|
||||||
_provision_tea_config(plan, target, routes)
|
|
||||||
|
|
||||||
|
|
||||||
# --- npm --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def render_npmrc(routes: tuple[CredProxyRoute, ...]) -> str:
|
|
||||||
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
|
|
||||||
finds the (single) route that claims it and writes a registry=
|
|
||||||
line at the proxy. Empty string when no such route exists, so
|
|
||||||
callers can branch on emptiness.
|
|
||||||
|
|
||||||
The proxy strips inbound Authorization and injects its own — the
|
|
||||||
npmrc deliberately carries no `_authToken`. The registry alone
|
|
||||||
is enough. Manifest validation enforces that the role is a
|
|
||||||
singleton, so the first match is the only match."""
|
|
||||||
for r in routes:
|
|
||||||
if "npm-registry" in r.roles:
|
|
||||||
return f"registry={cred_proxy_url()}{r.path}\n"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_npmrc(
|
|
||||||
plan: DockerBottlePlan,
|
|
||||||
target: str,
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
) -> None:
|
|
||||||
content = render_npmrc(routes)
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
container_npmrc = f"{container_home}/.npmrc"
|
|
||||||
npmrc = plan.stage_dir / "agent_npmrc"
|
|
||||||
npmrc.write_text(content)
|
|
||||||
npmrc.chmod(0o600)
|
|
||||||
info(f"writing {container_npmrc} (cred-proxy npm registry)")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(npmrc), f"{target}:{container_npmrc}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
docker_mod.docker_exec_root(target, ["chown", "node:node", container_npmrc])
|
|
||||||
docker_mod.docker_exec_root(target, ["chmod", "644", container_npmrc])
|
|
||||||
|
|
||||||
|
|
||||||
# --- git config -------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def render_cred_proxy_gitconfig(
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
|
|
||||||
) -> str:
|
|
||||||
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
|
|
||||||
rewrites. Driven by the `git-insteadof` role: each route that
|
|
||||||
claims it produces a `[url "<proxy><path>"] insteadOf =
|
|
||||||
<upstream>/` block. Empty string when no such route exists.
|
|
||||||
|
|
||||||
The rewrite is suppressed for any route whose upstream host is
|
|
||||||
also declared in `bottle.git`. git-gate is the canonical git
|
|
||||||
path on those hosts — its pre-receive runs gitleaks before
|
|
||||||
forwarding the push. A cred-proxy `https://<host>/` rewrite
|
|
||||||
would route HTTPS git ops around the gate. cred-proxy still
|
|
||||||
refuses smart-HTTP push at runtime (defense in depth), but
|
|
||||||
suppressing the rewrite means `git clone https://<host>/...`
|
|
||||||
doesn't have a tempting shortcut that just confuses on push.
|
|
||||||
|
|
||||||
The insteadOf left-hand side comes from `route.upstream` (with a
|
|
||||||
trailing `/` so insteadOf matches at the directory boundary),
|
|
||||||
so the same renderer handles github.com, gitea.dideric.is, and
|
|
||||||
any future host the user wires up."""
|
|
||||||
rules: list[str] = []
|
|
||||||
for r in routes:
|
|
||||||
if "git-insteadof" not in r.roles:
|
|
||||||
continue
|
|
||||||
# Strip scheme to derive the host for the git-gate overlap
|
|
||||||
# check. urllib.parse-free parse: same shape we accept in
|
|
||||||
# manifest validation.
|
|
||||||
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
|
||||||
if host in git_gate_hosts:
|
|
||||||
continue
|
|
||||||
rules.append(
|
|
||||||
f'[url "{cred_proxy_url()}{r.path}"]\n'
|
|
||||||
f"\tinsteadOf = {r.upstream}/\n"
|
|
||||||
)
|
|
||||||
if not rules:
|
|
||||||
return ""
|
|
||||||
return (
|
|
||||||
"# claude-bottle cred-proxy (PRD 0010): rewrite https://<host>/ to\n"
|
|
||||||
"# the per-bottle cred-proxy sidecar, which holds the upstream\n"
|
|
||||||
"# credential and injects the Authorization header.\n"
|
|
||||||
+ "".join(rules)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_gitconfig(
|
|
||||||
plan: DockerBottlePlan,
|
|
||||||
target: str,
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
git_gate_hosts: set[str],
|
|
||||||
) -> None:
|
|
||||||
"""Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs
|
|
||||||
after `provision_git`, so any git-gate rules already live in the
|
|
||||||
file; we append rather than overwrite. Hosts already brokered by
|
|
||||||
git-gate are skipped — git-gate is the canonical git path there."""
|
|
||||||
content = render_cred_proxy_gitconfig(routes, git_gate_hosts)
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
container_gitconfig = f"{container_home}/.gitconfig"
|
|
||||||
info(f"appending cred-proxy insteadOf rules to {container_gitconfig}")
|
|
||||||
# Use `tee -a` over stdin so the content never lands on argv and the
|
|
||||||
# append is atomic from the agent's perspective. `tee` runs as the
|
|
||||||
# node user (the default in the container) so ownership is preserved.
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "exec", "-i", target, "tee", "-a", container_gitconfig],
|
|
||||||
input=content,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
# Fall back to root-tee in case ~/.gitconfig didn't exist as the
|
|
||||||
# node user yet (no git-gate rules were written). The chown
|
|
||||||
# below makes ownership consistent.
|
|
||||||
result_root = subprocess.run(
|
|
||||||
["docker", "exec", "-i", "-u", "0", target,
|
|
||||||
"tee", "-a", container_gitconfig],
|
|
||||||
input=content,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_ = result_root # silence unused
|
|
||||||
docker_mod.docker_exec_root(target, ["chown", "node:node", container_gitconfig])
|
|
||||||
docker_mod.docker_exec_root(target, ["chmod", "644", container_gitconfig])
|
|
||||||
|
|
||||||
|
|
||||||
# --- tea --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def render_tea_config(routes: tuple[CredProxyRoute, ...]) -> str:
|
|
||||||
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
|
|
||||||
role: each route that claims it produces one `logins:` entry
|
|
||||||
pointing at the cred-proxy. The proxy substitutes the real
|
|
||||||
token at request time; the value in `token:` here is a
|
|
||||||
placeholder. `tea` refuses to make calls without a non-empty
|
|
||||||
token field, so the placeholder is necessary."""
|
|
||||||
tea_routes = [r for r in routes if "tea-login" in r.roles]
|
|
||||||
if not tea_routes:
|
|
||||||
return ""
|
|
||||||
lines = ["logins:"]
|
|
||||||
for r in tea_routes:
|
|
||||||
# Derive a stable login name from the upstream host. The
|
|
||||||
# path may not encode the host (e.g. `/gitea/dideric/` vs
|
|
||||||
# upstream gitea.dideric.is), so we read it off `upstream`.
|
|
||||||
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
|
||||||
lines.extend([
|
|
||||||
f"- name: {host}",
|
|
||||||
f" url: {cred_proxy_url()}{r.path}",
|
|
||||||
" token: cred-proxy-placeholder",
|
|
||||||
" default: false",
|
|
||||||
" ssh_host: \"\"",
|
|
||||||
" ssh_key: \"\"",
|
|
||||||
" insecure: false",
|
|
||||||
])
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_tea_config(
|
|
||||||
plan: DockerBottlePlan,
|
|
||||||
target: str,
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
) -> None:
|
|
||||||
content = render_tea_config(routes)
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
container_tea = f"{container_home}/.config/tea/config.yml"
|
|
||||||
cfg = plan.stage_dir / "agent_tea_config.yml"
|
|
||||||
cfg.write_text(content)
|
|
||||||
cfg.chmod(0o600)
|
|
||||||
info(
|
|
||||||
f"writing {container_tea} "
|
|
||||||
f"({len([r for r in routes if 'tea-login' in r.roles])} tea login(s))"
|
|
||||||
)
|
|
||||||
docker_mod.docker_exec_root(
|
|
||||||
target, ["mkdir", "-p", str(Path(container_tea).parent)]
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(cfg), f"{target}:{container_tea}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
docker_mod.docker_exec_root(target, [
|
|
||||||
"chown", "-R", "node:node", str(Path(container_tea).parent),
|
|
||||||
])
|
|
||||||
docker_mod.docker_exec_root(target, ["chmod", "600", container_tea])
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
"""Per-bottle credential proxy (PRD 0010).
|
|
||||||
|
|
||||||
A fourth per-bottle sidecar that holds API tokens (Anthropic OAuth,
|
|
||||||
GitHub PAT, Gitea PAT, npm token) and injects them as `Authorization`
|
|
||||||
headers on the agent's behalf. The agent's environ carries only URLs
|
|
||||||
pointing at `cred-proxy:<PORT>/<route>`; the upstream credentials live
|
|
||||||
exclusively in the cred-proxy container's environ.
|
|
||||||
|
|
||||||
The boundary is the container line — different PID, mount, and network
|
|
||||||
namespaces separate the agent's container from the cred-proxy's, so
|
|
||||||
the agent cannot ptrace into the proxy, cannot read its environ via
|
|
||||||
/proc, and cannot share memory. Reaching the proxy's environ requires
|
|
||||||
escaping the agent container, the same threshold pipelock and
|
|
||||||
git-gate already rely on.
|
|
||||||
|
|
||||||
This module defines the abstract proxy (`CredProxy`), its plan
|
|
||||||
dataclass (`CredProxyPlan`), and the resolved per-route shape
|
|
||||||
(`CredProxyRoute`). The sidecar's start/stop lifecycle is backend-
|
|
||||||
specific and lives on concrete subclasses (see
|
|
||||||
`claude_bottle/backend/docker/cred_proxy.py`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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
|
|
||||||
under the proxy to a real upstream, an auth scheme, an
|
|
||||||
in-container env-var slot, and optional provisioner roles.
|
|
||||||
|
|
||||||
Distinct from `manifest.CredProxyRoute` (the declaration shape
|
|
||||||
with Capitalize fields): this is the runtime view after the
|
|
||||||
abstract `CredProxy.prepare` step assigns token slots and
|
|
||||||
normalizes URLs. Modules that need both alias one on import.
|
|
||||||
|
|
||||||
`path` is the agent-facing prefix (e.g. `/anthropic/`).
|
|
||||||
`upstream` is the upstream base URL with scheme. `auth_scheme`
|
|
||||||
is the literal word that precedes the token in the injected
|
|
||||||
header (`Bearer` for most upstreams; `token` for Gitea —
|
|
||||||
sidesteps go-gitea/gitea#16734).
|
|
||||||
|
|
||||||
`token_env` is the env-var name inside the cred-proxy container
|
|
||||||
(e.g. `CRED_PROXY_TOKEN_0`); `token_ref` is the host env var the
|
|
||||||
CLI reads at launch and forwards into the container's environ
|
|
||||||
under `token_env`. Routes that share a `token_ref` coalesce to
|
|
||||||
one `token_env` slot.
|
|
||||||
|
|
||||||
`roles` are the provisioner tags from the manifest route (see
|
|
||||||
`manifest.CRED_PROXY_ROLES`). Each tag drives one agent-side
|
|
||||||
rewrite when this route's dotfile family is written."""
|
|
||||||
|
|
||||||
path: str
|
|
||||||
upstream: str
|
|
||||||
auth_scheme: str
|
|
||||||
token_env: str
|
|
||||||
token_ref: str
|
|
||||||
roles: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CredProxyPlan:
|
|
||||||
"""Output of CredProxy.prepare; consumed by .start.
|
|
||||||
|
|
||||||
The slug + routes_path + routes + token_env_map fields are
|
|
||||||
filled at prepare time (host-side, side-effect-free on docker).
|
|
||||||
The network + pipelock fields are populated by the backend's
|
|
||||||
launch step via `dataclasses.replace` once those resources
|
|
||||||
exist. Empty defaults are sentinels meaning "not yet set";
|
|
||||||
`.start` validates that they are populated.
|
|
||||||
|
|
||||||
`token_env_map` is `{<token_env in container>: <token_ref on host>}`.
|
|
||||||
The backend's start step reads `os.environ[token_ref]` and
|
|
||||||
forwards the value into the cred-proxy container's environ under
|
|
||||||
`token_env`. The plan itself never holds token values — secrets
|
|
||||||
never land in a dataclass that might be logged.
|
|
||||||
|
|
||||||
`pipelock_ca_host_path` is the host path of the per-bottle CA
|
|
||||||
pipelock will present on bumped TLS handshakes; the cred-proxy
|
|
||||||
image's entrypoint runs `update-ca-certificates` over it so the
|
|
||||||
proxy's HTTPS client trusts pipelock's CA. `pipelock_proxy_url`
|
|
||||||
is the URL cred-proxy sets as `HTTPS_PROXY` in its environ so
|
|
||||||
outbound HTTPS traverses pipelock — making pipelock's body
|
|
||||||
scanner part of the cred-proxy egress path."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
routes_path: Path
|
|
||||||
routes: tuple[CredProxyRoute, ...]
|
|
||||||
token_env_map: dict[str, str]
|
|
||||||
internal_network: str = ""
|
|
||||||
egress_network: str = ""
|
|
||||||
pipelock_ca_host_path: Path = Path()
|
|
||||||
pipelock_proxy_url: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_routes_for_bottle(
|
|
||||||
bottle: Bottle,
|
|
||||||
) -> tuple[CredProxyRoute, ...]:
|
|
||||||
"""Lift each `bottle.cred_proxy.routes[]` manifest entry into a
|
|
||||||
resolved CredProxyRoute. Order is preserved so route lookup at
|
|
||||||
the proxy is stable.
|
|
||||||
|
|
||||||
Token-env slots are assigned per distinct `token_ref`: the first
|
|
||||||
route with `token_ref` "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a
|
|
||||||
second route with the same `token_ref` shares slot 0. The launch
|
|
||||||
step forwards each `token_ref`'s value from the host environ into
|
|
||||||
the sidecar's environ under the matching slot name once.
|
|
||||||
|
|
||||||
Manifest validation already enforced uniqueness rules (no
|
|
||||||
duplicate paths, singleton-role enforcement)."""
|
|
||||||
out: list[CredProxyRoute] = []
|
|
||||||
slot_for_token: dict[str, str] = {}
|
|
||||||
for r in bottle.cred_proxy.routes:
|
|
||||||
token_env = slot_for_token.get(r.TokenRef)
|
|
||||||
if token_env is None:
|
|
||||||
token_env = f"CRED_PROXY_TOKEN_{len(slot_for_token)}"
|
|
||||||
slot_for_token[r.TokenRef] = token_env
|
|
||||||
out.append(CredProxyRoute(
|
|
||||||
path=r.Path,
|
|
||||||
upstream=r.Upstream.rstrip("/"),
|
|
||||||
auth_scheme=r.AuthScheme,
|
|
||||||
token_env=token_env,
|
|
||||||
token_ref=r.TokenRef,
|
|
||||||
roles=r.Role,
|
|
||||||
))
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_token_env_map(
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Collapse the route list into `{token_env: token_ref}`. Two
|
|
||||||
routes that share a token (gh-api + gh-git) coalesce; the result
|
|
||||||
is the set of env vars the backend's start step must forward into
|
|
||||||
the sidecar's environ."""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for r in routes:
|
|
||||||
existing = out.get(r.token_env)
|
|
||||||
if existing is not None and existing != r.token_ref:
|
|
||||||
die(
|
|
||||||
f"cred-proxy plan conflict: {r.token_env} maps to both "
|
|
||||||
f"{existing!r} and {r.token_ref!r}. Two routes sharing a "
|
|
||||||
f"token slot must reference the same host env var."
|
|
||||||
)
|
|
||||||
out[r.token_env] = r.token_ref
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_render_routes(
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
) -> str:
|
|
||||||
"""Serialize the route table for the cred-proxy server to read.
|
|
||||||
JSON, no token values, no host env-var names — the only thing
|
|
||||||
the proxy needs at runtime is the path → upstream + auth-scheme +
|
|
||||||
in-container env-var mapping. The actual token values arrive via
|
|
||||||
the container's environ."""
|
|
||||||
payload = {
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"path": r.path,
|
|
||||||
"upstream": r.upstream,
|
|
||||||
"auth_scheme": r.auth_scheme,
|
|
||||||
"token_env": r.token_env,
|
|
||||||
}
|
|
||||||
for r in routes
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return json.dumps(payload, indent=2, sort_keys=False) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def cred_proxy_resolve_token_values(
|
|
||||||
token_env_map: dict[str, str],
|
|
||||||
host_env: dict[str, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Read `host_env[TokenRef]` for each entry in `token_env_map` and
|
|
||||||
return `{token_env: <value>}`. Dies (with a clear pointer at the
|
|
||||||
missing var name) if any TokenRef is unset.
|
|
||||||
|
|
||||||
Pure function: takes the host env as an argument so tests can pass
|
|
||||||
a sealed mapping without touching `os.environ`."""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for token_env, token_ref in token_env_map.items():
|
|
||||||
value = host_env.get(token_ref)
|
|
||||||
if value is None:
|
|
||||||
die(
|
|
||||||
f"cred-proxy: host env var '{token_ref}' is unset. Set it "
|
|
||||||
f"before launching, or remove the corresponding route from "
|
|
||||||
f"bottle.cred_proxy.routes."
|
|
||||||
)
|
|
||||||
if not value:
|
|
||||||
die(
|
|
||||||
f"cred-proxy: host env var '{token_ref}' is empty. The "
|
|
||||||
f"cred-proxy will not inject an empty token; set it to the "
|
|
||||||
f"real value or remove the route."
|
|
||||||
)
|
|
||||||
out[token_env] = value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class CredProxy(ABC):
|
|
||||||
"""The per-bottle credential proxy. Encapsulates the host-side
|
|
||||||
prepare (route lift + routes.json render + token-env-map
|
|
||||||
derivation); the sidecar's start/stop lifecycle is backend-
|
|
||||||
specific and lives on concrete subclasses."""
|
|
||||||
|
|
||||||
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> CredProxyPlan:
|
|
||||||
"""Lift `bottle.cred_proxy.routes` into resolved routes,
|
|
||||||
render the routes.json (mode 600) under `stage_dir`, and
|
|
||||||
return the plan. Pure host-side, no docker subprocess. The
|
|
||||||
token-env map records the mapping the launch step uses to
|
|
||||||
forward values from the host's environ into the sidecar's
|
|
||||||
environ.
|
|
||||||
|
|
||||||
Returned plan is incomplete: the launch step must fill
|
|
||||||
`internal_network` / `egress_network` via `dataclasses.replace`
|
|
||||||
before passing it to `.start`."""
|
|
||||||
routes = cred_proxy_routes_for_bottle(bottle)
|
|
||||||
routes_path = stage_dir / "cred_proxy_routes.json"
|
|
||||||
routes_path.write_text(cred_proxy_render_routes(routes))
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
return CredProxyPlan(
|
|
||||||
slug=slug,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=routes,
|
|
||||||
token_env_map=cred_proxy_token_env_map(routes),
|
|
||||||
)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def start(self, plan: CredProxyPlan) -> str:
|
|
||||||
"""Bring up the cred-proxy sidecar according to `plan`. Returns
|
|
||||||
the target string identifying the running instance — the same
|
|
||||||
value to pass to `.stop`. Backend-specific."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def stop(self, target: str) -> None:
|
|
||||||
"""Tear down the cred-proxy sidecar identified by `target` (the
|
|
||||||
value `.start` returned). Idempotent: a missing target is
|
|
||||||
success. Backend-specific."""
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CRED_PROXY_HOSTNAME",
|
|
||||||
"CredProxy",
|
|
||||||
"CredProxyPlan",
|
|
||||||
"CredProxyRoute",
|
|
||||||
"cred_proxy_render_routes",
|
|
||||||
"cred_proxy_resolve_token_values",
|
|
||||||
"cred_proxy_routes_for_bottle",
|
|
||||||
"cred_proxy_token_env_map",
|
|
||||||
]
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
"""Cred-proxy HTTP server (PRD 0010).
|
|
||||||
|
|
||||||
Runs inside the per-bottle cred-proxy sidecar. Reads
|
|
||||||
`/run/cred-proxy/routes.json` (laid down by the backend's start step
|
|
||||||
via `docker cp`) and listens on `0.0.0.0:<port>`. For each request:
|
|
||||||
|
|
||||||
1. Match the request path against the longest route prefix.
|
|
||||||
2. Strip any inbound `Authorization` header (the agent cannot
|
|
||||||
smuggle a stolen token through this path).
|
|
||||||
3. Inject the configured header using the value of the env var
|
|
||||||
named by the route's `token_env`.
|
|
||||||
4. Forward to the upstream over HTTPS, preserving method, path
|
|
||||||
suffix, query string, request body, and the remaining headers.
|
|
||||||
5. Stream the response back without buffering — SSE-safe.
|
|
||||||
|
|
||||||
The agent talks plain HTTP to this server (loopback-equivalent across
|
|
||||||
the per-bottle internal docker network). The cred-proxy talks HTTPS
|
|
||||||
outbound through pipelock to the real upstream. Tokens live in this
|
|
||||||
container's environ; they never land in routes.json on disk and never
|
|
||||||
reach the agent's container.
|
|
||||||
|
|
||||||
Stdlib-only: this file ships into a minimal Python image with no pip
|
|
||||||
install layer. The constants are duplicated from `cred_proxy.py` so
|
|
||||||
the server doesn't need to import the rest of the package.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.client
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import socketserver
|
|
||||||
import sys
|
|
||||||
import typing
|
|
||||||
import urllib.parse
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
# --- Config / route table ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Route:
|
|
||||||
"""One row of the proxy's route table.
|
|
||||||
|
|
||||||
`path` is the agent-facing prefix (e.g. `/anthropic/`); the
|
|
||||||
incoming request's path starts with this. `upstream_scheme` /
|
|
||||||
`upstream_host` / `upstream_base_path` are the parsed pieces of
|
|
||||||
the upstream URL — the request's path after the prefix is
|
|
||||||
appended to `upstream_base_path`. `auth_scheme` is the literal
|
|
||||||
word in the injected header (`Bearer` or `token`). `token_env`
|
|
||||||
is the env-var name this container reads to get the token."""
|
|
||||||
|
|
||||||
path: str
|
|
||||||
upstream_scheme: str
|
|
||||||
upstream_host: str
|
|
||||||
upstream_port: int
|
|
||||||
upstream_base_path: str
|
|
||||||
auth_scheme: str
|
|
||||||
token_env: str
|
|
||||||
|
|
||||||
|
|
||||||
def parse_routes(payload: dict[str, object]) -> tuple[Route, ...]:
|
|
||||||
"""Parse the routes.json payload into a tuple of `Route`s. Sorted
|
|
||||||
by descending path length so longest-prefix match is the first
|
|
||||||
hit in iteration order."""
|
|
||||||
raw = payload.get("routes")
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
raise ValueError("routes.json: 'routes' must be a list")
|
|
||||||
out: list[Route] = []
|
|
||||||
for r in raw:
|
|
||||||
if not isinstance(r, dict):
|
|
||||||
raise ValueError(f"routes.json: route must be an object (got {type(r).__name__})")
|
|
||||||
path = r["path"]
|
|
||||||
upstream = r["upstream"]
|
|
||||||
auth_scheme = r["auth_scheme"]
|
|
||||||
token_env = r["token_env"]
|
|
||||||
if not isinstance(path, str) or not path.startswith("/") or not path.endswith("/"):
|
|
||||||
raise ValueError(f"routes.json: path {path!r} must start and end with /")
|
|
||||||
if not isinstance(upstream, str):
|
|
||||||
raise ValueError("routes.json: upstream must be a string")
|
|
||||||
if not isinstance(auth_scheme, str):
|
|
||||||
raise ValueError("routes.json: auth_scheme must be a string")
|
|
||||||
if not isinstance(token_env, str) or not token_env:
|
|
||||||
raise ValueError("routes.json: token_env must be a non-empty string")
|
|
||||||
parsed = urllib.parse.urlsplit(upstream)
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
raise ValueError(f"routes.json: upstream scheme must be http or https (got {parsed.scheme!r})")
|
|
||||||
if not parsed.hostname:
|
|
||||||
raise ValueError(f"routes.json: upstream {upstream!r} missing host")
|
|
||||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
||||||
base_path = parsed.path or ""
|
|
||||||
out.append(Route(
|
|
||||||
path=path,
|
|
||||||
upstream_scheme=parsed.scheme,
|
|
||||||
upstream_host=parsed.hostname,
|
|
||||||
upstream_port=port,
|
|
||||||
upstream_base_path=base_path,
|
|
||||||
auth_scheme=auth_scheme,
|
|
||||||
token_env=token_env,
|
|
||||||
))
|
|
||||||
out.sort(key=lambda r: len(r.path), reverse=True)
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
def select_route(routes: typing.Sequence[Route], request_path: str) -> Route | None:
|
|
||||||
"""Return the longest-prefix matching route, or None. Caller is
|
|
||||||
responsible for stripping any query string before passing
|
|
||||||
`request_path`."""
|
|
||||||
for r in routes:
|
|
||||||
if request_path.startswith(r.path):
|
|
||||||
return r
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_git_push_request(path: str, query: str) -> bool:
|
|
||||||
"""Return True if the request is a git smart-HTTP push.
|
|
||||||
|
|
||||||
git push over HTTPS hits two endpoints:
|
|
||||||
GET <repo>/info/refs?service=git-receive-pack (capabilities)
|
|
||||||
POST <repo>/git-receive-pack (the push)
|
|
||||||
|
|
||||||
Fetches use `service=git-upload-pack` / `/git-upload-pack` and are
|
|
||||||
not blocked. cred-proxy refuses push because git-gate's pre-receive
|
|
||||||
gitleaks scan is the gate for outbound git data; routing push
|
|
||||||
through cred-proxy would bypass that. Use the bottle.git SSH path
|
|
||||||
if you need to push.
|
|
||||||
"""
|
|
||||||
if path.endswith("/git-receive-pack"):
|
|
||||||
return True
|
|
||||||
if path.endswith("/info/refs"):
|
|
||||||
# Query string is parsed leniently — `service=git-receive-pack`
|
|
||||||
# may appear with other params in any order.
|
|
||||||
for pair in query.split("&"):
|
|
||||||
k, _, v = pair.partition("=")
|
|
||||||
if k == "service" and v == "git-receive-pack":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Header handling --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Hop-by-hop headers (RFC 7230 §6.1). Stripped before forwarding.
|
|
||||||
# Plus `host` (we set it for the upstream) and any `authorization` /
|
|
||||||
# `proxy-authorization` (the proxy injects its own, never forwards
|
|
||||||
# the agent's).
|
|
||||||
_HOP_BY_HOP = frozenset({
|
|
||||||
"connection",
|
|
||||||
"keep-alive",
|
|
||||||
"proxy-authenticate",
|
|
||||||
"proxy-authorization",
|
|
||||||
"te",
|
|
||||||
"trailers",
|
|
||||||
"transfer-encoding",
|
|
||||||
"upgrade",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Strip the agent's Accept-Encoding on the upstream leg and force
|
|
||||||
# `identity` instead. The response then flows back uncompressed,
|
|
||||||
# which lets pipelock's response scanner read the body — pipelock
|
|
||||||
# 2.3.0 has no decompression path and otherwise blocks with
|
|
||||||
# "compressed sse_stream response cannot be scanned". The cost is
|
|
||||||
# bandwidth from upstream; for LLM SSE streams this is negligible
|
|
||||||
# and the DLP coverage on the agent leg is the win.
|
|
||||||
_STRIPPED = _HOP_BY_HOP | frozenset({
|
|
||||||
"host", "authorization", "content-length", "accept-encoding",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def build_forward_headers(
|
|
||||||
incoming: typing.Iterable[tuple[str, str]],
|
|
||||||
*,
|
|
||||||
auth_scheme: str,
|
|
||||||
token: str,
|
|
||||||
upstream_host: str,
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
"""Build the header list to send upstream.
|
|
||||||
|
|
||||||
- Strip hop-by-hop headers, the inbound Authorization (the agent
|
|
||||||
cannot smuggle a stolen token), and Host (we set it ourselves).
|
|
||||||
- Strip Content-Length too: http.client recomputes it when we
|
|
||||||
pass `body` to `request()`.
|
|
||||||
- Honor the `Connection: close, x, y, z` form by also stripping
|
|
||||||
every listed header name.
|
|
||||||
- Inject `Authorization: <auth_scheme> <token>` and a Host header
|
|
||||||
pointing at the upstream.
|
|
||||||
- Force `Accept-Encoding: identity` so the upstream returns
|
|
||||||
uncompressed bytes — pipelock's response scanner can't read
|
|
||||||
gzip/br/deflate and would otherwise 403 the response.
|
|
||||||
"""
|
|
||||||
incoming_list = list(incoming)
|
|
||||||
# Headers listed in `Connection:` are also hop-by-hop for this hop.
|
|
||||||
extra_hop: set[str] = set()
|
|
||||||
for name, value in incoming_list:
|
|
||||||
if name.lower() == "connection":
|
|
||||||
for token_name in value.split(","):
|
|
||||||
extra_hop.add(token_name.strip().lower())
|
|
||||||
forwarded: list[tuple[str, str]] = []
|
|
||||||
for name, value in incoming_list:
|
|
||||||
lname = name.lower()
|
|
||||||
if lname in _STRIPPED or lname in extra_hop:
|
|
||||||
continue
|
|
||||||
forwarded.append((name, value))
|
|
||||||
forwarded.append(("Host", upstream_host))
|
|
||||||
forwarded.append(("Authorization", f"{auth_scheme} {token}"))
|
|
||||||
forwarded.append(("Accept-Encoding", "identity"))
|
|
||||||
return forwarded
|
|
||||||
|
|
||||||
|
|
||||||
def filter_response_headers(
|
|
||||||
incoming: typing.Iterable[tuple[str, str]],
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
"""Build the response header list to send back to the agent.
|
|
||||||
Strip hop-by-hop + `transfer-encoding` (we let the client's
|
|
||||||
HTTP/1.1 default chunking handle streamed bodies)."""
|
|
||||||
incoming_list = list(incoming)
|
|
||||||
extra_hop: set[str] = set()
|
|
||||||
for name, value in incoming_list:
|
|
||||||
if name.lower() == "connection":
|
|
||||||
for token_name in value.split(","):
|
|
||||||
extra_hop.add(token_name.strip().lower())
|
|
||||||
out: list[tuple[str, str]] = []
|
|
||||||
for name, value in incoming_list:
|
|
||||||
lname = name.lower()
|
|
||||||
if lname in _HOP_BY_HOP or lname in extra_hop:
|
|
||||||
continue
|
|
||||||
out.append((name, value))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- HTTP handler -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# How many bytes to read off the upstream response per chunk. Small
|
|
||||||
# enough that SSE keep-alive `:` lines (~1 byte) and per-event payloads
|
|
||||||
# (~hundreds of bytes) round-trip without waiting for a larger buffer
|
|
||||||
# to fill. Large enough to not dominate syscall overhead under load.
|
|
||||||
STREAM_CHUNK = 4096
|
|
||||||
|
|
||||||
|
|
||||||
class CredProxyHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
"""Per-request handler. The routes + tokens are read off the
|
|
||||||
server instance (set by `serve()`)."""
|
|
||||||
|
|
||||||
# Quieter logs: the default writes one line per request to stderr.
|
|
||||||
# Useful in debug but noisy in normal operation.
|
|
||||||
def log_message(self, format: str, *args: typing.Any) -> None:
|
|
||||||
if os.environ.get("CRED_PROXY_DEBUG"):
|
|
||||||
super().log_message(format, *args)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: self._proxy()
|
|
||||||
def do_POST(self) -> None: self._proxy()
|
|
||||||
def do_PUT(self) -> None: self._proxy()
|
|
||||||
def do_DELETE(self) -> None: self._proxy()
|
|
||||||
def do_PATCH(self) -> None: self._proxy()
|
|
||||||
def do_HEAD(self) -> None: self._proxy()
|
|
||||||
def do_OPTIONS(self) -> None: self._proxy()
|
|
||||||
|
|
||||||
def _proxy(self) -> None:
|
|
||||||
server = typing.cast("CredProxyServer", self.server)
|
|
||||||
path, _, query = self.path.partition("?")
|
|
||||||
if is_git_push_request(path, query):
|
|
||||||
self.send_error(
|
|
||||||
403,
|
|
||||||
"cred-proxy: git push over HTTPS is not supported; "
|
|
||||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
|
||||||
"git-gate's pre-receive hook)",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
route = select_route(server.routes, path)
|
|
||||||
if route is None:
|
|
||||||
self.send_error(404, f"no route for {path!r}")
|
|
||||||
return
|
|
||||||
token = server.tokens.get(route.token_env)
|
|
||||||
if not token:
|
|
||||||
self.send_error(500, f"cred-proxy: env var {route.token_env} unset in sidecar")
|
|
||||||
return
|
|
||||||
|
|
||||||
suffix = path[len(route.path):]
|
|
||||||
upstream_path = route.upstream_base_path.rstrip("/") + "/" + suffix
|
|
||||||
if query:
|
|
||||||
upstream_path = f"{upstream_path}?{query}"
|
|
||||||
|
|
||||||
# Read the request body, if any. We do not stream the body up
|
|
||||||
# because http.client doesn't accept a streamable body for
|
|
||||||
# arbitrary methods cleanly. v1 buffers — claude's tool-use
|
|
||||||
# requests are small JSON payloads; SSE flows are in the
|
|
||||||
# response direction only.
|
|
||||||
body: bytes | None = None
|
|
||||||
length_header = self.headers.get("Content-Length")
|
|
||||||
if length_header is not None:
|
|
||||||
try:
|
|
||||||
length = int(length_header)
|
|
||||||
except ValueError:
|
|
||||||
self.send_error(400, "invalid Content-Length")
|
|
||||||
return
|
|
||||||
if length > 0:
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
elif self.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
|
||||||
self.send_error(411, "cred-proxy: chunked request bodies not supported in v1")
|
|
||||||
return
|
|
||||||
|
|
||||||
forward_headers = build_forward_headers(
|
|
||||||
self.headers.items(),
|
|
||||||
auth_scheme=route.auth_scheme,
|
|
||||||
token=token,
|
|
||||||
upstream_host=route.upstream_host,
|
|
||||||
)
|
|
||||||
|
|
||||||
if route.upstream_scheme == "https":
|
|
||||||
conn: http.client.HTTPConnection = http.client.HTTPSConnection(
|
|
||||||
route.upstream_host, route.upstream_port, timeout=300,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn = http.client.HTTPConnection(
|
|
||||||
route.upstream_host, route.upstream_port, timeout=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn.request(self.command, upstream_path, body=body,
|
|
||||||
headers=dict(forward_headers))
|
|
||||||
resp = conn.getresponse()
|
|
||||||
except (OSError, http.client.HTTPException) as e:
|
|
||||||
try:
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.send_error(502, f"upstream connection failed: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._stream_response(resp)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _stream_response(self, resp: http.client.HTTPResponse) -> None:
|
|
||||||
out_headers = filter_response_headers(resp.getheaders())
|
|
||||||
# We send Connection: close so the agent's client closes after
|
|
||||||
# each request; simplifies streaming bookkeeping and keeps
|
|
||||||
# the handler stateless per request.
|
|
||||||
self.send_response(resp.status, resp.reason)
|
|
||||||
for name, value in out_headers:
|
|
||||||
self.send_header(name, value)
|
|
||||||
self.send_header("Connection", "close")
|
|
||||||
self.end_headers()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
chunk = resp.read(STREAM_CHUNK)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
self.wfile.write(chunk)
|
|
||||||
self.wfile.flush()
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
# Agent disconnected mid-stream; that's fine.
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class CredProxyServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
||||||
"""Threaded HTTP server. `routes` + `tokens` are populated by
|
|
||||||
`serve()` before `serve_forever()`."""
|
|
||||||
|
|
||||||
allow_reuse_address = True
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
routes: tuple[Route, ...] = ()
|
|
||||||
tokens: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Entry point ------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/run/cred-proxy/routes.json"
|
|
||||||
DEFAULT_PORT = 9099
|
|
||||||
|
|
||||||
|
|
||||||
def load_routes(path: str) -> tuple[Route, ...]:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
payload = json.load(f)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError(f"{path}: top-level must be an object")
|
|
||||||
return parse_routes(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def load_tokens(routes: tuple[Route, ...], environ: typing.Mapping[str, str]) -> dict[str, str]:
|
|
||||||
"""Read each route's `token_env` from the supplied environ. Missing
|
|
||||||
entries default to empty string; the handler returns 500 for
|
|
||||||
unset tokens at request time so the operator can spot the
|
|
||||||
misconfig in the cred-proxy's logs without the proxy refusing to
|
|
||||||
boot."""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for r in routes:
|
|
||||||
out[r.token_env] = environ.get(r.token_env, "")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def reload_routes(
|
|
||||||
server: "CredProxyServer",
|
|
||||||
routes_path: str,
|
|
||||||
*,
|
|
||||||
environ: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Re-read routes.json + tokens and swap them onto `server`. Used
|
|
||||||
by the SIGHUP handler (PRD 0014) so the operator can update the
|
|
||||||
routes file in-place and have cred-proxy pick up the change
|
|
||||||
without dropping in-flight connections.
|
|
||||||
|
|
||||||
Returns (ok, message). On failure the server's existing routes
|
|
||||||
stay in place — better to keep serving the old config than to
|
|
||||||
leave the proxy with no routes after a typo.
|
|
||||||
|
|
||||||
Atomic swap: Python attribute reassignment is atomic, and the
|
|
||||||
request handler reads `server.routes`/`server.tokens` once at
|
|
||||||
the top of `_proxy()` so an in-flight request keeps the version
|
|
||||||
it captured. New requests see the new routes."""
|
|
||||||
env = environ if environ is not None else os.environ
|
|
||||||
try:
|
|
||||||
new_routes = load_routes(routes_path)
|
|
||||||
new_tokens = load_tokens(new_routes, env)
|
|
||||||
except (OSError, ValueError, json.JSONDecodeError) as e:
|
|
||||||
return False, f"reload failed: {e}"
|
|
||||||
server.routes = new_routes
|
|
||||||
server.tokens = new_tokens
|
|
||||||
return True, (
|
|
||||||
f"reloaded {len(new_routes)} route(s): "
|
|
||||||
f"{', '.join(r.path for r in new_routes)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def install_sighup_handler(server: "CredProxyServer", routes_path: str) -> None:
|
|
||||||
"""Wire SIGHUP to reload_routes. No-op on platforms without
|
|
||||||
SIGHUP (Windows). The handler swallows exceptions so a bad
|
|
||||||
reload doesn't crash the long-lived sidecar."""
|
|
||||||
if not hasattr(signal, "SIGHUP"):
|
|
||||||
return
|
|
||||||
|
|
||||||
def handler(signum: int, frame: object) -> None:
|
|
||||||
del signum, frame
|
|
||||||
ok, message = reload_routes(server, routes_path)
|
|
||||||
prefix = "cred-proxy: SIGHUP " + ("ok: " if ok else "failed: ")
|
|
||||||
sys.stderr.write(prefix + message + "\n")
|
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGHUP, handler)
|
|
||||||
|
|
||||||
|
|
||||||
def serve(
|
|
||||||
*,
|
|
||||||
routes_path: str = DEFAULT_ROUTES_PATH,
|
|
||||||
port: int = DEFAULT_PORT,
|
|
||||||
bind: str = "0.0.0.0",
|
|
||||||
environ: typing.Mapping[str, str] | None = None,
|
|
||||||
) -> typing.NoReturn:
|
|
||||||
"""Bring up the server and run until killed. Exits non-zero on
|
|
||||||
config error so the container's restart policy can surface the
|
|
||||||
failure rather than silently retrying."""
|
|
||||||
env = environ if environ is not None else os.environ
|
|
||||||
routes = load_routes(routes_path)
|
|
||||||
tokens = load_tokens(routes, env)
|
|
||||||
server = CredProxyServer((bind, port), CredProxyHandler)
|
|
||||||
server.routes = routes
|
|
||||||
server.tokens = tokens
|
|
||||||
install_sighup_handler(server, routes_path)
|
|
||||||
sys.stderr.write(
|
|
||||||
f"cred-proxy listening on {bind}:{port}; "
|
|
||||||
f"{len(routes)} route(s): "
|
|
||||||
f"{', '.join(r.path for r in routes)}\n"
|
|
||||||
)
|
|
||||||
sys.stderr.flush()
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Tiny argv shim: no flags in v1, all config via env vars.
|
|
||||||
|
|
||||||
`CRED_PROXY_ROUTES` overrides the routes path (default
|
|
||||||
`/run/cred-proxy/routes.json`). `CRED_PROXY_PORT` overrides the
|
|
||||||
listen port. Both have defaults so the container needs no extra
|
|
||||||
config to come up."""
|
|
||||||
routes_path = os.environ.get("CRED_PROXY_ROUTES", DEFAULT_ROUTES_PATH)
|
|
||||||
port = int(os.environ.get("CRED_PROXY_PORT", str(DEFAULT_PORT)))
|
|
||||||
bind = os.environ.get("CRED_PROXY_BIND", "0.0.0.0")
|
|
||||||
serve(routes_path=routes_path, port=port, bind=bind)
|
|
||||||
return 0 # serve() does not return.
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main(sys.argv))
|
|
||||||
@@ -16,9 +16,10 @@ dataclass (`EgressProxyPlan`), and the resolved per-route shape
|
|||||||
specific and lives on concrete subclasses (see
|
specific and lives on concrete subclasses (see
|
||||||
`claude_bottle/backend/docker/egress_proxy.py`).
|
`claude_bottle/backend/docker/egress_proxy.py`).
|
||||||
|
|
||||||
Chunk 1 of the PRD: this module + the mitmproxy addon + the Docker
|
Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker
|
||||||
lifecycle land alongside the existing cred-proxy code. Chunk 2 wires
|
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
|
||||||
the agent's `HTTP_PROXY` over to egress-proxy and removes cred-proxy.
|
has been removed. Chunk 3 retargets the cred-proxy-block remediation
|
||||||
|
flow (PRD 0014) at egress-proxy and renames the MCP tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -76,7 +77,7 @@ class EgressProxyPlan:
|
|||||||
|
|
||||||
The slug + routes_path + routes + token_env_map fields are
|
The slug + routes_path + routes + token_env_map fields are
|
||||||
filled at prepare time (host-side, side-effect-free on docker).
|
filled at prepare time (host-side, side-effect-free on docker).
|
||||||
The network + pipelock fields are populated by the backend's
|
The network + CA + pipelock fields are populated by the backend's
|
||||||
launch step via `dataclasses.replace` once those resources
|
launch step via `dataclasses.replace` once those resources
|
||||||
exist. Empty defaults are sentinels meaning "not yet set";
|
exist. Empty defaults are sentinels meaning "not yet set";
|
||||||
`.start` validates that they are populated.
|
`.start` validates that they are populated.
|
||||||
@@ -87,6 +88,22 @@ class EgressProxyPlan:
|
|||||||
under `token_env`. The plan itself never holds token values —
|
under `token_env`. The plan itself never holds token values —
|
||||||
secrets never land in a dataclass that might be logged.
|
secrets never land in a dataclass that might be logged.
|
||||||
|
|
||||||
|
`mitmproxy_ca_host_path` is the host path of the per-bottle
|
||||||
|
egress-proxy CA (single PEM with cert+key concatenated) minted
|
||||||
|
by `egress_proxy_tls_init`. `.start` docker-cps it into the
|
||||||
|
sidecar at `~/.mitmproxy/mitmproxy-ca.pem` — mitmproxy reads
|
||||||
|
that file at boot to mint per-host leaf certs.
|
||||||
|
|
||||||
|
`mitmproxy_ca_cert_only_host_path` is the cert-only PEM (no
|
||||||
|
key) for installing into the agent's trust store via
|
||||||
|
`provision_ca`. Separate file rather than re-parsing the
|
||||||
|
concat so secrets and trust artefacts stay on distinct paths.
|
||||||
|
|
||||||
|
`pipelock_ca_host_path` is the host path of the pipelock CA
|
||||||
|
(cert only). `.start` docker-cps it into the sidecar so the
|
||||||
|
proxy's outbound HTTPS client trusts pipelock's MITM on the
|
||||||
|
egress-proxy → upstream leg.
|
||||||
|
|
||||||
`pipelock_proxy_url` is the URL egress-proxy sets as `HTTPS_PROXY`
|
`pipelock_proxy_url` is the URL egress-proxy sets as `HTTPS_PROXY`
|
||||||
in its environ so outbound HTTPS traverses pipelock — keeping
|
in its environ so outbound HTTPS traverses pipelock — keeping
|
||||||
pipelock's hostname allowlist + DLP body scanner on the
|
pipelock's hostname allowlist + DLP body scanner on the
|
||||||
@@ -99,6 +116,9 @@ class EgressProxyPlan:
|
|||||||
token_env_map: dict[str, str]
|
token_env_map: dict[str, str]
|
||||||
internal_network: str = ""
|
internal_network: str = ""
|
||||||
egress_network: str = ""
|
egress_network: str = ""
|
||||||
|
mitmproxy_ca_host_path: Path = Path()
|
||||||
|
mitmproxy_ca_cert_only_host_path: Path = Path()
|
||||||
|
pipelock_ca_host_path: Path = Path()
|
||||||
pipelock_proxy_url: str = ""
|
pipelock_proxy_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from mitmproxy import http # type: ignore[import-not-found]
|
|||||||
# Absolute import (NOT `from .egress_proxy_addon_core`) — the
|
# Absolute import (NOT `from .egress_proxy_addon_core`) — the
|
||||||
# container drops both files flat into /app/ so they are sibling
|
# container drops both files flat into /app/ so they are sibling
|
||||||
# top-level modules to mitmdump's loader, not a package.
|
# top-level modules to mitmdump's loader, not a package.
|
||||||
from egress_proxy_addon_core import Route, decide, load_routes # type: ignore[import-not-found]
|
from egress_proxy_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress-proxy/routes.yaml"
|
DEFAULT_ROUTES_PATH = "/etc/egress-proxy/routes.yaml"
|
||||||
@@ -93,7 +93,25 @@ class EgressProxyAddon:
|
|||||||
# below.
|
# below.
|
||||||
flow.request.headers.pop("authorization", None)
|
flow.request.headers.pop("authorization", None)
|
||||||
|
|
||||||
request_path = flow.request.path.split("?", 1)[0]
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
|
# Universal HTTPS git-push block. Defense-in-depth: git-gate
|
||||||
|
# (PRD 0008) is the only sanctioned outbound path for git
|
||||||
|
# writes — its pre-receive runs gitleaks. Letting HTTPS push
|
||||||
|
# through egress-proxy + auth injection would route around
|
||||||
|
# that scan, so we 403 before any route logic.
|
||||||
|
if is_git_push_request(request_path, query):
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
403,
|
||||||
|
(
|
||||||
|
b"egress-proxy: git push over HTTPS is not supported; "
|
||||||
|
b"use the bottle.git SSH path (gitleaks-scanned by "
|
||||||
|
b"git-gate's pre-receive hook)."
|
||||||
|
),
|
||||||
|
{"Content-Type": "text/plain; charset=utf-8"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
decision = decide(
|
decision = decide(
|
||||||
self.routes,
|
self.routes,
|
||||||
flow.request.pretty_host,
|
flow.request.pretty_host,
|
||||||
|
|||||||
@@ -135,6 +135,36 @@ def load_routes(text: str) -> tuple[Route, ...]:
|
|||||||
return parse_routes(payload)
|
return parse_routes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_push_request(path: str, query: str) -> bool:
|
||||||
|
"""Return True if the request is a git smart-HTTP push.
|
||||||
|
|
||||||
|
git push over HTTPS hits two endpoints:
|
||||||
|
GET <repo>/info/refs?service=git-receive-pack (capabilities)
|
||||||
|
POST <repo>/git-receive-pack (the push)
|
||||||
|
|
||||||
|
Fetches use `service=git-upload-pack` / `/git-upload-pack` and
|
||||||
|
are unaffected. Egress-proxy refuses HTTPS push because git-gate's
|
||||||
|
pre-receive gitleaks scan is the gate for outbound git data;
|
||||||
|
routing push through egress-proxy would bypass that. Use the
|
||||||
|
bottle.git SSH path if you need to push.
|
||||||
|
|
||||||
|
Universal across routes — the block fires even when no
|
||||||
|
egress_proxy route matches the host. A bare-pass route (host with
|
||||||
|
no auth, no path_allowlist) would otherwise let push through to
|
||||||
|
pipelock + upstream untouched.
|
||||||
|
"""
|
||||||
|
if path.endswith("/git-receive-pack"):
|
||||||
|
return True
|
||||||
|
if path.endswith("/info/refs"):
|
||||||
|
# Query string is parsed leniently — `service=git-receive-pack`
|
||||||
|
# may appear with other params in any order.
|
||||||
|
for pair in query.split("&"):
|
||||||
|
k, _, v = pair.partition("=")
|
||||||
|
if k == "service" and v == "git-receive-pack":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def match_route(
|
def match_route(
|
||||||
routes: typing.Sequence[Route],
|
routes: typing.Sequence[Route],
|
||||||
request_host: str,
|
request_host: str,
|
||||||
@@ -207,6 +237,7 @@ __all__ = [
|
|||||||
"Decision",
|
"Decision",
|
||||||
"Route",
|
"Route",
|
||||||
"decide",
|
"decide",
|
||||||
|
"is_git_push_request",
|
||||||
"load_routes",
|
"load_routes",
|
||||||
"match_route",
|
"match_route",
|
||||||
"parse_routes",
|
"parse_routes",
|
||||||
|
|||||||
+24
-218
@@ -14,7 +14,6 @@ the system prompt, for bottles the body is human documentation
|
|||||||
Bottle schema (frontmatter):
|
Bottle schema (frontmatter):
|
||||||
env: { <NAME>: <env-entry>, ... }
|
env: { <NAME>: <env-entry>, ... }
|
||||||
git: [ <git-entry>, ... ]
|
git: [ <git-entry>, ... ]
|
||||||
cred_proxy: { routes: [ <route>, ... ] } # superseded by egress_proxy (PRD 0017)
|
|
||||||
egress_proxy: { routes: [ <egress-route>, ... ] }
|
egress_proxy: { routes: [ <egress-route>, ... ] }
|
||||||
egress: { allowlist: [ <hostname>, ... ] }
|
egress: { allowlist: [ <hostname>, ... ] }
|
||||||
|
|
||||||
@@ -125,154 +124,6 @@ class GitEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CRED_PROXY_AUTH_SCHEMES = ("Bearer", "token")
|
|
||||||
|
|
||||||
# Provisioner role tags a route may carry. Each tag drives one
|
|
||||||
# agent-side rewrite when the cred-proxy sidecar comes up.
|
|
||||||
# anthropic-base-url: set ANTHROPIC_BASE_URL=<proxy><path>
|
|
||||||
# npm-registry: write ~/.npmrc registry= <proxy><path>
|
|
||||||
# git-insteadof: write ~/.gitconfig [url "<proxy><path>"]
|
|
||||||
# insteadOf = <route.upstream>/
|
|
||||||
# tea-login: add an entry to ~/.config/tea/config.yml
|
|
||||||
# (login url = <proxy><path>)
|
|
||||||
# Routes without a `role` are pure proxy entries with no agent-side
|
|
||||||
# rewrite — useful for upstreams whose tools the user wires up by
|
|
||||||
# hand.
|
|
||||||
CRED_PROXY_ROLES = frozenset({
|
|
||||||
"anthropic-base-url",
|
|
||||||
"npm-registry",
|
|
||||||
"git-insteadof",
|
|
||||||
"tea-login",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Roles whose semantics imply a single route can carry them. A second
|
|
||||||
# route claiming the same role would make the provisioner's choice
|
|
||||||
# ambiguous (which path goes into ANTHROPIC_BASE_URL?).
|
|
||||||
CRED_PROXY_SINGLETON_ROLES = frozenset({
|
|
||||||
"anthropic-base-url",
|
|
||||||
"npm-registry",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CredProxyRoute:
|
|
||||||
"""One route on the per-bottle cred-proxy sidecar (PRD 0010).
|
|
||||||
|
|
||||||
The agent dials `http://cred-proxy:<port><Path>...`; the sidecar
|
|
||||||
strips any inbound `Authorization` header, injects
|
|
||||||
`<AuthScheme> <token>` using the value of the host env var named
|
|
||||||
by `TokenRef`, and forwards the rest of the request to `Upstream`.
|
|
||||||
|
|
||||||
`Path` is the agent-facing prefix (must start and end with `/`).
|
|
||||||
`Upstream` is the upstream base URL (https only) — the request
|
|
||||||
path after `Path` is appended to it. `AuthScheme` is the literal
|
|
||||||
word that precedes the token in the injected header (`Bearer` for
|
|
||||||
most upstreams, `token` for Gitea — sidesteps go-gitea/gitea#16734).
|
|
||||||
`TokenRef` names the host env var holding the credential value;
|
|
||||||
the CLI reads it at launch and forwards into the sidecar's environ.
|
|
||||||
`Role` carries optional provisioner tags (see CRED_PROXY_ROLES).
|
|
||||||
|
|
||||||
`UpstreamHost` is parsed from `Upstream` for the pipelock allowlist
|
|
||||||
+ the git-insteadof suppression check."""
|
|
||||||
|
|
||||||
Path: str
|
|
||||||
Upstream: str
|
|
||||||
AuthScheme: str
|
|
||||||
TokenRef: str
|
|
||||||
Role: tuple[str, ...] = ()
|
|
||||||
UpstreamHost: str = ""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "CredProxyRoute":
|
|
||||||
label = f"bottle '{bottle_name}' cred_proxy.routes[{idx}]"
|
|
||||||
d = _as_json_object(raw, label)
|
|
||||||
path = d.get("path")
|
|
||||||
if not isinstance(path, str) or not path:
|
|
||||||
die(f"{label} missing required string field 'path'")
|
|
||||||
if not (path.startswith("/") and path.endswith("/")):
|
|
||||||
die(f"{label} path {path!r} must start and end with '/'")
|
|
||||||
upstream = d.get("upstream")
|
|
||||||
if not isinstance(upstream, str) or not upstream:
|
|
||||||
die(f"{label} missing required string field 'upstream'")
|
|
||||||
host = _parse_https_host(upstream, f"{label} upstream")
|
|
||||||
auth_scheme = d.get("auth_scheme")
|
|
||||||
if not isinstance(auth_scheme, str) or not auth_scheme:
|
|
||||||
die(f"{label} missing required string field 'auth_scheme'")
|
|
||||||
if auth_scheme not in CRED_PROXY_AUTH_SCHEMES:
|
|
||||||
die(
|
|
||||||
f"{label} auth_scheme {auth_scheme!r} is not one of "
|
|
||||||
f"{', '.join(CRED_PROXY_AUTH_SCHEMES)}"
|
|
||||||
)
|
|
||||||
token_ref = d.get("token_ref")
|
|
||||||
if not isinstance(token_ref, str) or not token_ref:
|
|
||||||
die(
|
|
||||||
f"{label} missing required string field 'token_ref' "
|
|
||||||
f"(name of the host env var holding the token value)"
|
|
||||||
)
|
|
||||||
role_raw = d.get("role")
|
|
||||||
roles: tuple[str, ...] = ()
|
|
||||||
if role_raw is None:
|
|
||||||
roles = ()
|
|
||||||
elif isinstance(role_raw, str):
|
|
||||||
roles = (role_raw,)
|
|
||||||
elif isinstance(role_raw, list):
|
|
||||||
role_list = cast(list[object], role_raw)
|
|
||||||
collected: list[str] = []
|
|
||||||
for r in role_list:
|
|
||||||
if not isinstance(r, str):
|
|
||||||
die(f"{label} role items must be strings (got {type(r).__name__})")
|
|
||||||
collected.append(r)
|
|
||||||
roles = tuple(collected)
|
|
||||||
else:
|
|
||||||
die(
|
|
||||||
f"{label} role must be a string or a list of strings "
|
|
||||||
f"(was {type(role_raw).__name__})"
|
|
||||||
)
|
|
||||||
for r in roles:
|
|
||||||
if r not in CRED_PROXY_ROLES:
|
|
||||||
die(
|
|
||||||
f"{label} role {r!r} is not one of "
|
|
||||||
f"{', '.join(sorted(CRED_PROXY_ROLES))}"
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
Path=path,
|
|
||||||
Upstream=upstream,
|
|
||||||
AuthScheme=auth_scheme,
|
|
||||||
TokenRef=token_ref,
|
|
||||||
Role=roles,
|
|
||||||
UpstreamHost=host,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CredProxyConfig:
|
|
||||||
"""Per-bottle cred-proxy configuration. Today this is just the
|
|
||||||
route table; the nesting under `cred_proxy:` leaves room for
|
|
||||||
per-bottle proxy settings (port override, log level, etc.) in
|
|
||||||
follow-ups."""
|
|
||||||
|
|
||||||
routes: tuple[CredProxyRoute, ...] = ()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "CredProxyConfig":
|
|
||||||
d = _as_json_object(raw, f"bottle '{bottle_name}' cred_proxy")
|
|
||||||
routes_raw = d.get("routes")
|
|
||||||
routes: tuple[CredProxyRoute, ...] = ()
|
|
||||||
if routes_raw is not None:
|
|
||||||
if not isinstance(routes_raw, list):
|
|
||||||
die(
|
|
||||||
f"bottle '{bottle_name}' cred_proxy.routes must be an array "
|
|
||||||
f"(was {type(routes_raw).__name__})"
|
|
||||||
)
|
|
||||||
routes_list = cast(list[object], routes_raw)
|
|
||||||
routes = tuple(
|
|
||||||
CredProxyRoute.from_dict(bottle_name, i, entry)
|
|
||||||
for i, entry in enumerate(routes_list)
|
|
||||||
)
|
|
||||||
_validate_cred_proxy_routes(bottle_name, routes)
|
|
||||||
return cls(routes=routes)
|
|
||||||
|
|
||||||
|
|
||||||
# Auth schemes for the egress-proxy route's optional `auth` block.
|
# Auth schemes for the egress-proxy route's optional `auth` block.
|
||||||
# Same values cred-proxy accepts today; `token` sidesteps the Gitea
|
# Same values cred-proxy accepts today; `token` sidesteps the Gitea
|
||||||
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
||||||
@@ -480,15 +331,15 @@ class BottleEgress:
|
|||||||
class Bottle:
|
class Bottle:
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
git: tuple[GitEntry, ...] = ()
|
git: tuple[GitEntry, ...] = ()
|
||||||
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
|
|
||||||
egress_proxy: EgressProxyConfig = field(default_factory=EgressProxyConfig)
|
egress_proxy: EgressProxyConfig = field(default_factory=EgressProxyConfig)
|
||||||
egress: BottleEgress = field(default_factory=BottleEgress)
|
egress: BottleEgress = field(default_factory=BottleEgress)
|
||||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
||||||
# the launch step brings up a supervise sidecar that exposes three
|
# the launch step brings up a supervise sidecar that exposes three
|
||||||
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
# MCP tools to the agent (cred-proxy-block, pipelock-block,
|
||||||
# capability-block) plus mounts the current-config dir read-only
|
# capability-block; the cred-proxy-block tool is renamed and
|
||||||
# into the agent at /etc/claude-bottle/current-config. False (the
|
# retargeted at egress-proxy in PRD 0017 chunk 3) plus mounts the
|
||||||
# default) skips the sidecar and the mount.
|
# current-config dir read-only into the agent at /etc/claude-bottle/
|
||||||
|
# current-config. False (the default) skips the sidecar and mount.
|
||||||
supervise: bool = False
|
supervise: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -539,16 +390,25 @@ class Bottle:
|
|||||||
if "tokens" in d:
|
if "tokens" in d:
|
||||||
die(
|
die(
|
||||||
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
|
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
|
||||||
f"each route now lives under 'cred_proxy.routes' with explicit "
|
f"each route now lives under 'egress_proxy.routes' with explicit "
|
||||||
f"path / upstream / auth_scheme / token_ref / role[]. See "
|
f"host / path_allowlist / auth. See docs/prds/0017-egress-proxy-via-mitmproxy.md."
|
||||||
f"docs/prds/0010-cred-proxy.md."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cred_proxy = (
|
if "cred_proxy" in d:
|
||||||
CredProxyConfig.from_dict(name, d["cred_proxy"])
|
die(
|
||||||
if "cred_proxy" in d
|
f"bottle '{name}' has a 'cred_proxy' field, which has been removed "
|
||||||
else CredProxyConfig()
|
f"(PRD 0017). Rename to 'egress_proxy' and migrate each route:\n"
|
||||||
)
|
f" - 'path' + 'upstream' (cred-proxy URL prefix + upstream URL)\n"
|
||||||
|
f" → 'host' (just the upstream hostname)\n"
|
||||||
|
f" - 'auth_scheme' + 'token_ref' (flat)\n"
|
||||||
|
f" → 'auth: {{ scheme, token_ref }}' (nested, optional)\n"
|
||||||
|
f" - 'role' (provisioner dotfile rewrites): drop — egress-proxy "
|
||||||
|
f"is on the agent's HTTP_PROXY path, so dotfile rewrites are no "
|
||||||
|
f"longer needed.\n"
|
||||||
|
f" - 'path_allowlist' (new): optional URL prefix gate for the "
|
||||||
|
f"host.\n"
|
||||||
|
f"See docs/prds/0017-egress-proxy-via-mitmproxy.md."
|
||||||
|
)
|
||||||
|
|
||||||
egress_proxy = (
|
egress_proxy = (
|
||||||
EgressProxyConfig.from_dict(name, d["egress_proxy"])
|
EgressProxyConfig.from_dict(name, d["egress_proxy"])
|
||||||
@@ -571,8 +431,8 @@ class Bottle:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
env=env, git=git, cred_proxy=cred_proxy, egress_proxy=egress_proxy,
|
env=env, git=git, egress_proxy=egress_proxy, egress=egress,
|
||||||
egress=egress, supervise=supervise_raw,
|
supervise=supervise_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -846,60 +706,6 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
|||||||
return (user, host, port, path)
|
return (user, host, port, path)
|
||||||
|
|
||||||
|
|
||||||
def _parse_https_host(url: str, label: str) -> str:
|
|
||||||
"""Extract the host from an `https://host[:port][/path]` URL.
|
|
||||||
Dies if `url` is not an https:// URL or the host segment is empty.
|
|
||||||
Used to derive `CredProxyRoute.UpstreamHost` from a route's
|
|
||||||
`upstream` so pipelock's allowlist (and the provisioner's git-gate
|
|
||||||
overlap check) can match on host alone."""
|
|
||||||
if not url.startswith("https://"):
|
|
||||||
die(f"{label} must be an https:// URL (was {url!r})")
|
|
||||||
rest = url[len("https://"):]
|
|
||||||
hostport, _, _ = rest.partition("/")
|
|
||||||
host, _, _port = hostport.partition(":")
|
|
||||||
if not host:
|
|
||||||
die(f"{label} host is empty in {url!r}")
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_cred_proxy_routes(
|
|
||||||
bottle_name: str,
|
|
||||||
routes: tuple[CredProxyRoute, ...],
|
|
||||||
) -> None:
|
|
||||||
"""Cross-validation for `bottle.cred_proxy.routes`:
|
|
||||||
|
|
||||||
- Paths must be unique within the bottle (the proxy routes by
|
|
||||||
longest-prefix match; duplicate paths leave the choice
|
|
||||||
undefined).
|
|
||||||
- Singleton roles (`anthropic-base-url`, `npm-registry`) may
|
|
||||||
appear on at most one route — the provisioner uses them to
|
|
||||||
write a single dotfile entry, so two routes claiming the role
|
|
||||||
would make the choice ambiguous.
|
|
||||||
|
|
||||||
No cross-validation against `bottle.git` is performed. git-gate
|
|
||||||
(SSH push/fetch) and cred-proxy (HTTPS REST + git smart-HTTP
|
|
||||||
fetch) broker different protocols; declaring both on the same
|
|
||||||
host is a legitimate dev setup.
|
|
||||||
"""
|
|
||||||
seen_paths: dict[str, None] = {}
|
|
||||||
for r in routes:
|
|
||||||
if r.Path in seen_paths:
|
|
||||||
die(
|
|
||||||
f"bottle '{bottle_name}' cred_proxy.routes has duplicate path "
|
|
||||||
f"{r.Path!r}; each path must be unique on the proxy."
|
|
||||||
)
|
|
||||||
seen_paths[r.Path] = None
|
|
||||||
for role in CRED_PROXY_SINGLETON_ROLES:
|
|
||||||
with_role = [r for r in routes if role in r.Role]
|
|
||||||
if len(with_role) > 1:
|
|
||||||
paths = ", ".join(r.Path for r in with_role)
|
|
||||||
die(
|
|
||||||
f"bottle '{bottle_name}' cred_proxy.routes has {len(with_role)} "
|
|
||||||
f"routes with role {role!r} (paths: {paths}); this role drives a "
|
|
||||||
f"single agent-side rewrite — pick one."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_egress_proxy_routes(
|
def _validate_egress_proxy_routes(
|
||||||
bottle_name: str,
|
bottle_name: str,
|
||||||
routes: tuple[EgressProxyRoute, ...],
|
routes: tuple[EgressProxyRoute, ...],
|
||||||
@@ -950,7 +756,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
|||||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
||||||
# ghost into an empty config.
|
# ghost into an empty config.
|
||||||
_BOTTLE_KEYS = frozenset(
|
_BOTTLE_KEYS = frozenset(
|
||||||
{"env", "git", "cred_proxy", "egress_proxy", "egress", "supervise"}
|
{"env", "git", "egress_proxy", "egress", "supervise"}
|
||||||
)
|
)
|
||||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
|
||||||
|
|||||||
+58
-60
@@ -3,9 +3,14 @@
|
|||||||
Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
|
Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
|
||||||
forward proxy with hostname allowlisting + DLP scanning + URL-entropy
|
forward proxy with hostname allowlisting + DLP scanning + URL-entropy
|
||||||
checks. One sidecar per agent, attached to the agent's --internal
|
checks. One sidecar per agent, attached to the agent's --internal
|
||||||
network and a per-agent user-defined egress bridge. Combined with
|
network and a per-agent user-defined egress bridge.
|
||||||
HTTPS_PROXY/HTTP_PROXY pointing at the sidecar's service name, pipelock
|
|
||||||
is the only egress route the agent has.
|
Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress-proxy
|
||||||
|
(not pipelock); egress-proxy sets `HTTPS_PROXY=pipelock` on its
|
||||||
|
outbound leg. So pipelock no longer sees the agent's connections
|
||||||
|
directly — it sees the egress-proxy → upstream leg, applies the
|
||||||
|
hostname allowlist + DLP body scan there, and forwards to the real
|
||||||
|
upstream.
|
||||||
|
|
||||||
Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
||||||
"""
|
"""
|
||||||
@@ -17,7 +22,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 .egress_proxy import EGRESS_PROXY_HOSTNAME
|
||||||
from .supervise import SUPERVISE_HOSTNAME
|
from .supervise import SUPERVISE_HOSTNAME
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
@@ -57,48 +62,45 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
return list(bottle.egress.allowlist)
|
return list(bottle.egress.allowlist)
|
||||||
|
|
||||||
|
|
||||||
def pipelock_token_hosts(bottle: Bottle) -> list[str]:
|
def pipelock_route_hosts(bottle: Bottle) -> list[str]:
|
||||||
"""Hostnames the cred-proxy sidecar (PRD 0010) talks to upstream
|
"""Hostnames declared in `bottle.egress_proxy.routes`. Returned
|
||||||
on the agent's behalf. Derived from each route's
|
sorted + deduped.
|
||||||
`upstream.UpstreamHost` in `bottle.cred_proxy.routes`. Returned
|
|
||||||
sorted+deduped.
|
|
||||||
|
|
||||||
These hosts must be on pipelock's allowlist so cred-proxy's
|
Post-cutover topology (PRD 0017): the agent's HTTPS_PROXY points
|
||||||
outbound HTTPS traffic can leave the egress network. They are
|
at egress-proxy, not pipelock; egress-proxy's outbound leg sets
|
||||||
NOT auto-added to passthrough_domains: cred-proxy's HTTPS client
|
`HTTPS_PROXY=pipelock`. So pipelock no longer terminates the
|
||||||
trusts pipelock's per-bottle CA at runtime (installed via
|
agent's connections — it sees the egress-proxy → upstream leg
|
||||||
docker cp + update-ca-certificates in the cred-proxy image),
|
only. Each declared route's host still needs to be on pipelock's
|
||||||
so pipelock MITMs and body-scans the cred-proxy → upstream leg
|
allowlist so that leg can leave the egress network."""
|
||||||
the same way it does direct agent traffic."""
|
hosts = {r.Host for r in bottle.egress_proxy.routes if r.Host}
|
||||||
hosts = {r.UpstreamHost for r in bottle.cred_proxy.routes if r.UpstreamHost}
|
|
||||||
return sorted(hosts)
|
return sorted(hosts)
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
the cred-proxy upstream hosts derived from bottle.cred_proxy.routes,
|
the egress-proxy route hosts (from bottle.egress_proxy.routes), the
|
||||||
the cred-proxy sidecar's own hostname when any cred_proxy route is
|
egress-proxy sidecar's own hostname when any route is declared, and
|
||||||
declared, and the supervise sidecar's hostname when bottle.supervise
|
the supervise sidecar's hostname when bottle.supervise is enabled.
|
||||||
is enabled. Sorted for stability. Git upstreams declared in
|
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
||||||
`bottle.git` do NOT contribute here — git traffic flows through the
|
contribute here — git traffic flows through the per-agent git-gate
|
||||||
per-agent git-gate sidecar (PRD 0008), not pipelock.
|
sidecar (PRD 0008), not pipelock.
|
||||||
|
|
||||||
The cred-proxy + supervise hostnames are auto-added because the
|
The egress-proxy + supervise hostnames are auto-added because the
|
||||||
agent's HTTP_PROXY points at pipelock, so a manifest-driven URL
|
sidecars sit on the bottle's internal network alongside the agent;
|
||||||
like `http://cred-proxy:9099/anthropic/...` or
|
requests that pass through pipelock for `egress-proxy:9099` or
|
||||||
`http://supervise:9100/` arrives at pipelock as a request for the
|
`supervise:9100` (e.g. when egress-proxy uses HTTPS_PROXY=pipelock
|
||||||
sidecar hostname. Without this auto-allow, pipelock would 403 the
|
on its upstream leg) would otherwise be 403'd by pipelock's
|
||||||
request before it reached the sidecar."""
|
hostname gate."""
|
||||||
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)
|
||||||
for h in pipelock_bottle_allowlist(bottle):
|
for h in pipelock_bottle_allowlist(bottle):
|
||||||
if h:
|
if h:
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
for h in pipelock_token_hosts(bottle):
|
for h in pipelock_route_hosts(bottle):
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
if bottle.cred_proxy.routes:
|
if bottle.egress_proxy.routes:
|
||||||
seen.setdefault(CRED_PROXY_HOSTNAME, None)
|
seen.setdefault(EGRESS_PROXY_HOSTNAME, None)
|
||||||
if bottle.supervise:
|
if bottle.supervise:
|
||||||
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
||||||
return sorted(seen.keys())
|
return sorted(seen.keys())
|
||||||
@@ -122,16 +124,16 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
|
|||||||
|
|
||||||
Empirically only `seed_phrase_detection.enabled: false`
|
Empirically only `seed_phrase_detection.enabled: false`
|
||||||
actually stops the block (verified by sending a 12-word BIP-39
|
actually stops the block (verified by sending a 12-word BIP-39
|
||||||
body through three pipelock instances). It is a global toggle
|
body through three pipelock instances). It is a global toggle —
|
||||||
— there is no per-path / per-host knob in pipelock 2.3.0 — so
|
no per-path / per-host knob in pipelock 2.3.0 — so we turn the
|
||||||
we turn the detector off for the entire bottle when an
|
detector off for the entire bottle when the bottle declares an
|
||||||
`anthropic-base-url` route is declared. The trade-off is
|
egress-proxy route to `api.anthropic.com`. The trade-off is
|
||||||
accepted: BIP-39 detection has little value in claude-bottle's
|
accepted: BIP-39 detection has little value in claude-bottle's
|
||||||
threat model (the agent has no access to a user's crypto
|
threat model (the agent has no access to a user's crypto wallet
|
||||||
wallet seeds; the patterns that matter — gh*_, sk-ant-, AKIA,
|
seeds; the patterns that matter — gh*_, sk-ant-, AKIA, etc. —
|
||||||
etc. — keep firing)."""
|
keep firing)."""
|
||||||
return not any(
|
return not any(
|
||||||
"anthropic-base-url" in r.Role for r in bottle.cred_proxy.routes
|
r.Host == "api.anthropic.com" for r in bottle.egress_proxy.routes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,16 +145,12 @@ def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
|
|||||||
other allowlisted host is MITM'd by pipelock's per-bottle CA so
|
other allowlisted host is MITM'd by pipelock's per-bottle CA so
|
||||||
its body scanner sees the cleartext.
|
its body scanner sees the cleartext.
|
||||||
|
|
||||||
cred-proxy upstream hosts (github, gitea, npm) are deliberately
|
egress-proxy route hosts (github, gitea, npm) are deliberately
|
||||||
NOT auto-added here. cred-proxy's HTTPS client trusts pipelock's
|
NOT auto-added here. egress-proxy's HTTPS client trusts pipelock's
|
||||||
CA at runtime (folded into its trust store via docker cp +
|
CA at runtime (folded into its trust store via docker cp), so
|
||||||
update-ca-certificates), so pipelock can MITM the cred-proxy →
|
pipelock MITMs and body-scans the egress-proxy → upstream leg the
|
||||||
upstream leg and body-scan it the same way it body-scans the
|
same way it body-scanned the agent's direct HTTPS traffic before
|
||||||
agent's direct HTTPS traffic. Without this, an agent that pushed
|
the PRD 0017 cutover.
|
||||||
a secret via cred-proxy's /gh-git/ path would have no body
|
|
||||||
scanner in front of it. The PRD's earlier reasoning that
|
|
||||||
cred-proxy hosts needed passthrough was a workaround for the
|
|
||||||
cert-trust gap that no longer exists.
|
|
||||||
|
|
||||||
`bottle` is kept on the signature for forward-compat (a future
|
`bottle` is kept on the signature for forward-compat (a future
|
||||||
knob might let a manifest opt a host into passthrough); today
|
knob might let a manifest opt a host into passthrough); today
|
||||||
@@ -207,13 +205,13 @@ def pipelock_build_config(
|
|||||||
|
|
||||||
`ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass
|
`ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass
|
||||||
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
|
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
|
||||||
destinations by default, which would catch the agent's
|
destinations by default, which would catch sibling-sidecar
|
||||||
cred-proxy traffic (cred-proxy sits on the bottle's internal
|
traffic on the bottle's internal Docker network in 172.x space
|
||||||
Docker network in 172.x space). Pass the bottle's internal
|
(e.g. egress-proxy → pipelock on the upstream leg). Pass the
|
||||||
network CIDR here so `cred-proxy:9099` requests get through
|
bottle's internal network CIDR here so internal-network requests
|
||||||
pipelock while api_allowlist + body-scanning still apply. Empty
|
pass through pipelock while api_allowlist + body-scanning still
|
||||||
by default; omitted from the rendered yaml when empty so
|
apply. Empty by default; omitted from the rendered yaml when
|
||||||
pipelock keeps its built-in SSRF defaults."""
|
empty so pipelock keeps its built-in SSRF defaults."""
|
||||||
cfg: dict[str, object] = {
|
cfg: dict[str, object] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"mode": "strict",
|
"mode": "strict",
|
||||||
@@ -322,9 +320,9 @@ class PipelockProxyPlan:
|
|||||||
that they are populated.
|
that they are populated.
|
||||||
|
|
||||||
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
|
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
|
||||||
so the agent's requests at `cred-proxy:9099` (or any other
|
so traffic from sibling sidecars (egress-proxy → pipelock on the
|
||||||
bottle-internal sidecar) bypass pipelock's RFC1918 SSRF guard
|
upstream leg, etc.) bypasses pipelock's RFC1918 SSRF guard while
|
||||||
while api_allowlist and body-scanning still apply."""
|
api_allowlist and body-scanning still apply."""
|
||||||
|
|
||||||
yaml_path: Path
|
yaml_path: Path
|
||||||
slug: str
|
slug: str
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
"""A capture-and-echo HTTP server used as a fake upstream behind the
|
|
||||||
cred-proxy in integration tests.
|
|
||||||
|
|
||||||
Captures the last request's method, path, and headers under
|
|
||||||
/__last_request (as JSON). Returns a fixed 200 OK with a deterministic
|
|
||||||
body for every other path. Tests probe /__last_request to assert on
|
|
||||||
header injection (PRD 0010 SC3/SC6).
|
|
||||||
|
|
||||||
Stdlib-only; runs inside a python:alpine container with a single
|
|
||||||
bind-mount.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socketserver
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
|
|
||||||
|
|
||||||
_lock = threading.Lock()
|
|
||||||
_last_request: dict[str, object] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, format: str, *args: object) -> None:
|
|
||||||
# Quiet — the test reads the capture endpoint, not stderr.
|
|
||||||
return
|
|
||||||
|
|
||||||
def _capture_and_respond(self) -> None:
|
|
||||||
# Skip capturing the inspection endpoints so the test's own
|
|
||||||
# query to /__last_request doesn't overwrite the request it
|
|
||||||
# came in to inspect.
|
|
||||||
if not self.path.startswith("/__"):
|
|
||||||
with _lock:
|
|
||||||
global _last_request
|
|
||||||
_last_request = {
|
|
||||||
"method": self.command,
|
|
||||||
"path": self.path,
|
|
||||||
"headers": [[k, v] for k, v in self.headers.items()],
|
|
||||||
}
|
|
||||||
if self.path == "/__last_request":
|
|
||||||
body = json.dumps(_last_request, indent=2).encode("utf-8")
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
return
|
|
||||||
if self.path == "/__sse":
|
|
||||||
# SSE-style streaming response. Used by the no-buffering
|
|
||||||
# test: three events with short flushes between them.
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "text/event-stream")
|
|
||||||
self.send_header("Cache-Control", "no-cache")
|
|
||||||
self.end_headers()
|
|
||||||
for i in range(3):
|
|
||||||
self.wfile.write(f"data: event-{i}\n\n".encode("utf-8"))
|
|
||||||
self.wfile.flush()
|
|
||||||
return
|
|
||||||
body = b'{"upstream":"fake","ok":true}\n'
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: self._capture_and_respond()
|
|
||||||
def do_POST(self) -> None: self._capture_and_respond()
|
|
||||||
def do_PUT(self) -> None: self._capture_and_respond()
|
|
||||||
def do_DELETE(self) -> None: self._capture_and_respond()
|
|
||||||
def do_PATCH(self) -> None: self._capture_and_respond()
|
|
||||||
|
|
||||||
|
|
||||||
class FakeServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
||||||
allow_reuse_address = True
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
port = int(os.environ.get("FAKE_UPSTREAM_PORT", "8080"))
|
|
||||||
server = FakeServer(("0.0.0.0", port), Handler)
|
|
||||||
sys.stderr.write(f"fake-upstream listening on :{port}\n")
|
|
||||||
sys.stderr.flush()
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
"""Integration: drive `DockerCredProxy.prepare` → `.start` against a
|
|
||||||
fake upstream container, then verify header injection / strip-and-
|
|
||||||
replace at the wire level (PRD 0010 SC2, SC3, SC6).
|
|
||||||
|
|
||||||
Topology mirrors production: a per-bottle internal docker network (no
|
|
||||||
default gateway) for the agent ↔ cred-proxy leg, and an egress network
|
|
||||||
for cred-proxy ↔ upstream. The "agent" is a curl container on the
|
|
||||||
internal net; the "upstream" is the fake-upstream container on the
|
|
||||||
egress net. cred-proxy straddles both.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.backend.docker.cred_proxy import (
|
|
||||||
CRED_PROXY_HOSTNAME,
|
|
||||||
CRED_PROXY_PORT,
|
|
||||||
DockerCredProxy,
|
|
||||||
build_cred_proxy_image,
|
|
||||||
cred_proxy_container_name,
|
|
||||||
)
|
|
||||||
from claude_bottle.backend.docker.network import (
|
|
||||||
network_create_egress,
|
|
||||||
network_create_internal,
|
|
||||||
network_remove,
|
|
||||||
)
|
|
||||||
from tests._docker import skip_unless_docker
|
|
||||||
|
|
||||||
|
|
||||||
CURL_IMAGE = "curlimages/curl:latest"
|
|
||||||
FAKE_UPSTREAM_IMAGE = "python:3.13-alpine"
|
|
||||||
FAKE_UPSTREAM_HOST = "fake-upstream"
|
|
||||||
FAKE_UPSTREAM_PORT = "8080"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_routes_json(upstream_host: str, upstream_port: str) -> str:
|
|
||||||
payload = {
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"path": "/fake/",
|
|
||||||
"upstream": f"http://{upstream_host}:{upstream_port}",
|
|
||||||
"auth_scheme": "Bearer",
|
|
||||||
"token_env": "CRED_PROXY_TOKEN_0",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return json.dumps(payload, indent=2) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
|
||||||
class TestCredProxySidecar(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
# Pre-pull the probe + fake-upstream base images so per-test
|
|
||||||
# retries don't race the registry. Skip if pulls fail (the
|
|
||||||
# canary suite separately probes registry health).
|
|
||||||
for image in (CURL_IMAGE, FAKE_UPSTREAM_IMAGE):
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "pull", image],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise unittest.SkipTest(f"could not pull {image}")
|
|
||||||
build_cred_proxy_image()
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.slug = f"cb-test-cp-{os.getpid()}"
|
|
||||||
self.proxy_name = ""
|
|
||||||
self.fake_name = f"fake-upstream-{self.slug}"
|
|
||||||
self.internal_net = ""
|
|
||||||
self.egress_net = ""
|
|
||||||
self.work_dir = Path(tempfile.mkdtemp())
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
for name in (self.proxy_name, self.fake_name):
|
|
||||||
if name:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for n in (self.internal_net, self.egress_net):
|
|
||||||
if n:
|
|
||||||
network_remove(n)
|
|
||||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
def _bring_up_fake_upstream(self) -> None:
|
|
||||||
"""Run the fake-upstream container on the egress network with
|
|
||||||
the host stable name `fake-upstream`. Bind-mount the script
|
|
||||||
from tests/integration/."""
|
|
||||||
repo_dir = str(Path(__file__).resolve().parent.parent.parent)
|
|
||||||
script = "tests/integration/_fake_upstream.py"
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "-d",
|
|
||||||
"--name", self.fake_name,
|
|
||||||
"--hostname", FAKE_UPSTREAM_HOST,
|
|
||||||
"--network", self.egress_net,
|
|
||||||
"--network-alias", FAKE_UPSTREAM_HOST,
|
|
||||||
"-v", f"{repo_dir}/{script}:/srv.py:ro",
|
|
||||||
"-e", f"FAKE_UPSTREAM_PORT={FAKE_UPSTREAM_PORT}",
|
|
||||||
FAKE_UPSTREAM_IMAGE,
|
|
||||||
"python3", "/srv.py",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
self.fail(f"failed to start fake-upstream: {r.stderr}")
|
|
||||||
|
|
||||||
def _start_cred_proxy_via_production_code(self) -> str:
|
|
||||||
"""Run DockerCredProxy.start with a plan that points at the
|
|
||||||
fake upstream. We bypass the manifest path so we can route
|
|
||||||
the proxy at a test-only upstream (the fake-upstream
|
|
||||||
container) without going through the parser."""
|
|
||||||
from claude_bottle.cred_proxy import (
|
|
||||||
CredProxyPlan,
|
|
||||||
CredProxyRoute,
|
|
||||||
)
|
|
||||||
routes_path = self.work_dir / "routes.json"
|
|
||||||
routes_path.write_text(_make_routes_json(FAKE_UPSTREAM_HOST, FAKE_UPSTREAM_PORT))
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
plan = CredProxyPlan(
|
|
||||||
slug=self.slug,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=(CredProxyRoute(
|
|
||||||
path="/fake/",
|
|
||||||
upstream=f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}",
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token_env="CRED_PROXY_TOKEN_0",
|
|
||||||
token_ref="TEST_TOKEN",
|
|
||||||
),),
|
|
||||||
token_env_map={"CRED_PROXY_TOKEN_0": "TEST_TOKEN"},
|
|
||||||
internal_network=self.internal_net,
|
|
||||||
egress_network=self.egress_net,
|
|
||||||
)
|
|
||||||
# Inject the host-side TEST_TOKEN into our process env so the
|
|
||||||
# production resolver picks it up.
|
|
||||||
os.environ["TEST_TOKEN"] = "real-token-injected-by-proxy"
|
|
||||||
try:
|
|
||||||
return DockerCredProxy().start(plan)
|
|
||||||
finally:
|
|
||||||
os.environ.pop("TEST_TOKEN", None)
|
|
||||||
|
|
||||||
def _curl_via_internal_net(self, path: str, *extra: str) -> str:
|
|
||||||
"""Run a sibling curl container on the internal network — same
|
|
||||||
access topology the agent uses in production — to hit the
|
|
||||||
cred-proxy. Returns stdout."""
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "--rm",
|
|
||||||
"--network", self.internal_net,
|
|
||||||
CURL_IMAGE,
|
|
||||||
"-s", "--max-time", "10",
|
|
||||||
"--retry", "20", "--retry-delay", "1", "--retry-connrefused",
|
|
||||||
*extra,
|
|
||||||
f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}{path}",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, timeout=60, check=False,
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode,
|
|
||||||
f"curl failed: stdout={r.stdout!r} stderr={r.stderr!r}")
|
|
||||||
return r.stdout
|
|
||||||
|
|
||||||
def _query_fake_capture(self) -> dict:
|
|
||||||
"""Read the fake upstream's /__last_request endpoint to see
|
|
||||||
what headers it received."""
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "--rm",
|
|
||||||
"--network", self.egress_net,
|
|
||||||
CURL_IMAGE,
|
|
||||||
"-s", "--max-time", "10",
|
|
||||||
"--retry", "5", "--retry-delay", "1", "--retry-connrefused",
|
|
||||||
f"http://{FAKE_UPSTREAM_HOST}:{FAKE_UPSTREAM_PORT}/__last_request",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, timeout=30, check=False,
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode, f"capture query failed: {r.stderr}")
|
|
||||||
return json.loads(r.stdout)
|
|
||||||
|
|
||||||
@unittest.skipIf(
|
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
|
||||||
"skipped under act_runner: docker socket mount topology breaks "
|
|
||||||
"in-process visibility of networks created on the host daemon",
|
|
||||||
)
|
|
||||||
def test_end_to_end_header_injection_and_strip(self):
|
|
||||||
"""Full bring-up via the production DockerCredProxy code path,
|
|
||||||
then send a request from a sibling curl container with the
|
|
||||||
agent's `Authorization` header. The fake upstream's capture
|
|
||||||
must show:
|
|
||||||
- the agent's Authorization was stripped (no `stolen` token)
|
|
||||||
- the cred-proxy injected `Bearer real-token-injected-by-proxy`
|
|
||||||
- the request reached the upstream at all
|
|
||||||
"""
|
|
||||||
self.internal_net = network_create_internal(self.slug)
|
|
||||||
self.egress_net = network_create_egress(self.slug)
|
|
||||||
self._bring_up_fake_upstream()
|
|
||||||
self.proxy_name = self._start_cred_proxy_via_production_code()
|
|
||||||
self.assertEqual(cred_proxy_container_name(self.slug), self.proxy_name)
|
|
||||||
|
|
||||||
# Agent → cred-proxy with a smuggled Authorization header.
|
|
||||||
body = self._curl_via_internal_net(
|
|
||||||
"/fake/v1/messages",
|
|
||||||
"-H", "Authorization: Bearer stolen-by-prompt-injection",
|
|
||||||
"-X", "POST",
|
|
||||||
"-H", "Content-Type: application/json",
|
|
||||||
"--data-binary", '{"hello":"world"}',
|
|
||||||
)
|
|
||||||
# The fake upstream responds with a fixed body.
|
|
||||||
self.assertIn('"upstream":"fake"', body)
|
|
||||||
|
|
||||||
# Now ask the fake upstream what headers it actually saw.
|
|
||||||
captured = self._query_fake_capture()
|
|
||||||
self.assertEqual("POST", captured["method"])
|
|
||||||
self.assertEqual("/v1/messages", captured["path"],
|
|
||||||
"the /fake/ prefix should be stripped before forwarding")
|
|
||||||
|
|
||||||
headers = {k.lower(): v for k, v in captured["headers"]}
|
|
||||||
self.assertEqual(
|
|
||||||
"Bearer real-token-injected-by-proxy",
|
|
||||||
headers.get("authorization"),
|
|
||||||
"cred-proxy must strip the inbound Authorization and inject "
|
|
||||||
"the configured value",
|
|
||||||
)
|
|
||||||
self.assertNotIn("stolen", headers.get("authorization", ""),
|
|
||||||
"the agent's smuggled token must NOT reach upstream")
|
|
||||||
self.assertEqual(
|
|
||||||
FAKE_UPSTREAM_HOST,
|
|
||||||
headers.get("host"),
|
|
||||||
"Host header should point at the upstream, not the proxy",
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(
|
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
|
||||||
"skipped under act_runner: docker socket mount topology breaks "
|
|
||||||
"in-process visibility of networks created on the host daemon",
|
|
||||||
)
|
|
||||||
def test_unknown_path_returns_404(self):
|
|
||||||
"""An agent reaching for an unconfigured route gets a 404,
|
|
||||||
not a silent forward to anywhere."""
|
|
||||||
self.internal_net = network_create_internal(self.slug)
|
|
||||||
self.egress_net = network_create_egress(self.slug)
|
|
||||||
self._bring_up_fake_upstream()
|
|
||||||
self.proxy_name = self._start_cred_proxy_via_production_code()
|
|
||||||
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "--rm",
|
|
||||||
"--network", self.internal_net,
|
|
||||||
CURL_IMAGE,
|
|
||||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
"--max-time", "10",
|
|
||||||
"--retry", "20", "--retry-delay", "1", "--retry-connrefused",
|
|
||||||
f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}/not-a-route",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, timeout=60, check=False,
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode)
|
|
||||||
self.assertEqual("404", r.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""Integration: SIGHUP reload + host-side apply_routes_change
|
|
||||||
(PRD 0014).
|
|
||||||
|
|
||||||
Brings up a real cred-proxy sidecar with one route, then uses
|
|
||||||
apply_routes_change (docker cp + SIGHUP) to swap to a different
|
|
||||||
route. Verifies cred-proxy actually serves the new routes after the
|
|
||||||
reload (and 404s the old ones).
|
|
||||||
|
|
||||||
Avoids a real upstream by routing to unreachable hostnames — the
|
|
||||||
proxy's 502 "upstream connection failed" is a sufficient signal that
|
|
||||||
the route matched. 404 means no route matched.
|
|
||||||
|
|
||||||
apply_routes_change uses docker exec / cp / kill (not bind mounts),
|
|
||||||
so this test should work in docker-in-docker environments too — no
|
|
||||||
skip decorator beyond skip_unless_docker.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.backend.docker.cred_proxy import (
|
|
||||||
CRED_PROXY_PORT,
|
|
||||||
DockerCredProxy,
|
|
||||||
build_cred_proxy_image,
|
|
||||||
cred_proxy_container_name,
|
|
||||||
)
|
|
||||||
from claude_bottle.backend.docker.cred_proxy_apply import (
|
|
||||||
CredProxyApplyError,
|
|
||||||
apply_routes_change,
|
|
||||||
fetch_current_routes,
|
|
||||||
)
|
|
||||||
from claude_bottle.backend.docker.network import (
|
|
||||||
network_create_egress,
|
|
||||||
network_create_internal,
|
|
||||||
network_remove,
|
|
||||||
)
|
|
||||||
from claude_bottle.cred_proxy import (
|
|
||||||
CRED_PROXY_HOSTNAME,
|
|
||||||
CredProxyPlan,
|
|
||||||
CredProxyRoute,
|
|
||||||
)
|
|
||||||
from tests._docker import skip_unless_docker
|
|
||||||
|
|
||||||
|
|
||||||
CURL_IMAGE = "curlimages/curl:latest"
|
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
|
||||||
class TestCredProxySighupReload(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "pull", CURL_IMAGE],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise unittest.SkipTest(f"could not pull {CURL_IMAGE}")
|
|
||||||
build_cred_proxy_image()
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.slug = f"cb-test-sighup-{os.getpid()}-{int(time.time())}"
|
|
||||||
self.proxy_name = ""
|
|
||||||
self.internal_net = ""
|
|
||||||
self.egress_net = ""
|
|
||||||
self.work_dir = Path(tempfile.mkdtemp(prefix="cred-proxy-sighup."))
|
|
||||||
# Token value for both initial and post-SIGHUP routes — they
|
|
||||||
# share the same TokenRef so they share CRED_PROXY_TOKEN_0 in
|
|
||||||
# the container's environ.
|
|
||||||
os.environ["CB_SIGHUP_TEST_TOKEN"] = "test-token"
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
os.environ.pop("CB_SIGHUP_TEST_TOKEN", None)
|
|
||||||
if self.proxy_name:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", self.proxy_name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
for n in (self.internal_net, self.egress_net):
|
|
||||||
if n:
|
|
||||||
network_remove(n)
|
|
||||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
def _bring_up_with_route(self, path: str, upstream: str) -> None:
|
|
||||||
self.internal_net = network_create_internal(self.slug)
|
|
||||||
self.egress_net = network_create_egress(self.slug)
|
|
||||||
route = CredProxyRoute(
|
|
||||||
path=path,
|
|
||||||
upstream=upstream,
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token_env="CRED_PROXY_TOKEN_0",
|
|
||||||
token_ref="CB_SIGHUP_TEST_TOKEN",
|
|
||||||
)
|
|
||||||
routes_path = self.work_dir / "routes.json"
|
|
||||||
from claude_bottle.cred_proxy import cred_proxy_render_routes
|
|
||||||
routes_path.write_text(cred_proxy_render_routes((route,)))
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
plan = CredProxyPlan(
|
|
||||||
slug=self.slug,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=(route,),
|
|
||||||
token_env_map={"CRED_PROXY_TOKEN_0": "CB_SIGHUP_TEST_TOKEN"},
|
|
||||||
internal_network=self.internal_net,
|
|
||||||
egress_network=self.egress_net,
|
|
||||||
# No pipelock for this test — the proxy talks directly to
|
|
||||||
# the egress network. Upstreams are unreachable so the
|
|
||||||
# 502s confirm the route table.
|
|
||||||
)
|
|
||||||
self.proxy_name = DockerCredProxy().start(plan)
|
|
||||||
# Wait until the proxy is serving (it's the only way I have
|
|
||||||
# to know python has bound to the port).
|
|
||||||
deadline = time.monotonic() + 10.0
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
code = self._curl("/__probe/")
|
|
||||||
if code in (404, 502): # serving — either response proves it's up
|
|
||||||
return
|
|
||||||
time.sleep(0.2)
|
|
||||||
raise AssertionError("cred-proxy never came up")
|
|
||||||
|
|
||||||
def _curl(self, path: str) -> int | None:
|
|
||||||
"""Return the HTTP status from a curl-in-container request to
|
|
||||||
the cred-proxy, or None on connection failure."""
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "run", "--rm",
|
|
||||||
"--network", self.internal_net,
|
|
||||||
CURL_IMAGE,
|
|
||||||
"-sS", "-o", "/dev/null",
|
|
||||||
"-w", "%{http_code}",
|
|
||||||
"--max-time", "8",
|
|
||||||
f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}{path}",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(r.stdout.strip())
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_sighup_swaps_routes(self):
|
|
||||||
"""Initial route /a/ matches (502 from unreachable upstream);
|
|
||||||
/b/ 404s. After apply_routes_change with /b/ only, the table
|
|
||||||
flips: /a/ 404s, /b/ matches."""
|
|
||||||
self._bring_up_with_route("/a/", "https://unreachable-a.example")
|
|
||||||
|
|
||||||
self.assertEqual(502, self._curl("/a/foo"))
|
|
||||||
self.assertEqual(404, self._curl("/b/foo"))
|
|
||||||
|
|
||||||
new_routes = json.dumps({"routes": [{
|
|
||||||
"path": "/b/",
|
|
||||||
"upstream": "https://unreachable-b.example",
|
|
||||||
"auth_scheme": "Bearer",
|
|
||||||
"token_env": "CRED_PROXY_TOKEN_0",
|
|
||||||
}]}) + "\n"
|
|
||||||
|
|
||||||
before, after = apply_routes_change(self.slug, new_routes)
|
|
||||||
self.assertIn("/a/", before)
|
|
||||||
self.assertEqual(new_routes, after)
|
|
||||||
|
|
||||||
# SIGHUP propagates as a Python signal — runs at the next
|
|
||||||
# bytecode boundary on the main thread. Give it a moment.
|
|
||||||
deadline = time.monotonic() + 5.0
|
|
||||||
flipped = False
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if self._curl("/a/foo") == 404 and self._curl("/b/foo") == 502:
|
|
||||||
flipped = True
|
|
||||||
break
|
|
||||||
time.sleep(0.2)
|
|
||||||
self.assertTrue(flipped, "SIGHUP reload did not propagate to the route table")
|
|
||||||
|
|
||||||
def test_in_flight_connections_survive_sighup(self):
|
|
||||||
"""SIGHUP must reload without dropping the bound socket. The
|
|
||||||
signal handler runs on the main thread; in-flight worker
|
|
||||||
threads keep the routes they captured at request start.
|
|
||||||
Verified by issuing a request right after SIGHUP and seeing
|
|
||||||
the new route in effect (the listener never restarted)."""
|
|
||||||
self._bring_up_with_route("/a/", "https://unreachable.example")
|
|
||||||
# Fetching the current routes also proves the proxy is up.
|
|
||||||
current = fetch_current_routes(self.slug)
|
|
||||||
self.assertIn("/a/", current)
|
|
||||||
|
|
||||||
new_routes = json.dumps({"routes": [{
|
|
||||||
"path": "/c/",
|
|
||||||
"upstream": "https://unreachable-c.example",
|
|
||||||
"auth_scheme": "Bearer",
|
|
||||||
"token_env": "CRED_PROXY_TOKEN_0",
|
|
||||||
}]}) + "\n"
|
|
||||||
apply_routes_change(self.slug, new_routes)
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 5.0
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if self._curl("/c/foo") == 502:
|
|
||||||
return
|
|
||||||
time.sleep(0.2)
|
|
||||||
self.fail("new route not picked up after SIGHUP")
|
|
||||||
|
|
||||||
def test_apply_with_invalid_json_raises(self):
|
|
||||||
self._bring_up_with_route("/a/", "https://unreachable.example")
|
|
||||||
with self.assertRaises(CredProxyApplyError) as cm:
|
|
||||||
apply_routes_change(self.slug, "{not json")
|
|
||||||
self.assertIn("not valid JSON", str(cm.exception))
|
|
||||||
|
|
||||||
def test_apply_against_missing_sidecar_raises(self):
|
|
||||||
# Don't bring up the sidecar; the slug points at nothing.
|
|
||||||
with self.assertRaises(CredProxyApplyError):
|
|
||||||
apply_routes_change(
|
|
||||||
self.slug,
|
|
||||||
'{"routes": [{"path": "/x/", "upstream": "https://example.com",'
|
|
||||||
' "auth_scheme": "Bearer", "token_env": "CRED_PROXY_TOKEN_0"}]}',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -205,8 +205,15 @@ class TestSuperviseSidecar(unittest.TestCase):
|
|||||||
|
|
||||||
def test_tools_call_round_trips_through_queue(self):
|
def test_tools_call_round_trips_through_queue(self):
|
||||||
"""End-to-end: agent in the bottle calls cred-proxy-block;
|
"""End-to-end: agent in the bottle calls cred-proxy-block;
|
||||||
the call blocks on the queue; the host approves via the
|
the call blocks on the queue; the host rejects via the
|
||||||
dashboard helpers; the agent receives the approval."""
|
dashboard helpers; the agent receives the rejection.
|
||||||
|
|
||||||
|
PRD 0017 chunk 2 deleted the cred-proxy sidecar, so the
|
||||||
|
approval-apply path on cred-proxy-block is broken in this
|
||||||
|
intermediate state (chunk 3 retargets it at egress-proxy and
|
||||||
|
restores the round-trip approval test). For now this verifies
|
||||||
|
only the queue + response leg by exercising the reject path
|
||||||
|
— no docker-exec into a sidecar needed."""
|
||||||
self._require_bind_mount_sharing()
|
self._require_bind_mount_sharing()
|
||||||
self._bring_up_sidecar()
|
self._bring_up_sidecar()
|
||||||
|
|
||||||
@@ -246,10 +253,11 @@ class TestSuperviseSidecar(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual("integration test", qp.proposal.justification)
|
self.assertEqual("integration test", qp.proposal.justification)
|
||||||
|
|
||||||
# Approve via the dashboard helper (same path the TUI
|
# Reject via the dashboard helper. The reject path skips
|
||||||
# uses). For 0013 this writes a Response file + a no-op
|
# the sidecar-apply step, so it works without a real
|
||||||
# audit entry (no real config change).
|
# cred-proxy sidecar (which doesn't exist in chunk 2's
|
||||||
dashboard.approve(qp, notes="lgtm from integration test")
|
# transitional state).
|
||||||
|
dashboard.reject(qp, reason="no real cred-proxy in chunk 2")
|
||||||
finally:
|
finally:
|
||||||
t.join(timeout=20)
|
t.join(timeout=20)
|
||||||
|
|
||||||
@@ -259,10 +267,12 @@ class TestSuperviseSidecar(unittest.TestCase):
|
|||||||
self.assertEqual(7, response["id"])
|
self.assertEqual(7, response["id"])
|
||||||
result = response["result"]
|
result = response["result"]
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
self.assertFalse(result.get("isError"))
|
# Rejected tool calls surface as MCP errors so the agent
|
||||||
|
# treats them as failures (not silent successes).
|
||||||
|
self.assertTrue(result.get("isError"))
|
||||||
text = result["content"][0]["text"]
|
text = result["content"][0]["text"]
|
||||||
self.assertIn("status: approved", text)
|
self.assertIn("rejected", text)
|
||||||
self.assertIn("notes: lgtm from integration test", text)
|
self.assertIn("no real cred-proxy", text)
|
||||||
|
|
||||||
def test_orphan_sidecar_name_collision_recovered(self):
|
def test_orphan_sidecar_name_collision_recovered(self):
|
||||||
"""An orphan supervise sidecar from a previous run blocks
|
"""An orphan supervise sidecar from a previous run blocks
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
"""Unit: CredProxy route lift + routes.json render + token resolution
|
|
||||||
(PRD 0010)."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from claude_bottle.cred_proxy import (
|
|
||||||
cred_proxy_render_routes,
|
|
||||||
cred_proxy_resolve_token_values,
|
|
||||||
cred_proxy_token_env_map,
|
|
||||||
cred_proxy_routes_for_bottle,
|
|
||||||
)
|
|
||||||
from claude_bottle.log import Die
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _bottle(routes):
|
|
||||||
return Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {"cred_proxy": {"routes": routes}}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}).bottles["dev"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpstreamLift(unittest.TestCase):
|
|
||||||
def test_single_route_yields_single_upstream(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
])
|
|
||||||
upstreams = cred_proxy_routes_for_bottle(b)
|
|
||||||
self.assertEqual(1, len(upstreams))
|
|
||||||
u = upstreams[0]
|
|
||||||
self.assertEqual("/anthropic/", u.path)
|
|
||||||
self.assertEqual("https://api.anthropic.com", u.upstream)
|
|
||||||
self.assertEqual("Bearer", u.auth_scheme)
|
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_0", u.token_env)
|
|
||||||
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", u.token_ref)
|
|
||||||
self.assertEqual(("anthropic-base-url",), u.roles)
|
|
||||||
|
|
||||||
def test_shared_token_ref_collapses_to_one_slot(self):
|
|
||||||
# Two github routes share GH_PAT — they share token_env.
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH_PAT"},
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH_PAT",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
])
|
|
||||||
upstreams = cred_proxy_routes_for_bottle(b)
|
|
||||||
self.assertEqual(2, len(upstreams))
|
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0"},
|
|
||||||
{u.token_env for u in upstreams})
|
|
||||||
|
|
||||||
def test_distinct_token_refs_get_distinct_slots(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/a/", "upstream": "https://a.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T1"},
|
|
||||||
{"path": "/b/", "upstream": "https://b.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T2"},
|
|
||||||
{"path": "/c/", "upstream": "https://c.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T1"},
|
|
||||||
])
|
|
||||||
upstreams = cred_proxy_routes_for_bottle(b)
|
|
||||||
# T1 -> slot 0, T2 -> slot 1, T1 reuses slot 0.
|
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[0].token_env)
|
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_1", upstreams[1].token_env)
|
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_0", upstreams[2].token_env)
|
|
||||||
|
|
||||||
def test_upstream_trailing_slash_stripped(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/x/", "upstream": "https://gitea.dideric.is/",
|
|
||||||
"auth_scheme": "token", "token_ref": "T"},
|
|
||||||
])
|
|
||||||
self.assertEqual("https://gitea.dideric.is",
|
|
||||||
cred_proxy_routes_for_bottle(b)[0].upstream)
|
|
||||||
|
|
||||||
def test_roles_list_passes_through(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "T",
|
|
||||||
"role": ["git-insteadof", "tea-login"]},
|
|
||||||
])
|
|
||||||
self.assertEqual(("git-insteadof", "tea-login"),
|
|
||||||
cred_proxy_routes_for_bottle(b)[0].roles)
|
|
||||||
|
|
||||||
def test_empty_routes_yields_empty_upstreams(self):
|
|
||||||
b = _bottle([])
|
|
||||||
self.assertEqual((), cred_proxy_routes_for_bottle(b))
|
|
||||||
|
|
||||||
|
|
||||||
class TestTokenEnvMap(unittest.TestCase):
|
|
||||||
def test_distinct_envs_yield_full_map(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/a/", "upstream": "https://a.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "A"},
|
|
||||||
{"path": "/b/", "upstream": "https://b.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "B"},
|
|
||||||
])
|
|
||||||
m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b))
|
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "A",
|
|
||||||
"CRED_PROXY_TOKEN_1": "B"}, m)
|
|
||||||
|
|
||||||
def test_shared_token_ref_yields_one_env(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH"},
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH"},
|
|
||||||
])
|
|
||||||
m = cred_proxy_token_env_map(cred_proxy_routes_for_bottle(b))
|
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "GH"}, m)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRoutesRender(unittest.TestCase):
|
|
||||||
def test_renders_json_with_expected_shape(self):
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.dideric.is",
|
|
||||||
"auth_scheme": "token", "token_ref": "GITEA_TOKEN"},
|
|
||||||
])
|
|
||||||
rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b))
|
|
||||||
payload = json.loads(rendered)
|
|
||||||
self.assertEqual(["routes"], list(payload.keys()))
|
|
||||||
self.assertEqual(2, len(payload["routes"]))
|
|
||||||
first = payload["routes"][0]
|
|
||||||
self.assertEqual({"path", "upstream", "auth_scheme", "token_env"},
|
|
||||||
set(first.keys()))
|
|
||||||
|
|
||||||
def test_routes_carry_no_token_values_or_host_env_names(self):
|
|
||||||
# routes.json lives mode-600 in the staging dir and gets
|
|
||||||
# docker cp'd into the sidecar — it must not leak secret values
|
|
||||||
# or the host-side TokenRef name.
|
|
||||||
b = _bottle([{"path": "/x/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"}])
|
|
||||||
rendered = cred_proxy_render_routes(cred_proxy_routes_for_bottle(b))
|
|
||||||
self.assertNotIn("GITHUB_TOKEN", rendered)
|
|
||||||
|
|
||||||
def test_empty_upstreams_renders_empty_routes_array(self):
|
|
||||||
rendered = cred_proxy_render_routes(())
|
|
||||||
self.assertEqual({"routes": []}, json.loads(rendered))
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveTokenValues(unittest.TestCase):
|
|
||||||
def test_resolves_present_env(self):
|
|
||||||
out = cred_proxy_resolve_token_values(
|
|
||||||
{"CRED_PROXY_TOKEN_0": "FOO"},
|
|
||||||
{"FOO": "the-value"},
|
|
||||||
)
|
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "the-value"}, out)
|
|
||||||
|
|
||||||
def test_unset_host_env_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
cred_proxy_resolve_token_values(
|
|
||||||
{"CRED_PROXY_TOKEN_0": "MISSING"},
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_host_env_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
cred_proxy_resolve_token_values(
|
|
||||||
{"CRED_PROXY_TOKEN_0": "FOO"},
|
|
||||||
{"FOO": ""},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCredProxyPrepare(unittest.TestCase):
|
|
||||||
def test_prepare_writes_routes_file_and_returns_plan(self):
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.cred_proxy import CredProxy, CredProxyPlan
|
|
||||||
|
|
||||||
class StubCredProxy(CredProxy):
|
|
||||||
def start(self, plan): return ""
|
|
||||||
def stop(self, target): return None
|
|
||||||
|
|
||||||
b = _bottle([
|
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN"},
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GITHUB_TOKEN",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
])
|
|
||||||
with tempfile.TemporaryDirectory() as td:
|
|
||||||
stage = Path(td)
|
|
||||||
plan = StubCredProxy().prepare(b, "test-slug", stage)
|
|
||||||
self.assertIsInstance(plan, CredProxyPlan)
|
|
||||||
self.assertEqual("test-slug", plan.slug)
|
|
||||||
self.assertTrue(plan.routes_path.is_file())
|
|
||||||
self.assertEqual(0o600, plan.routes_path.stat().st_mode & 0o777)
|
|
||||||
payload = json.loads(plan.routes_path.read_text())
|
|
||||||
self.assertEqual(2, len(payload["routes"]))
|
|
||||||
self.assertEqual({"CRED_PROXY_TOKEN_0": "GITHUB_TOKEN"},
|
|
||||||
plan.token_env_map)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
"""Unit: cred-proxy server pure functions — route parsing, route
|
|
||||||
selection, header injection (PRD 0010); SIGHUP reload (PRD 0014)."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.cred_proxy_server import (
|
|
||||||
CredProxyServer,
|
|
||||||
Route,
|
|
||||||
build_forward_headers,
|
|
||||||
filter_response_headers,
|
|
||||||
is_git_push_request,
|
|
||||||
load_tokens,
|
|
||||||
parse_routes,
|
|
||||||
reload_routes,
|
|
||||||
select_route,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseRoutes(unittest.TestCase):
|
|
||||||
def test_parses_minimal_payload(self):
|
|
||||||
routes = parse_routes({"routes": [
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "CRED_PROXY_TOKEN_0"},
|
|
||||||
]})
|
|
||||||
self.assertEqual(1, len(routes))
|
|
||||||
r = routes[0]
|
|
||||||
self.assertEqual("/anthropic/", r.path)
|
|
||||||
self.assertEqual("https", r.upstream_scheme)
|
|
||||||
self.assertEqual("api.anthropic.com", r.upstream_host)
|
|
||||||
self.assertEqual(443, r.upstream_port)
|
|
||||||
self.assertEqual("", r.upstream_base_path)
|
|
||||||
self.assertEqual("Bearer", r.auth_scheme)
|
|
||||||
self.assertEqual("CRED_PROXY_TOKEN_0", r.token_env)
|
|
||||||
|
|
||||||
def test_extracts_port_from_upstream(self):
|
|
||||||
routes = parse_routes({"routes": [
|
|
||||||
{"path": "/gitea/gitea.dideric.is/",
|
|
||||||
"upstream": "https://gitea.dideric.is:30443",
|
|
||||||
"auth_scheme": "token", "token_env": "CRED_PROXY_TOKEN_0"},
|
|
||||||
]})
|
|
||||||
self.assertEqual(30443, routes[0].upstream_port)
|
|
||||||
|
|
||||||
def test_sorted_by_descending_path_length(self):
|
|
||||||
# /a/b/ should come before /a/ so longest-prefix is first.
|
|
||||||
routes = parse_routes({"routes": [
|
|
||||||
{"path": "/a/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T1"},
|
|
||||||
{"path": "/a/b/", "upstream": "https://y.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T2"},
|
|
||||||
]})
|
|
||||||
self.assertEqual("/a/b/", routes[0].path)
|
|
||||||
self.assertEqual("/a/", routes[1].path)
|
|
||||||
|
|
||||||
def test_bad_path_rejected(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_routes({"routes": [
|
|
||||||
{"path": "no-leading-slash", "upstream": "https://x",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T"},
|
|
||||||
]})
|
|
||||||
|
|
||||||
def test_non_http_scheme_rejected(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_routes({"routes": [
|
|
||||||
{"path": "/x/", "upstream": "ftp://x.example/",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T"},
|
|
||||||
]})
|
|
||||||
|
|
||||||
|
|
||||||
class TestSelectRoute(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.routes = parse_routes({"routes": [
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T_A"},
|
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T_G"},
|
|
||||||
{"path": "/gitea/gitea.dideric.is/",
|
|
||||||
"upstream": "https://gitea.dideric.is",
|
|
||||||
"auth_scheme": "token", "token_env": "T_T"},
|
|
||||||
]})
|
|
||||||
|
|
||||||
def test_matches_prefix(self):
|
|
||||||
r = select_route(self.routes, "/anthropic/v1/messages")
|
|
||||||
assert r is not None
|
|
||||||
self.assertEqual("/anthropic/", r.path)
|
|
||||||
|
|
||||||
def test_no_match_returns_none(self):
|
|
||||||
self.assertIsNone(select_route(self.routes, "/other/path"))
|
|
||||||
|
|
||||||
def test_picks_longest_prefix(self):
|
|
||||||
routes = parse_routes({"routes": [
|
|
||||||
{"path": "/a/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T1"},
|
|
||||||
{"path": "/a/long/", "upstream": "https://y.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T2"},
|
|
||||||
]})
|
|
||||||
r = select_route(routes, "/a/long/sub")
|
|
||||||
assert r is not None
|
|
||||||
self.assertEqual("/a/long/", r.path)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildForwardHeaders(unittest.TestCase):
|
|
||||||
def test_strips_authorization_and_injects(self):
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("Authorization", "Bearer stolen-token"),
|
|
||||||
("Content-Type", "application/json")],
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token="real-token",
|
|
||||||
upstream_host="api.anthropic.com",
|
|
||||||
)
|
|
||||||
names = [n.lower() for n, _ in headers]
|
|
||||||
# Only one Authorization remains, with the injected value.
|
|
||||||
auth_values = [v for n, v in headers if n.lower() == "authorization"]
|
|
||||||
self.assertEqual(["Bearer real-token"], auth_values)
|
|
||||||
self.assertEqual(1, names.count("authorization"))
|
|
||||||
# Content-Type passes through.
|
|
||||||
self.assertIn(("Content-Type", "application/json"), headers)
|
|
||||||
|
|
||||||
def test_strips_authorization_case_insensitive(self):
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("authorization", "Bearer stolen")],
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token="real",
|
|
||||||
upstream_host="x.example",
|
|
||||||
)
|
|
||||||
auth_values = [v for n, v in headers if n.lower() == "authorization"]
|
|
||||||
self.assertEqual(["Bearer real"], auth_values)
|
|
||||||
|
|
||||||
def test_strips_hop_by_hop(self):
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("Connection", "keep-alive, x-custom"),
|
|
||||||
("X-Custom", "should-be-dropped"),
|
|
||||||
("Keep-Alive", "300"),
|
|
||||||
("Transfer-Encoding", "chunked"),
|
|
||||||
("X-Real", "kept")],
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token="t",
|
|
||||||
upstream_host="x.example",
|
|
||||||
)
|
|
||||||
names = [n.lower() for n, _ in headers]
|
|
||||||
self.assertNotIn("connection", names)
|
|
||||||
self.assertNotIn("keep-alive", names)
|
|
||||||
self.assertNotIn("transfer-encoding", names)
|
|
||||||
self.assertNotIn("x-custom", names) # listed in Connection: -> hop-by-hop
|
|
||||||
self.assertIn("x-real", names)
|
|
||||||
|
|
||||||
def test_forces_identity_accept_encoding(self):
|
|
||||||
# The agent's gzip/br Accept-Encoding gets replaced with
|
|
||||||
# `identity` so the upstream returns uncompressed bytes —
|
|
||||||
# pipelock's response scanner can't read compressed bodies
|
|
||||||
# and would 403 with "compressed sse_stream response cannot
|
|
||||||
# be scanned".
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("Accept-Encoding", "gzip, deflate, br")],
|
|
||||||
auth_scheme="Bearer", token="t", upstream_host="x.example",
|
|
||||||
)
|
|
||||||
ae = [v for n, v in headers if n.lower() == "accept-encoding"]
|
|
||||||
self.assertEqual(["identity"], ae)
|
|
||||||
|
|
||||||
def test_strips_content_length(self):
|
|
||||||
# http.client recomputes Content-Length; passing it through
|
|
||||||
# double-counts and breaks the upstream.
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("Content-Length", "999")],
|
|
||||||
auth_scheme="Bearer", token="t", upstream_host="x.example",
|
|
||||||
)
|
|
||||||
names = [n.lower() for n, _ in headers]
|
|
||||||
self.assertNotIn("content-length", names)
|
|
||||||
|
|
||||||
def test_sets_host_to_upstream(self):
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[("Host", "cred-proxy:9099")],
|
|
||||||
auth_scheme="Bearer", token="t", upstream_host="api.anthropic.com",
|
|
||||||
)
|
|
||||||
host_values = [v for n, v in headers if n.lower() == "host"]
|
|
||||||
self.assertEqual(["api.anthropic.com"], host_values)
|
|
||||||
|
|
||||||
def test_uses_token_scheme(self):
|
|
||||||
# gitea uses Authorization: token <pat>, not Bearer.
|
|
||||||
headers = build_forward_headers(
|
|
||||||
[],
|
|
||||||
auth_scheme="token", token="abc123", upstream_host="gitea.dideric.is",
|
|
||||||
)
|
|
||||||
auth_values = [v for n, v in headers if n.lower() == "authorization"]
|
|
||||||
self.assertEqual(["token abc123"], auth_values)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterResponseHeaders(unittest.TestCase):
|
|
||||||
def test_strips_hop_by_hop_only(self):
|
|
||||||
out = filter_response_headers([
|
|
||||||
("Content-Type", "text/event-stream"),
|
|
||||||
("Connection", "close"),
|
|
||||||
("Transfer-Encoding", "chunked"),
|
|
||||||
("Cache-Control", "no-cache"),
|
|
||||||
])
|
|
||||||
names = [n.lower() for n, _ in out]
|
|
||||||
self.assertIn("content-type", names)
|
|
||||||
self.assertIn("cache-control", names)
|
|
||||||
self.assertNotIn("connection", names)
|
|
||||||
self.assertNotIn("transfer-encoding", names)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsGitPushRequest(unittest.TestCase):
|
|
||||||
"""git push over HTTPS goes through /info/refs?service=git-receive-pack
|
|
||||||
(capabilities probe) then POST /git-receive-pack (the push body).
|
|
||||||
Fetches use /git-upload-pack and are not blocked — the bypass we're
|
|
||||||
closing is push, since git-gate's gitleaks pre-receive is the scanner
|
|
||||||
for outbound git data."""
|
|
||||||
|
|
||||||
def test_push_capabilities_probe_blocked(self):
|
|
||||||
self.assertTrue(is_git_push_request(
|
|
||||||
"/gh-git/owner/repo.git/info/refs",
|
|
||||||
"service=git-receive-pack",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_push_body_blocked(self):
|
|
||||||
self.assertTrue(is_git_push_request(
|
|
||||||
"/gh-git/owner/repo.git/git-receive-pack", "",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_fetch_capabilities_allowed(self):
|
|
||||||
self.assertFalse(is_git_push_request(
|
|
||||||
"/gh-git/owner/repo.git/info/refs",
|
|
||||||
"service=git-upload-pack",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_fetch_body_allowed(self):
|
|
||||||
self.assertFalse(is_git_push_request(
|
|
||||||
"/gh-git/owner/repo.git/git-upload-pack", "",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_rest_api_allowed(self):
|
|
||||||
# tea/gh-style REST calls hit /api/v1/... — unrelated.
|
|
||||||
self.assertFalse(is_git_push_request(
|
|
||||||
"/gitea/gitea.dideric.is/api/v1/repos/x/y", "",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_push_with_extra_query_params(self):
|
|
||||||
# `service` may appear with other params in any order.
|
|
||||||
self.assertTrue(is_git_push_request(
|
|
||||||
"/gh-git/owner/repo.git/info/refs",
|
|
||||||
"trace=1&service=git-receive-pack",
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadTokens(unittest.TestCase):
|
|
||||||
def test_reads_per_route_env(self):
|
|
||||||
routes = (
|
|
||||||
Route("/a/", "https", "x", 443, "", "Bearer", "T_0"),
|
|
||||||
Route("/b/", "https", "y", 443, "", "Bearer", "T_1"),
|
|
||||||
)
|
|
||||||
out = load_tokens(routes, {"T_0": "val0", "T_1": "val1"})
|
|
||||||
self.assertEqual({"T_0": "val0", "T_1": "val1"}, out)
|
|
||||||
|
|
||||||
def test_missing_env_yields_empty_string(self):
|
|
||||||
# The handler returns 500 at request time rather than the
|
|
||||||
# server refusing to start. This keeps the operator's failure
|
|
||||||
# signal in the cred-proxy's logs.
|
|
||||||
routes = (Route("/a/", "https", "x", 443, "", "Bearer", "T_0"),)
|
|
||||||
out = load_tokens(routes, {})
|
|
||||||
self.assertEqual({"T_0": ""}, out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReloadRoutes(unittest.TestCase):
|
|
||||||
"""SIGHUP reload helper (PRD 0014).
|
|
||||||
|
|
||||||
Drives the same code path the signal handler invokes, but
|
|
||||||
without actually sending a signal — keeps the test
|
|
||||||
deterministic. The signal binding is just `signal.signal(SIGHUP,
|
|
||||||
handler)`; install_sighup_handler is exercised by the
|
|
||||||
integration test."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="cp-reload-test.")
|
|
||||||
self.routes_path = Path(self._tmp.name) / "routes.json"
|
|
||||||
self.routes_path.write_text(json.dumps({"routes": [
|
|
||||||
{"path": "/a/", "upstream": "https://a.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T0"},
|
|
||||||
]}))
|
|
||||||
# Bind to :0 so the test doesn't need a fixed port.
|
|
||||||
self.server = CredProxyServer(("127.0.0.1", 0), _NullHandler)
|
|
||||||
self.server.routes = parse_routes(json.loads(self.routes_path.read_text()))
|
|
||||||
self.server.tokens = {"T0": "old"}
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.server.server_close()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_reload_swaps_routes_and_tokens(self):
|
|
||||||
self.routes_path.write_text(json.dumps({"routes": [
|
|
||||||
{"path": "/a/", "upstream": "https://a.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T0"},
|
|
||||||
{"path": "/b/", "upstream": "https://b.example",
|
|
||||||
"auth_scheme": "Bearer", "token_env": "T1"},
|
|
||||||
]}))
|
|
||||||
ok, msg = reload_routes(
|
|
||||||
self.server, str(self.routes_path),
|
|
||||||
environ={"T0": "new0", "T1": "new1"},
|
|
||||||
)
|
|
||||||
self.assertTrue(ok, msg)
|
|
||||||
self.assertEqual(2, len(self.server.routes))
|
|
||||||
self.assertEqual({"T0": "new0", "T1": "new1"}, self.server.tokens)
|
|
||||||
self.assertIn("reloaded 2 route(s)", msg)
|
|
||||||
|
|
||||||
def test_failed_reload_keeps_old_routes(self):
|
|
||||||
original_routes = self.server.routes
|
|
||||||
original_tokens = self.server.tokens
|
|
||||||
self.routes_path.write_text("not valid json {")
|
|
||||||
ok, msg = reload_routes(
|
|
||||||
self.server, str(self.routes_path),
|
|
||||||
environ={"T0": "ignored"},
|
|
||||||
)
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertIn("reload failed", msg)
|
|
||||||
self.assertIs(original_routes, self.server.routes)
|
|
||||||
self.assertIs(original_tokens, self.server.tokens)
|
|
||||||
|
|
||||||
def test_failed_reload_on_missing_file_keeps_old_routes(self):
|
|
||||||
original_routes = self.server.routes
|
|
||||||
self.routes_path.unlink()
|
|
||||||
ok, _ = reload_routes(
|
|
||||||
self.server, str(self.routes_path), environ={},
|
|
||||||
)
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertIs(original_routes, self.server.routes)
|
|
||||||
|
|
||||||
|
|
||||||
class _NullHandler: # noqa: D401 — test helper, not a real handler
|
|
||||||
"""Dummy handler class; the reload tests never actually serve a
|
|
||||||
request, so the handler is never instantiated."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
raise RuntimeError("should not be called in reload tests")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""Unit: DockerCredProxy helpers + early-exit guards (PRD 0010).
|
|
||||||
|
|
||||||
The full docker lifecycle is exercised by integration tests; here we
|
|
||||||
cover the pure helpers and the validation checks `.start` runs
|
|
||||||
before touching docker."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.backend.docker.cred_proxy import (
|
|
||||||
CRED_PROXY_HOSTNAME,
|
|
||||||
CRED_PROXY_PORT,
|
|
||||||
DockerCredProxy,
|
|
||||||
cred_proxy_container_name,
|
|
||||||
cred_proxy_url,
|
|
||||||
)
|
|
||||||
from claude_bottle.cred_proxy import CredProxyPlan, CredProxyRoute
|
|
||||||
from claude_bottle.log import Die
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_plan(**overrides):
|
|
||||||
base = {
|
|
||||||
"slug": "demo",
|
|
||||||
"routes_path": Path("/nonexistent"),
|
|
||||||
"routes": (),
|
|
||||||
"token_env_map": {},
|
|
||||||
"internal_network": "",
|
|
||||||
"egress_network": "",
|
|
||||||
"pipelock_ca_host_path": Path(),
|
|
||||||
"pipelock_proxy_url": "",
|
|
||||||
}
|
|
||||||
base.update(overrides)
|
|
||||||
return CredProxyPlan(**base)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNameAndUrl(unittest.TestCase):
|
|
||||||
def test_container_name_carries_slug(self):
|
|
||||||
self.assertEqual("claude-bottle-cred-proxy-demo",
|
|
||||||
cred_proxy_container_name("demo"))
|
|
||||||
|
|
||||||
def test_url_uses_alias_not_container_name(self):
|
|
||||||
# The URL agents dial is stable across bottles — the slug
|
|
||||||
# never appears in it. That's the whole point of attaching
|
|
||||||
# --network-alias cred-proxy on the internal network.
|
|
||||||
self.assertEqual(f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}",
|
|
||||||
cred_proxy_url())
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartGuards(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.proxy = DockerCredProxy()
|
|
||||||
|
|
||||||
def test_empty_upstreams_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
self.proxy.start(_empty_plan())
|
|
||||||
|
|
||||||
def test_missing_internal_network_dies(self):
|
|
||||||
upstream = CredProxyRoute(
|
|
||||||
path="/anthropic/",
|
|
||||||
upstream="https://api.anthropic.com",
|
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
|
||||||
token_ref="T",
|
|
||||||
)
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
self.proxy.start(_empty_plan(routes=(upstream,)))
|
|
||||||
|
|
||||||
def test_missing_routes_file_dies(self):
|
|
||||||
upstream = CredProxyRoute(
|
|
||||||
path="/anthropic/",
|
|
||||||
upstream="https://api.anthropic.com",
|
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
|
||||||
token_ref="T",
|
|
||||||
)
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
self.proxy.start(_empty_plan(
|
|
||||||
routes=(upstream,),
|
|
||||||
internal_network="net-x",
|
|
||||||
egress_network="egress-x",
|
|
||||||
routes_path=Path("/tmp/cred-proxy-test-does-not-exist.json"),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_pipelock_url_without_ca_dies(self):
|
|
||||||
# URL set + CA path empty/missing is a wiring bug: either both
|
|
||||||
# populated (production) or both empty (test escape hatch).
|
|
||||||
upstream = CredProxyRoute(
|
|
||||||
path="/anthropic/",
|
|
||||||
upstream="https://api.anthropic.com",
|
|
||||||
auth_scheme="Bearer", token_env="CRED_PROXY_TOKEN_0",
|
|
||||||
token_ref="T",
|
|
||||||
)
|
|
||||||
with tempfile.NamedTemporaryFile() as routes:
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
self.proxy.start(_empty_plan(
|
|
||||||
routes=(upstream,),
|
|
||||||
internal_network="net-x",
|
|
||||||
egress_network="egress-x",
|
|
||||||
routes_path=Path(routes.name),
|
|
||||||
pipelock_proxy_url="http://pipelock:8888",
|
|
||||||
pipelock_ca_host_path=Path("/tmp/cred-proxy-no-ca.pem"),
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -10,6 +10,7 @@ from claude_bottle.egress_proxy_addon_core import (
|
|||||||
Decision,
|
Decision,
|
||||||
Route,
|
Route,
|
||||||
decide,
|
decide,
|
||||||
|
is_git_push_request,
|
||||||
load_routes,
|
load_routes,
|
||||||
match_route,
|
match_route,
|
||||||
parse_routes,
|
parse_routes,
|
||||||
@@ -245,5 +246,52 @@ class TestDecisionDefaults(unittest.TestCase):
|
|||||||
self.assertIsNone(d.inject_authorization)
|
self.assertIsNone(d.inject_authorization)
|
||||||
|
|
||||||
|
|
||||||
|
# --- is_git_push_request ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsGitPushRequest(unittest.TestCase):
|
||||||
|
def test_post_git_receive_pack_endpoint(self):
|
||||||
|
# The POST that carries the actual push payload.
|
||||||
|
self.assertTrue(is_git_push_request("/owner/repo.git/git-receive-pack", ""))
|
||||||
|
|
||||||
|
def test_info_refs_with_receive_pack_service(self):
|
||||||
|
# The capability advertisement GET that precedes a push.
|
||||||
|
self.assertTrue(is_git_push_request(
|
||||||
|
"/owner/repo.git/info/refs",
|
||||||
|
"service=git-receive-pack",
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_info_refs_with_extra_query_params(self):
|
||||||
|
# service= may appear with other params in any order.
|
||||||
|
self.assertTrue(is_git_push_request(
|
||||||
|
"/owner/repo.git/info/refs",
|
||||||
|
"foo=bar&service=git-receive-pack&z=1",
|
||||||
|
))
|
||||||
|
self.assertTrue(is_git_push_request(
|
||||||
|
"/owner/repo.git/info/refs",
|
||||||
|
"service=git-receive-pack&foo=bar",
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_fetch_endpoints_not_blocked(self):
|
||||||
|
# `service=git-upload-pack` is fetch; never blocked.
|
||||||
|
self.assertFalse(is_git_push_request(
|
||||||
|
"/owner/repo.git/info/refs",
|
||||||
|
"service=git-upload-pack",
|
||||||
|
))
|
||||||
|
self.assertFalse(is_git_push_request(
|
||||||
|
"/owner/repo.git/git-upload-pack", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_info_refs_without_service_not_blocked(self):
|
||||||
|
# Bare info/refs (no query) defaults to git-upload-pack on
|
||||||
|
# the server side; not push.
|
||||||
|
self.assertFalse(is_git_push_request("/x/info/refs", ""))
|
||||||
|
|
||||||
|
def test_unrelated_paths_not_blocked(self):
|
||||||
|
self.assertFalse(is_git_push_request("/repos/owner/repo", ""))
|
||||||
|
self.assertFalse(is_git_push_request("/v1/messages", ""))
|
||||||
|
self.assertFalse(is_git_push_request("/", ""))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -22,19 +22,18 @@ def _write(p: Path, text: str) -> None:
|
|||||||
|
|
||||||
_BOTTLE_DEV = """
|
_BOTTLE_DEV = """
|
||||||
---
|
---
|
||||||
cred_proxy:
|
egress_proxy:
|
||||||
routes:
|
routes:
|
||||||
- path: /anthropic/
|
- host: api.anthropic.com
|
||||||
upstream: https://api.anthropic.com
|
auth:
|
||||||
auth_scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
token_ref: CLAUDE_CODE_OAUTH_TOKEN
|
||||||
role: anthropic-base-url
|
|
||||||
egress:
|
egress:
|
||||||
allowlist:
|
allowlist:
|
||||||
- example.com
|
- example.com
|
||||||
---
|
---
|
||||||
|
|
||||||
The dev bottle. Anthropic OAuth via cred-proxy.
|
The dev bottle. Anthropic OAuth via egress-proxy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_AGENT_IMPL = """
|
_AGENT_IMPL = """
|
||||||
@@ -88,10 +87,11 @@ class TestBottleFileParses(_ResolveCase):
|
|||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
m = self.resolve()
|
m = self.resolve()
|
||||||
self.assertIn("dev", m.bottles)
|
self.assertIn("dev", m.bottles)
|
||||||
routes = m.bottles["dev"].cred_proxy.routes
|
routes = m.bottles["dev"].egress_proxy.routes
|
||||||
self.assertEqual(1, len(routes))
|
self.assertEqual(1, len(routes))
|
||||||
self.assertEqual("/anthropic/", routes[0].Path)
|
self.assertEqual("api.anthropic.com", routes[0].Host)
|
||||||
self.assertEqual("https://api.anthropic.com", routes[0].Upstream)
|
self.assertEqual("Bearer", routes[0].AuthScheme)
|
||||||
|
self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", routes[0].TokenRef)
|
||||||
self.assertEqual(["example.com"], list(m.bottles["dev"].egress.allowlist))
|
self.assertEqual(["example.com"], list(m.bottles["dev"].egress.allowlist))
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ class TestCwdAgentOverridesHome(_ResolveCase):
|
|||||||
m = self.resolve()
|
m = self.resolve()
|
||||||
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
|
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
|
||||||
# Home bottle still present
|
# Home bottle still present
|
||||||
self.assertEqual(1, len(m.bottles["dev"].cred_proxy.routes))
|
self.assertEqual(1, len(m.bottles["dev"].egress_proxy.routes))
|
||||||
|
|
||||||
|
|
||||||
class TestCwdBottlesIgnored(_ResolveCase):
|
class TestCwdBottlesIgnored(_ResolveCase):
|
||||||
@@ -149,21 +149,20 @@ class TestCwdBottlesIgnored(_ResolveCase):
|
|||||||
self.cwd_cb / "bottles" / "dev.md",
|
self.cwd_cb / "bottles" / "dev.md",
|
||||||
"""
|
"""
|
||||||
---
|
---
|
||||||
cred_proxy:
|
egress_proxy:
|
||||||
routes:
|
routes:
|
||||||
- path: /anthropic/
|
- host: attacker.example.com
|
||||||
upstream: https://attacker.example.com
|
auth:
|
||||||
auth_scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
token_ref: CLAUDE_CODE_OAUTH_TOKEN
|
||||||
role: anthropic-base-url
|
|
||||||
---
|
---
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
m = self.resolve()
|
m = self.resolve()
|
||||||
# Home value wins because cwd bottles are ignored entirely.
|
# Home value wins because cwd bottles are ignored entirely.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"https://api.anthropic.com",
|
"api.anthropic.com",
|
||||||
m.bottles["dev"].cred_proxy.routes[0].Upstream,
|
m.bottles["dev"].egress_proxy.routes[0].Host,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Unit: bottle.cred_proxy.routes manifest parsing + validation (PRD 0010)."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from claude_bottle.log import Die
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _manifest(routes, git=None):
|
|
||||||
bottle: dict[str, object] = {"cred_proxy": {"routes": routes}}
|
|
||||||
if git is not None:
|
|
||||||
bottle["git"] = git
|
|
||||||
return {
|
|
||||||
"bottles": {"dev": bottle},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestCredProxyRouteParsing(unittest.TestCase):
|
|
||||||
def test_parses_minimal_route(self):
|
|
||||||
m = Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/anthropic/",
|
|
||||||
"upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer",
|
|
||||||
"token_ref": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
|
|
||||||
]))
|
|
||||||
routes = m.bottles["dev"].cred_proxy.routes
|
|
||||||
self.assertEqual(1, len(routes))
|
|
||||||
r = routes[0]
|
|
||||||
self.assertEqual("/anthropic/", r.Path)
|
|
||||||
self.assertEqual("https://api.anthropic.com", r.Upstream)
|
|
||||||
self.assertEqual("Bearer", r.AuthScheme)
|
|
||||||
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", r.TokenRef)
|
|
||||||
self.assertEqual((), r.Role)
|
|
||||||
self.assertEqual("api.anthropic.com", r.UpstreamHost)
|
|
||||||
|
|
||||||
def test_role_string_normalizes_to_tuple(self):
|
|
||||||
m = Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
]))
|
|
||||||
self.assertEqual(("anthropic-base-url",),
|
|
||||||
m.bottles["dev"].cred_proxy.routes[0].Role)
|
|
||||||
|
|
||||||
def test_role_list_supported(self):
|
|
||||||
m = Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "T",
|
|
||||||
"role": ["git-insteadof", "tea-login"]},
|
|
||||||
]))
|
|
||||||
self.assertEqual(("git-insteadof", "tea-login"),
|
|
||||||
m.bottles["dev"].cred_proxy.routes[0].Role)
|
|
||||||
|
|
||||||
def test_upstream_host_extracted(self):
|
|
||||||
m = Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.dideric.is:30443",
|
|
||||||
"auth_scheme": "token", "token_ref": "T"},
|
|
||||||
]))
|
|
||||||
self.assertEqual("gitea.dideric.is",
|
|
||||||
m.bottles["dev"].cred_proxy.routes[0].UpstreamHost)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCredProxyRouteValidation(unittest.TestCase):
|
|
||||||
def _route(self, **overrides):
|
|
||||||
base = {
|
|
||||||
"path": "/x/",
|
|
||||||
"upstream": "https://example.com",
|
|
||||||
"auth_scheme": "Bearer",
|
|
||||||
"token_ref": "TOK",
|
|
||||||
}
|
|
||||||
base.update(overrides)
|
|
||||||
return base
|
|
||||||
|
|
||||||
def test_missing_path_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(path=None)]))
|
|
||||||
|
|
||||||
def test_path_without_trailing_slash_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(path="/no-slash")]))
|
|
||||||
|
|
||||||
def test_path_without_leading_slash_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(path="no-slash/")]))
|
|
||||||
|
|
||||||
def test_missing_upstream_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(upstream=None)]))
|
|
||||||
|
|
||||||
def test_non_https_upstream_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(upstream="http://x.example")]))
|
|
||||||
|
|
||||||
def test_unknown_auth_scheme_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(auth_scheme="Basic")]))
|
|
||||||
|
|
||||||
def test_missing_token_ref_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(token_ref=None)]))
|
|
||||||
|
|
||||||
def test_unknown_role_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([self._route(role="something-made-up")]))
|
|
||||||
|
|
||||||
|
|
||||||
class TestCredProxyCrossValidation(unittest.TestCase):
|
|
||||||
def test_duplicate_path_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/x/", "upstream": "https://a.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T1"},
|
|
||||||
{"path": "/x/", "upstream": "https://b.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T2"},
|
|
||||||
]))
|
|
||||||
|
|
||||||
def test_two_routes_same_anthropic_role_dies(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "A1",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
{"path": "/anthropic-2/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "A2",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
]))
|
|
||||||
|
|
||||||
def test_multiple_git_insteadof_ok(self):
|
|
||||||
# git-insteadof is not a singleton role — each route can
|
|
||||||
# independently rewrite its own host.
|
|
||||||
m = Manifest.from_json_obj(_manifest([
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "GT",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]))
|
|
||||||
self.assertEqual(2, len(m.bottles["dev"].cred_proxy.routes))
|
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyTokensField(unittest.TestCase):
|
|
||||||
def test_legacy_tokens_field_dies_with_hint(self):
|
|
||||||
# The PRD-iteration shape ({"tokens": [{Kind: ...}]}) was
|
|
||||||
# replaced by cred_proxy.routes; old manifests must fail
|
|
||||||
# loudly with a pointer.
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {"tokens": [
|
|
||||||
{"Kind": "anthropic", "TokenRef": "T"},
|
|
||||||
]}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyCredProxy(unittest.TestCase):
|
|
||||||
def test_no_cred_proxy_field_yields_empty_routes(self):
|
|
||||||
m = Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
self.assertEqual((), m.bottles["dev"].cred_proxy.routes)
|
|
||||||
|
|
||||||
def test_routes_array_type_required(self):
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {"cred_proxy": {"routes": "not-a-list"}}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults,
|
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults,
|
||||||
bottle.egress.allowlist, and cred-proxy upstream hosts derived from
|
bottle.egress.allowlist, and egress-proxy route hosts derived from
|
||||||
bottle.cred_proxy.routes (PRD 0010). Git upstreams declared in bottle.git
|
bottle.egress_proxy.routes (PRD 0017). Git upstreams declared in
|
||||||
do not contribute here; they flow through the per-agent git-gate (PRD 0008)."""
|
bottle.git do not contribute here; they flow through the per-agent
|
||||||
|
git-gate (PRD 0008)."""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ from claude_bottle.manifest import Manifest
|
|||||||
from claude_bottle.pipelock import (
|
from claude_bottle.pipelock import (
|
||||||
pipelock_effective_allowlist,
|
pipelock_effective_allowlist,
|
||||||
pipelock_effective_tls_passthrough,
|
pipelock_effective_tls_passthrough,
|
||||||
pipelock_token_hosts,
|
pipelock_route_hosts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ def _bottle(spec):
|
|||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
|
def _routes(routes):
|
||||||
|
return {"egress_proxy": {"routes": routes}}
|
||||||
|
|
||||||
|
|
||||||
class TestEffectiveAllowlist(unittest.TestCase):
|
class TestEffectiveAllowlist(unittest.TestCase):
|
||||||
def test_union_and_dedup(self):
|
def test_union_and_dedup(self):
|
||||||
eff = pipelock_effective_allowlist(_bottle({
|
eff = pipelock_effective_allowlist(_bottle({
|
||||||
@@ -37,66 +42,52 @@ class TestEffectiveAllowlist(unittest.TestCase):
|
|||||||
self.assertEqual(eff, sorted(eff), "sorted")
|
self.assertEqual(eff, sorted(eff), "sorted")
|
||||||
|
|
||||||
|
|
||||||
def _routes(routes):
|
class TestRouteHosts(unittest.TestCase):
|
||||||
return {"cred_proxy": {"routes": routes}}
|
def test_each_route_contributes_its_host(self):
|
||||||
|
hosts = pipelock_route_hosts(_bottle(_routes([
|
||||||
|
{"host": "api.github.com",
|
||||||
class TestTokenHosts(unittest.TestCase):
|
"auth": {"scheme": "Bearer", "token_ref": "GH"}},
|
||||||
def test_each_route_contributes_its_upstream_host(self):
|
{"host": "github.com",
|
||||||
hosts = pipelock_token_hosts(_bottle(_routes([
|
"auth": {"scheme": "Bearer", "token_ref": "GH"}},
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH"},
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH"},
|
|
||||||
])))
|
])))
|
||||||
self.assertEqual(["api.github.com", "github.com"], hosts)
|
self.assertEqual(["api.github.com", "github.com"], hosts)
|
||||||
|
|
||||||
def test_dedupe_across_routes(self):
|
|
||||||
hosts = pipelock_token_hosts(_bottle(_routes([
|
|
||||||
{"path": "/a/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T1"},
|
|
||||||
{"path": "/b/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T2"},
|
|
||||||
])))
|
|
||||||
self.assertEqual(["x.example"], hosts)
|
|
||||||
|
|
||||||
def test_no_routes_empty(self):
|
def test_no_routes_empty(self):
|
||||||
self.assertEqual([], pipelock_token_hosts(_bottle({})))
|
self.assertEqual([], pipelock_route_hosts(_bottle({})))
|
||||||
|
|
||||||
|
|
||||||
class TestAllowlistWithTokens(unittest.TestCase):
|
class TestAllowlistWithRoutes(unittest.TestCase):
|
||||||
def test_route_hosts_added_to_allowlist(self):
|
def test_route_hosts_added_to_allowlist(self):
|
||||||
eff = pipelock_effective_allowlist(_bottle(_routes([
|
eff = pipelock_effective_allowlist(_bottle(_routes([
|
||||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
{"host": "registry.npmjs.org",
|
||||||
"auth_scheme": "Bearer", "token_ref": "N"},
|
"auth": {"scheme": "Bearer", "token_ref": "N"}},
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
{"host": "api.github.com",
|
||||||
"auth_scheme": "Bearer", "token_ref": "G"},
|
"auth": {"scheme": "Bearer", "token_ref": "G"}},
|
||||||
])))
|
])))
|
||||||
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):
|
def test_egress_proxy_hostname_auto_added_when_routes_exist(self):
|
||||||
# The agent's HTTP_PROXY points at pipelock, so a request for
|
# Egress-proxy's outbound leg uses HTTPS_PROXY=pipelock, so
|
||||||
# http://cred-proxy:9099/... arrives at pipelock as a request
|
# any request that flows through egress-proxy → pipelock
|
||||||
# for hostname `cred-proxy`. pipelock must allow it or the
|
# would otherwise be rejected by pipelock's hostname gate.
|
||||||
# agent can't reach its own sidecar.
|
|
||||||
eff = pipelock_effective_allowlist(_bottle(_routes([
|
eff = pipelock_effective_allowlist(_bottle(_routes([
|
||||||
{"path": "/x/", "upstream": "https://x.example",
|
{"host": "x.example",
|
||||||
"auth_scheme": "Bearer", "token_ref": "T"},
|
"auth": {"scheme": "Bearer", "token_ref": "T"}},
|
||||||
])))
|
])))
|
||||||
self.assertIn("cred-proxy", eff)
|
self.assertIn("egress-proxy", eff)
|
||||||
|
|
||||||
def test_cred_proxy_hostname_NOT_added_when_no_routes(self):
|
def test_egress_proxy_hostname_NOT_added_when_no_routes(self):
|
||||||
# No cred-proxy sidecar, no auto-allow.
|
|
||||||
eff = pipelock_effective_allowlist(_bottle({}))
|
eff = pipelock_effective_allowlist(_bottle({}))
|
||||||
self.assertNotIn("cred-proxy", eff)
|
self.assertNotIn("egress-proxy", eff)
|
||||||
|
|
||||||
def test_supervise_hostname_auto_added_when_supervise_enabled(self):
|
def test_supervise_hostname_auto_added_when_supervise_enabled(self):
|
||||||
# Same reasoning as cred-proxy: the agent's HTTP_PROXY points
|
# The agent's MCP client opens long-polled requests to
|
||||||
# at pipelock, so http://supervise:9100/ (the MCP endpoint)
|
# http://supervise:9100/. They bypass the agent's HTTP_PROXY
|
||||||
# arrives at pipelock as hostname `supervise`. Without this
|
# (via NO_PROXY=supervise) and shouldn't traverse pipelock;
|
||||||
# auto-allow, claude-code's MCP client gets a 403 and the
|
# but for the launch path where supervise traffic does flow
|
||||||
# supervise server shows up as "failed" in /mcp.
|
# through pipelock (egress-proxy → ... → supervise edge
|
||||||
|
# cases), the hostname needs to be on the allowlist anyway.
|
||||||
eff = pipelock_effective_allowlist(_bottle({"supervise": True}))
|
eff = pipelock_effective_allowlist(_bottle({"supervise": True}))
|
||||||
self.assertIn("supervise", eff)
|
self.assertIn("supervise", eff)
|
||||||
|
|
||||||
@@ -106,6 +97,18 @@ class TestAllowlistWithTokens(unittest.TestCase):
|
|||||||
eff_explicit = pipelock_effective_allowlist(_bottle({"supervise": False}))
|
eff_explicit = pipelock_effective_allowlist(_bottle({"supervise": False}))
|
||||||
self.assertNotIn("supervise", eff_explicit)
|
self.assertNotIn("supervise", eff_explicit)
|
||||||
|
|
||||||
|
def test_path_allowlist_does_not_affect_pipelock_allowlist(self):
|
||||||
|
# path_allowlist is enforced by egress-proxy, not pipelock.
|
||||||
|
# Pipelock only sees the upstream hostname; the path filter
|
||||||
|
# has already passed (or 403'd) at egress-proxy.
|
||||||
|
eff = pipelock_effective_allowlist(_bottle(_routes([
|
||||||
|
{"host": "github.com", "path_allowlist": ["/x/", "/y/"]},
|
||||||
|
])))
|
||||||
|
self.assertIn("github.com", eff)
|
||||||
|
# The path strings don't leak into the allowlist.
|
||||||
|
for entry in eff:
|
||||||
|
self.assertFalse(entry.startswith("/"))
|
||||||
|
|
||||||
|
|
||||||
class TestTlsPassthrough(unittest.TestCase):
|
class TestTlsPassthrough(unittest.TestCase):
|
||||||
def test_default_includes_api_anthropic(self):
|
def test_default_includes_api_anthropic(self):
|
||||||
@@ -113,15 +116,15 @@ class TestTlsPassthrough(unittest.TestCase):
|
|||||||
self.assertEqual(["api.anthropic.com"], passthrough)
|
self.assertEqual(["api.anthropic.com"], passthrough)
|
||||||
|
|
||||||
def test_route_hosts_NOT_added_to_passthrough(self):
|
def test_route_hosts_NOT_added_to_passthrough(self):
|
||||||
# cred-proxy now trusts pipelock's per-bottle CA, so pipelock
|
# egress-proxy trusts pipelock's per-bottle CA, so pipelock
|
||||||
# can MITM the cred-proxy -> upstream leg and body-scan it.
|
# MITMs and body-scans the egress-proxy → upstream leg the
|
||||||
# Auto-adding cred-proxy hosts to passthrough would silently
|
# same way it scanned direct agent traffic before. Auto-adding
|
||||||
# disable that second scanner.
|
# route hosts to passthrough would silently disable that.
|
||||||
passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([
|
passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([
|
||||||
{"path": "/gh-api/", "upstream": "https://api.github.com",
|
{"host": "api.github.com",
|
||||||
"auth_scheme": "Bearer", "token_ref": "G"},
|
"auth": {"scheme": "Bearer", "token_ref": "G"}},
|
||||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
{"host": "registry.npmjs.org",
|
||||||
"auth_scheme": "Bearer", "token_ref": "N"},
|
"auth": {"scheme": "Bearer", "token_ref": "N"}},
|
||||||
])))
|
])))
|
||||||
self.assertEqual(["api.anthropic.com"], passthrough)
|
self.assertEqual(["api.anthropic.com"], passthrough)
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
|
|
||||||
def test_ssrf_block_emitted_when_allowlist_supplied(self):
|
def test_ssrf_block_emitted_when_allowlist_supplied(self):
|
||||||
# The bottle's internal Docker subnet lands here at launch
|
# The bottle's internal Docker subnet lands here at launch
|
||||||
# time so cred-proxy:9099 (172.x.x.x) doesn't trip pipelock's
|
# time so sibling-sidecar traffic (172.x.x.x) doesn't trip
|
||||||
# RFC1918 SSRF guard.
|
# pipelock's RFC1918 SSRF guard.
|
||||||
cfg = pipelock_build_config(
|
cfg = pipelock_build_config(
|
||||||
fixture_minimal().bottles["dev"],
|
fixture_minimal().bottles["dev"],
|
||||||
ssrf_ip_allowlist=("172.20.0.0/16",),
|
ssrf_ip_allowlist=("172.20.0.0/16",),
|
||||||
@@ -109,11 +109,9 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
# up to route claude through pipelock.
|
# up to route claude through pipelock.
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
bottle = Manifest.from_json_obj({
|
bottle = Manifest.from_json_obj({
|
||||||
"bottles": {"dev": {"cred_proxy": {"routes": [
|
"bottles": {"dev": {"egress_proxy": {"routes": [
|
||||||
{"path": "/anthropic/",
|
{"host": "api.anthropic.com",
|
||||||
"upstream": "https://api.anthropic.com",
|
"auth": {"scheme": "Bearer", "token_ref": "T"}},
|
||||||
"auth_scheme": "Bearer", "token_ref": "T",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
]}}},
|
]}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
@@ -206,11 +204,9 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
def test_render_emits_seed_phrase_off_for_anthropic_route(self):
|
def test_render_emits_seed_phrase_off_for_anthropic_route(self):
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
bottle = Manifest.from_json_obj({
|
bottle = Manifest.from_json_obj({
|
||||||
"bottles": {"dev": {"cred_proxy": {"routes": [
|
"bottles": {"dev": {"egress_proxy": {"routes": [
|
||||||
{"path": "/anthropic/",
|
{"host": "api.anthropic.com",
|
||||||
"upstream": "https://api.anthropic.com",
|
"auth": {"scheme": "Bearer", "token_ref": "T"}},
|
||||||
"auth_scheme": "Bearer", "token_ref": "T",
|
|
||||||
"role": "anthropic-base-url"},
|
|
||||||
]}}},
|
]}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""Unit: cred-proxy agent-side provisioner renderers (PRD 0010).
|
|
||||||
|
|
||||||
The docker cp / docker exec side effects are exercised by integration
|
|
||||||
tests; these unit tests cover the pure render functions."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from claude_bottle.backend.docker.provision.cred_proxy import (
|
|
||||||
render_cred_proxy_gitconfig,
|
|
||||||
render_npmrc,
|
|
||||||
render_tea_config,
|
|
||||||
)
|
|
||||||
from claude_bottle.cred_proxy import cred_proxy_routes_for_bottle
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _bottle(routes):
|
|
||||||
return Manifest.from_json_obj({
|
|
||||||
"bottles": {"dev": {"cred_proxy": {"routes": routes}}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}).bottles["dev"]
|
|
||||||
|
|
||||||
|
|
||||||
def _upstreams(routes):
|
|
||||||
return cred_proxy_routes_for_bottle(_bottle(routes))
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderNpmrc(unittest.TestCase):
|
|
||||||
def test_empty_when_no_role(self):
|
|
||||||
self.assertEqual("", render_npmrc(_upstreams([])))
|
|
||||||
self.assertEqual("", render_npmrc(_upstreams([
|
|
||||||
{"path": "/x/", "upstream": "https://x.example",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "T"},
|
|
||||||
])))
|
|
||||||
|
|
||||||
def test_writes_registry_line_for_npm_registry_role(self):
|
|
||||||
out = render_npmrc(_upstreams([
|
|
||||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
|
|
||||||
"role": "npm-registry"},
|
|
||||||
]))
|
|
||||||
self.assertEqual("registry=http://cred-proxy:9099/npm/\n", out)
|
|
||||||
|
|
||||||
def test_omits_authtoken(self):
|
|
||||||
# The proxy injects Authorization at request time.
|
|
||||||
out = render_npmrc(_upstreams([
|
|
||||||
{"path": "/npm/", "upstream": "https://registry.npmjs.org",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "NPM_TOKEN",
|
|
||||||
"role": "npm-registry"},
|
|
||||||
]))
|
|
||||||
self.assertNotIn("_authToken", out)
|
|
||||||
self.assertNotIn("NPM_TOKEN", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderGitconfig(unittest.TestCase):
|
|
||||||
def test_empty_when_no_role(self):
|
|
||||||
self.assertEqual("", render_cred_proxy_gitconfig(_upstreams([
|
|
||||||
{"path": "/anthropic/", "upstream": "https://api.anthropic.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "A"},
|
|
||||||
])))
|
|
||||||
|
|
||||||
def test_writes_insteadof_for_git_insteadof_role(self):
|
|
||||||
out = render_cred_proxy_gitconfig(_upstreams([
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]))
|
|
||||||
self.assertIn('[url "http://cred-proxy:9099/gh-git/"]', out)
|
|
||||||
self.assertIn("insteadOf = https://github.com/", out)
|
|
||||||
|
|
||||||
def test_gitea_writes_per_host_insteadof(self):
|
|
||||||
out = render_cred_proxy_gitconfig(_upstreams([
|
|
||||||
{"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
|
|
||||||
"auth_scheme": "token", "token_ref": "GITEA",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]))
|
|
||||||
self.assertIn('[url "http://cred-proxy:9099/gitea/dideric/"]', out)
|
|
||||||
self.assertIn("insteadOf = https://gitea.dideric.is/", out)
|
|
||||||
|
|
||||||
def test_two_routes_yield_two_rules(self):
|
|
||||||
out = render_cred_proxy_gitconfig(_upstreams([
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "GT",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]))
|
|
||||||
self.assertEqual(2, out.count("insteadOf"))
|
|
||||||
self.assertIn("github.com", out)
|
|
||||||
self.assertIn("gitea.example.com", out)
|
|
||||||
|
|
||||||
def test_suppressed_when_git_gate_covers_host(self):
|
|
||||||
# When bottle.git brokers github.com over SSH, git-gate is the
|
|
||||||
# canonical git path. The cred-proxy https://github.com/
|
|
||||||
# rewrite would let the agent push over HTTPS — bypassing
|
|
||||||
# gitleaks. Suppress it.
|
|
||||||
out = render_cred_proxy_gitconfig(
|
|
||||||
_upstreams([
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "GH",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]),
|
|
||||||
{"github.com"},
|
|
||||||
)
|
|
||||||
self.assertEqual("", out)
|
|
||||||
|
|
||||||
def test_partial_suppression_keeps_other_hosts(self):
|
|
||||||
out = render_cred_proxy_gitconfig(
|
|
||||||
_upstreams([
|
|
||||||
{"path": "/gitea/a/", "upstream": "https://gitea.dideric.is",
|
|
||||||
"auth_scheme": "token", "token_ref": "T1",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
{"path": "/gitea/b/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "T2",
|
|
||||||
"role": "git-insteadof"},
|
|
||||||
]),
|
|
||||||
{"gitea.dideric.is"},
|
|
||||||
)
|
|
||||||
self.assertNotIn("gitea.dideric.is/", out)
|
|
||||||
self.assertIn("gitea.example.com/", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderTeaConfig(unittest.TestCase):
|
|
||||||
def test_empty_when_no_role(self):
|
|
||||||
self.assertEqual("", render_tea_config(_upstreams([
|
|
||||||
{"path": "/gh-git/", "upstream": "https://github.com",
|
|
||||||
"auth_scheme": "Bearer", "token_ref": "G"},
|
|
||||||
])))
|
|
||||||
|
|
||||||
def test_single_login_block(self):
|
|
||||||
out = render_tea_config(_upstreams([
|
|
||||||
{"path": "/gitea/dideric/", "upstream": "https://gitea.dideric.is",
|
|
||||||
"auth_scheme": "token", "token_ref": "GITEA",
|
|
||||||
"role": "tea-login"},
|
|
||||||
]))
|
|
||||||
self.assertIn("logins:", out)
|
|
||||||
# Login name comes from the upstream host, not the path —
|
|
||||||
# the path may not encode the host.
|
|
||||||
self.assertIn("- name: gitea.dideric.is", out)
|
|
||||||
self.assertIn("url: http://cred-proxy:9099/gitea/dideric/", out)
|
|
||||||
self.assertIn("token: cred-proxy-placeholder", out)
|
|
||||||
self.assertNotIn("GITEA", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCombinedRoles(unittest.TestCase):
|
|
||||||
"""A single gitea route typically carries both `git-insteadof`
|
|
||||||
and `tea-login` — the renderers should each fire independently."""
|
|
||||||
|
|
||||||
def test_gitea_route_fires_both_renderers(self):
|
|
||||||
routes = _upstreams([
|
|
||||||
{"path": "/gitea/x/", "upstream": "https://gitea.example.com",
|
|
||||||
"auth_scheme": "token", "token_ref": "T",
|
|
||||||
"role": ["git-insteadof", "tea-login"]},
|
|
||||||
])
|
|
||||||
self.assertIn("insteadOf", render_cred_proxy_gitconfig(routes))
|
|
||||||
self.assertIn("logins:", render_tea_config(routes))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -252,18 +252,18 @@ class TestRealisticBottleFile(unittest.TestCase):
|
|||||||
|
|
||||||
def test_dev_bottle(self):
|
def test_dev_bottle(self):
|
||||||
out = _y("""
|
out = _y("""
|
||||||
cred_proxy:
|
egress_proxy:
|
||||||
routes:
|
routes:
|
||||||
- path: /anthropic/
|
- host: api.anthropic.com
|
||||||
upstream: https://api.anthropic.com
|
auth:
|
||||||
auth_scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
|
token_ref: CLAUDE_CODE_OAUTH_TOKEN
|
||||||
role: anthropic-base-url
|
- host: gitea.dideric.is
|
||||||
- path: /gitea/dideric/
|
auth:
|
||||||
upstream: https://gitea.dideric.is
|
scheme: token
|
||||||
auth_scheme: token
|
token_ref: GITEA_TOKEN
|
||||||
token_ref: GITEA_TOKEN
|
path_allowlist:
|
||||||
role: [git-insteadof, tea-login]
|
- /didericis/
|
||||||
git:
|
git:
|
||||||
- Name: claude-bottle
|
- Name: claude-bottle
|
||||||
Upstream: ssh://git@gitea.dideric.is:30009/x/y.git
|
Upstream: ssh://git@gitea.dideric.is:30009/x/y.git
|
||||||
@@ -275,10 +275,14 @@ class TestRealisticBottleFile(unittest.TestCase):
|
|||||||
- example.com
|
- example.com
|
||||||
""")
|
""")
|
||||||
# Spot-check the deep parts; the structure is large.
|
# Spot-check the deep parts; the structure is large.
|
||||||
self.assertEqual(2, len(out["cred_proxy"]["routes"]))
|
self.assertEqual(2, len(out["egress_proxy"]["routes"]))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["git-insteadof", "tea-login"],
|
["/didericis/"],
|
||||||
out["cred_proxy"]["routes"][1]["role"],
|
out["egress_proxy"]["routes"][1]["path_allowlist"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"Bearer",
|
||||||
|
out["egress_proxy"]["routes"][0]["auth"]["scheme"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"100.78.141.42",
|
"100.78.141.42",
|
||||||
|
|||||||
Reference in New Issue
Block a user