refactor(cred_proxy): flat routes, role-driven provisioning (PRD 0010)
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:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user