refactor(cred_proxy): flat routes, role-driven provisioning (PRD 0010)
test / unit (pull_request) Successful in 14s
test / integration (pull_request) Successful in 22s

Replace bottle.tokens (with Kind enum and hardcoded per-kind
route/auth tables) with bottle.cred_proxy.routes — each route
declares its own path, upstream, auth_scheme, token_ref, and
optional role[]. The manifest is now the source of truth for the
proxy's runtime route table; adding an upstream is a manifest edit,
not a code change.

Agent-side rewrites move from per-kind dispatch to per-role tags
on routes:
  anthropic-base-url -> set ANTHROPIC_BASE_URL=<proxy><path>
  npm-registry       -> write ~/.npmrc registry=
  git-insteadof      -> write ~/.gitconfig [url] insteadOf, keyed
                        off route.upstream (suppressed when
                        bottle.git brokers the same host)
  tea-login          -> add a ~/.config/tea/config.yml login

Roles are a list (string accepted as sugar). A gitea route
typically carries ["git-insteadof", "tea-login"]. Singleton roles
(anthropic-base-url, npm-registry) appear on at most one route.

token_env slots are assigned per distinct TokenRef in declaration
order — two routes sharing a token_ref (e.g. github API + git
endpoints) share a slot.

Drops: TOKEN_KINDS, _KIND_ROUTES, _KIND_AUTH_SCHEME, _TOKEN_DEFAULT_HOST,
cred_proxy_route_path_for_gitea, the kind field on CredProxyUpstream,
and the kind-based hardcoding in pipelock_token_hosts (now derives
from route.UpstreamHost).

Legacy bottle.tokens manifests now die with a hint pointing at
bottle.cred_proxy.routes + this PRD. Tests rewritten end-to-end.
Docs + example.json + the dev ~/claude-bottle.json updated to match.
This commit is contained in:
2026-05-13 21:49:55 -04:00
parent 27b2d78b11
commit fcbbc4484d
15 changed files with 798 additions and 695 deletions
+5 -10
View File
@@ -107,16 +107,11 @@ class DockerBottlePlan(BottlePlan):
else:
info(" git remotes : (none)")
if self.cred_proxy_plan.upstreams:
kinds: list[str] = []
seen: set[str] = set()
for u in self.cred_proxy_plan.upstreams:
key = u.kind if u.kind != "gitea" else f"gitea ({u.upstream})"
if key in seen:
continue
seen.add(key)
kinds.append(key)
routes = [f"{u.path}{u.upstream}" for u in self.cred_proxy_plan.upstreams]
refs = sorted({u.token_ref for u in self.cred_proxy_plan.upstreams})
info(f" cred-proxy : {', '.join(kinds)}; tokens: {', '.join(refs)}")
info(f" cred-proxy : {len(routes)} route(s); tokens: {', '.join(refs)}")
for line in routes:
info(f" {line}")
else:
info(" cred-proxy : (none)")
info(f" egress : {self.allowlist_summary}")
@@ -153,11 +148,11 @@ class DockerBottlePlan(BottlePlan):
],
"cred_proxy": [
{
"kind": u.kind,
"path": u.path,
"upstream": u.upstream,
"auth_scheme": u.auth_scheme,
"token_ref": u.token_ref,
"roles": list(u.roles),
}
for u in self.cred_proxy_plan.upstreams
],
+19 -8
View File
@@ -89,21 +89,32 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
has_anthropic_token = any(t.Kind == "anthropic" for t in bottle.tokens)
if spec.forward_oauth_token and not has_anthropic_token:
# Find the (at most one) cred-proxy route claiming the
# anthropic-base-url role. Manifest validation enforces the
# singleton constraint.
anthropic_route = next(
(u for u in cred_proxy_plan.upstreams if "anthropic-base-url" in u.roles),
None,
)
if spec.forward_oauth_token and anthropic_route is None:
# Pre-PRD 0010 behavior: agent reads CLAUDE_CODE_OAUTH_TOKEN
# directly. Still the path when bottle.tokens has no anthropic
# entry; the cred-proxy sidecar holds the token otherwise.
# directly. Still the path when no cred_proxy.routes entry
# is tagged anthropic-base-url; otherwise the sidecar holds
# the token.
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
if has_anthropic_token:
if anthropic_route is not None:
# Point claude-code at the cred-proxy. The sidecar holds the
# OAuth token; the agent's environ does not.
forwarded_env["ANTHROPIC_BASE_URL"] = f"{cred_proxy_url()}/anthropic"
# OAuth token; the agent's environ does not. Strip the
# trailing slash so claude-code's path-join produces e.g.
# 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 `bottle.tokens[].TokenRef`
# (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"
@@ -46,14 +46,17 @@ def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
def render_npmrc(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.npmrc` content. No-op (empty string) when no npm
route is declared, so callers can branch on emptiness.
"""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."""
is enough. Manifest validation enforces that the role is a
singleton, so the first match is the only match."""
for u in upstreams:
if u.kind == "npm":
if "npm-registry" in u.roles:
return f"registry={cred_proxy_url()}{u.path}\n"
return ""
@@ -89,40 +92,37 @@ def render_cred_proxy_gitconfig(
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
) -> str:
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
rewrites. Empty string when no github / gitea routes are declared.
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 host that's 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 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.
github expands to one rewrite (https://github.com/... → /gh-git/...,
the git transport endpoint); /gh-api/ stays unmapped here because
tools call api.github.com directly rather than through git.
Gitea entries get one rewrite per declared host."""
The insteadOf left-hand side comes from `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 u in upstreams:
if u.kind == "github" and u.path == "/gh-git/":
if "github.com" in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}/gh-git/"]\n'
f"\tinsteadOf = https://github.com/\n"
)
elif u.kind == "gitea":
# u.path is /gitea/<host>/; derive the host the same way
# the route table did so we match git_gate's UpstreamHost.
host = u.path[len("/gitea/"):].rstrip("/")
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if "git-insteadof" not in u.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 = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
if host in git_gate_hosts:
continue
rules.append(
f'[url "{cred_proxy_url()}{u.path}"]\n'
f"\tinsteadOf = {u.upstream}/\n"
)
if not rules:
return ""
return (
@@ -180,19 +180,21 @@ def _provision_gitconfig(
def render_tea_config(upstreams: tuple[CredProxyUpstream, ...]) -> str:
"""Render `~/.config/tea/config.yml`. One `logins:` entry per
gitea route, pointing at the cred-proxy. The proxy substitutes
the real token; the value in `token:` here is a placeholder and
is replaced by the proxy on every request, but `tea` won't make
calls without a non-empty token field."""
giteas = [u for u in upstreams if u.kind == "gitea"]
if not giteas:
"""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 = [u for u in upstreams if "tea-login" in u.roles]
if not tea_routes:
return ""
lines = ["logins:"]
for u in giteas:
# Derive a stable login name from the host (the part of the
# path between /gitea/ and the trailing /).
host = u.path[len("/gitea/"):].rstrip("/")
for u 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 = u.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
lines.extend([
f"- name: {host}",
f" url: {cred_proxy_url()}{u.path}",
+43 -71
View File
@@ -28,34 +28,37 @@ from dataclasses import dataclass
from pathlib import Path
from .log import die
from .manifest import Bottle, TokenEntry
from .manifest import Bottle
@dataclass(frozen=True)
class CredProxyUpstream:
"""One route on the cred-proxy sidecar. Maps a path under the
proxy to a real upstream, an auth scheme, and the env-var slot
that holds the token inside the proxy container.
proxy to a real upstream, an auth scheme, an in-container env-var
slot, and optional provisioner roles.
`kind` is the originating `TokenEntry.Kind`; `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 all kinds
except `gitea`, which uses `token` to sidestep go-gitea/gitea#16734).
`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`. Two routes that share a TokenRef (the github
Kind expands into two routes — gh-api and gh-git) carry the same
`token_env`."""
under `token_env`. Routes that share a TokenRef 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 upstream's dotfile family is written."""
kind: str
path: str
upstream: str
auth_scheme: str
token_env: str
token_ref: str
roles: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -93,64 +96,35 @@ class CredProxyPlan:
pipelock_proxy_url: str = ""
# Hardcoded upstream URLs for the non-gitea Kinds. Gitea's URL is per-
# entry (`TokenEntry.Url`).
_KIND_ROUTES: dict[str, tuple[tuple[str, str], ...]] = {
# kind -> ((path, upstream), ...) — a Kind can produce multiple
# routes; today only `github` does (api + git endpoints).
"anthropic": (("/anthropic/", "https://api.anthropic.com"),),
"github": (
("/gh-api/", "https://api.github.com"),
("/gh-git/", "https://github.com"),
),
"npm": (("/npm/", "https://registry.npmjs.org"),),
}
# Per-Kind auth header value prefix. Gitea uses `token` (not Bearer);
# everyone else uses Bearer.
_KIND_AUTH_SCHEME: dict[str, str] = {
"anthropic": "Bearer",
"github": "Bearer",
"gitea": "token",
"npm": "Bearer",
}
def cred_proxy_route_path_for_gitea(host: str) -> str:
"""Agent-facing path for a single Gitea instance. The host segment
disambiguates routes when multiple gitea entries are declared."""
return f"/gitea/{host}/"
def cred_proxy_upstreams_for_bottle(
bottle: Bottle,
) -> tuple[CredProxyUpstream, ...]:
"""Lift every `bottle.tokens[]` entry into one or more
CredProxyUpstreams. Order is preserved so route lookup is stable.
Manifest validation already enforced uniqueness rules."""
"""Lift each `bottle.cred_proxy.routes[]` entry into a
CredProxyUpstream. Order is preserved so route lookup is stable.
Token-env slots are assigned per distinct TokenRef: the first
route with TokenRef "GH_PAT" gets `CRED_PROXY_TOKEN_0`; a second
route with the same TokenRef shares slot 0. The launch step
forwards each TokenRef'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[CredProxyUpstream] = []
for i, t in enumerate(bottle.tokens):
token_env = f"CRED_PROXY_TOKEN_{i}"
scheme = _KIND_AUTH_SCHEME[t.Kind]
if t.Kind == "gitea":
out.append(CredProxyUpstream(
kind="gitea",
path=cred_proxy_route_path_for_gitea(t.UpstreamHost),
upstream=t.Url.rstrip("/"),
auth_scheme=scheme,
token_env=token_env,
token_ref=t.TokenRef,
))
else:
for path, upstream in _KIND_ROUTES[t.Kind]:
out.append(CredProxyUpstream(
kind=t.Kind,
path=path,
upstream=upstream,
auth_scheme=scheme,
token_env=token_env,
token_ref=t.TokenRef,
))
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(CredProxyUpstream(
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)
@@ -212,14 +186,14 @@ def cred_proxy_resolve_token_values(
if value is None:
die(
f"cred-proxy: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding token entry "
f"from bottle.tokens."
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 token entry."
f"real value or remove the route."
)
out[token_env] = value
return out
@@ -269,10 +243,8 @@ __all__ = [
"CredProxy",
"CredProxyPlan",
"CredProxyUpstream",
"TokenEntry",
"cred_proxy_render_routes",
"cred_proxy_resolve_token_values",
"cred_proxy_route_path_for_gitea",
"cred_proxy_token_env_map",
"cred_proxy_upstreams_for_bottle",
]
+170 -111
View File
@@ -5,10 +5,10 @@ Schema (see CLAUDE.md "Intended design"):
{
"bottles": {
"<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... },
"git": [ <git-entry>, ... ],
"tokens": [ <token-entry>, ... ],
"egress": { "allowlist": [ "<hostname>", ... ] }
"env": { "<NAME>": <env-entry>, ... },
"git": [ <git-entry>, ... ],
"cred_proxy": { "routes": [ <route>, ... ] },
"egress": { "allowlist": [ "<hostname>", ... ] }
}
},
"agents": {
@@ -114,92 +114,152 @@ class GitEntry:
)
TOKEN_KINDS = ("anthropic", "github", "gitea", "npm")
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 TokenEntry:
"""One credential the per-bottle cred-proxy sidecar (PRD 0010)
holds and injects on the agent's behalf.
class CredProxyRoute:
"""One route on the per-bottle cred-proxy sidecar (PRD 0010).
`Kind` selects the route handler: `anthropic` / `github` / `npm`
have fixed upstream URLs; `gitea` requires an explicit `Url`
because the upstream is per-instance.
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`.
`TokenRef` is the name of the host env var the CLI resolves at
launch time. The value is forwarded into the cred-proxy
container's environ via `docker run -e NAME` — never onto argv,
never into a file. The value does NOT land in the agent's
environ.
`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 `Url` for `gitea` entries (or the
documented default for the other kinds). It exists so the
cross-validator can spot collisions with `bottle.git` upstreams
without re-parsing URLs at every call site."""
`UpstreamHost` is parsed from `Upstream` for the pipelock allowlist
+ the git-insteadof suppression check."""
Kind: str
Path: str
Upstream: str
AuthScheme: str
TokenRef: str
Url: str = ""
Role: tuple[str, ...] = ()
UpstreamHost: str = ""
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "TokenEntry":
d = _as_json_object(raw, f"bottle '{bottle_name}' tokens[{idx}]")
kind = d.get("Kind")
if not isinstance(kind, str) or not kind:
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"bottle '{bottle_name}' tokens[{idx}] missing required string field "
f"'Kind'"
f"{label} auth_scheme {auth_scheme!r} is not one of "
f"{', '.join(CRED_PROXY_AUTH_SCHEMES)}"
)
if kind not in TOKEN_KINDS:
die(
f"bottle '{bottle_name}' tokens[{idx}] Kind {kind!r} is not one of "
f"{', '.join(TOKEN_KINDS)}"
)
token_ref = d.get("TokenRef")
token_ref = d.get("token_ref")
if not isinstance(token_ref, str) or not token_ref:
die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) missing required "
f"string field 'TokenRef' (name of the host env var to forward)"
f"{label} missing required string field 'token_ref' "
f"(name of the host env var holding the token value)"
)
url_raw = d.get("Url")
if url_raw is None:
url = ""
elif isinstance(url_raw, str):
url = url_raw
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"bottle '{bottle_name}' tokens[{idx}] ({kind}) Url must be a string "
f"(was {type(url_raw).__name__})"
f"{label} role must be a string or a list of strings "
f"(was {type(role_raw).__name__})"
)
if kind == "gitea":
if not url:
for r in roles:
if r not in CRED_PROXY_ROLES:
die(
f"bottle '{bottle_name}' tokens[{idx}] (gitea) requires a Url "
f"(the Gitea instance, e.g. https://gitea.dideric.is)"
f"{label} role {r!r} is not one of "
f"{', '.join(sorted(CRED_PROXY_ROLES))}"
)
host = _parse_https_host(
url, f"bottle '{bottle_name}' tokens[{idx}] (gitea) Url"
)
else:
if url:
die(
f"bottle '{bottle_name}' tokens[{idx}] ({kind}) cannot set Url; "
f"the upstream for this Kind is fixed by cred-proxy. Drop the "
f"'Url' field."
)
host = _TOKEN_DEFAULT_HOST[kind]
return cls(Kind=kind, TokenRef=token_ref, Url=url, UpstreamHost=host)
return cls(
Path=path,
Upstream=upstream,
AuthScheme=auth_scheme,
TokenRef=token_ref,
Role=roles,
UpstreamHost=host,
)
# Hostnames the cred-proxy talks to upstream for the non-gitea kinds.
# Used both for the proxy's route table and for the manifest cross-
# validator that rejects overlap with `bottle.git`.
_TOKEN_DEFAULT_HOST: dict[str, str] = {
"anthropic": "api.anthropic.com",
"github": "github.com",
"npm": "registry.npmjs.org",
}
@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)
DLP_ACTIONS = ("block", "warn")
@@ -257,7 +317,7 @@ class BottleEgress:
class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = ()
tokens: tuple[TokenEntry, ...] = ()
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
egress: BottleEgress = field(default_factory=BottleEgress)
@classmethod
@@ -305,20 +365,19 @@ class Bottle:
)
_validate_unique_git_names(name, git)
tokens: tuple[TokenEntry, ...] = ()
tokens_raw = d.get("tokens")
if tokens_raw is not None:
if not isinstance(tokens_raw, list):
die(
f"bottle '{name}' tokens must be an array "
f"(was {type(tokens_raw).__name__})"
)
tokens_list = cast(list[object], tokens_raw)
tokens = tuple(
TokenEntry.from_dict(name, i, entry)
for i, entry in enumerate(tokens_list)
if "tokens" in d:
die(
f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
f"each route now lives under 'cred_proxy.routes' with explicit "
f"path / upstream / auth_scheme / token_ref / role[]. See "
f"docs/prds/0010-cred-proxy.md."
)
_validate_tokens(name, tokens, git)
cred_proxy = (
CredProxyConfig.from_dict(name, d["cred_proxy"])
if "cred_proxy" in d
else CredProxyConfig()
)
egress_raw = d.get("egress")
egress = (
@@ -327,7 +386,7 @@ class Bottle:
else BottleEgress()
)
return cls(env=env, git=git, tokens=tokens, egress=egress)
return cls(env=env, git=git, cred_proxy=cred_proxy, egress=egress)
@dataclass(frozen=True)
@@ -561,41 +620,41 @@ def _parse_https_host(url: str, label: str) -> str:
return host
def _validate_tokens(
def _validate_cred_proxy_routes(
bottle_name: str,
tokens: tuple[TokenEntry, ...],
git: tuple[GitEntry, ...],
routes: tuple[CredProxyRoute, ...],
) -> None:
"""Cross-validation for `bottle.tokens`:
"""Cross-validation for `bottle.cred_proxy.routes`:
- At most one entry per Kind, except `gitea` which may have
multiple entries (one per Gitea instance) with distinct Urls.
- 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.
A `github` or `gitea` token MAY name the same host as a
`bottle.git` entry: the two paths broker different protocols
(git-gate handles SSH push/fetch with an IdentityFile; cred-proxy
handles HTTPS REST API calls with a PAT), so declaring both on
one host is a legitimate dev setup, not a configuration error.
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.
"""
del git # cross-host overlap is intentionally not rejected.
by_kind: dict[str, list[TokenEntry]] = {}
for t in tokens:
by_kind.setdefault(t.Kind, []).append(t)
for kind, entries in by_kind.items():
if kind == "gitea":
seen: dict[str, None] = {}
for e in entries:
if e.Url in seen:
die(
f"bottle '{bottle_name}' tokens has duplicate gitea Url "
f"{e.Url!r}; one entry per Gitea instance."
)
seen[e.Url] = None
elif len(entries) > 1:
seen_paths: dict[str, None] = {}
for r in routes:
if r.Path in seen_paths:
die(
f"bottle '{bottle_name}' tokens has {len(entries)} entries with "
f"Kind {kind!r}; at most one is allowed (gitea is the only Kind "
f"that may have multiple entries)."
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."
)
+9 -18
View File
@@ -57,27 +57,18 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
def pipelock_token_hosts(bottle: Bottle) -> list[str]:
"""Hostnames the cred-proxy sidecar (PRD 0010) talks to upstream
on the agent's behalf. Derived from `bottle.tokens[]`. Returned
on the agent's behalf. Derived from each route's
`upstream.UpstreamHost` in `bottle.cred_proxy.routes`. Returned
sorted+deduped.
These hosts must be on pipelock's allowlist so cred-proxy's
outbound HTTPS traffic can leave the egress network, and on
pipelock's TLS-passthrough list so pipelock does not MITM them —
cred-proxy validates real upstream certs with the system CA store,
so a pipelock-bumped cert would fail trust."""
hosts: set[str] = set()
for t in bottle.tokens:
if t.Kind == "github":
hosts.add("api.github.com")
hosts.add("github.com")
elif t.Kind == "gitea":
if t.UpstreamHost:
hosts.add(t.UpstreamHost)
elif t.Kind == "npm":
hosts.add("registry.npmjs.org")
elif t.Kind == "anthropic":
# Already on DEFAULT_ALLOWLIST + DEFAULT_TLS_PASSTHROUGH.
hosts.add("api.anthropic.com")
outbound HTTPS traffic can leave the egress network. They are
NOT auto-added to passthrough_domains: cred-proxy's HTTPS client
trusts pipelock's per-bottle CA at runtime (installed via
docker cp + update-ca-certificates in the cred-proxy image),
so pipelock MITMs and body-scans the cred-proxy upstream leg
the same way it does direct agent traffic."""
hosts = {r.UpstreamHost for r in bottle.cred_proxy.routes if r.UpstreamHost}
return sorted(hosts)