18e3b62b72
Delete CLAUDE.md in favor of AGENTS.md as the orientation doc, rebrand the project from Codex-bottle to provider-agnostic bot-bottle, and repoint every CLAUDE.md reference across PRDs, research notes, the implementer agent example, and the yaml_subset comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
552 lines
29 KiB
Markdown
552 lines
29 KiB
Markdown
# PRD 0010: Credential proxy for agent-bound API tokens
|
|
|
|
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
|
- **Author:** didericis
|
|
- **Created:** 2026-05-13
|
|
- **Superseded:** 2026-05-25
|
|
|
|
> **Historical reference only.** The cred-proxy sidecar this PRD
|
|
> describes was replaced by the egress-proxy sidecar (PRD 0017) in
|
|
> a hard cutover. The auth-injection role moved over largely intact;
|
|
> path-prefix routing is replaced by universal MITM at the agent's
|
|
> HTTP_PROXY. See PRD 0017's "Migration — hard cutover" section for
|
|
> the field-by-field manifest rename.
|
|
|
|
## Summary
|
|
|
|
Per-bottle sidecar container that holds API tokens (Anthropic
|
|
OAuth, GitHub PAT, Gitea PAT, npm token). The agent container
|
|
keeps only URLs in its environ; the sidecar injects the right
|
|
`Authorization` header and forwards over TLS to the upstream. The
|
|
boundary is the container line — PID, mount, and network
|
|
namespaces separate the agent's container from the sidecar's, so
|
|
from inside the agent the sidecar's processes are not visible in
|
|
`/proc`, cannot be `ptrace`'d, and share no memory. Reaching the
|
|
sidecar's environ requires escaping the agent container — the same
|
|
threshold pipelock and git-gate already rely on.
|
|
|
|
AWS / SigV4 is explicitly out of scope — it is per-request signing,
|
|
not header injection, and does not fit this proxy's shape. If a
|
|
bottle needs AWS credentials later, that lives in a separate PRD.
|
|
|
|
## Problem
|
|
|
|
Today `CLAUDE_CODE_OAUTH_TOKEN` (and any `bottle.env` secrets such
|
|
as a Gitea PAT, GitHub PAT, or npm token) gets `docker run -e`'d
|
|
straight into the agent's environ. Inside the bottle the agent
|
|
runs as `node` with `--dangerously-skip-permissions`; its Bash
|
|
tool can do `printenv`, `cat /proc/self/environ`, or
|
|
`node -e 'console.log(process.env)'` and capture every value into
|
|
the conversation. From there a prompt-injected or hijacked agent
|
|
can exfil over any allowed egress (api.anthropic.com itself if
|
|
nothing else).
|
|
|
|
Linux has no per-env-var ACL — once a variable is in a process's
|
|
environ, the process and its descendants own it. The credible
|
|
boundary is container-level: hold the credential in a separate
|
|
container the agent cannot reach. Default Docker's namespace
|
|
isolation enforces that — the same property pipelock and git-gate
|
|
already rely on.
|
|
|
|
The research note
|
|
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
|
|
surveys the existing tools and concludes that a small
|
|
bot-bottle-specific reverse proxy is less work and less risk
|
|
than either adopting nono (alpha, unaudited) or Infisical Agent
|
|
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
|
|
This PRD is the build.
|
|
|
|
## Goals / Success Criteria
|
|
|
|
Each test runs inside a bottle whose manifest declares the four
|
|
common upstreams (Anthropic, GitHub, Gitea, npm) as
|
|
`bottle.cred_proxy.routes` entries:
|
|
|
|
1. **No plaintext tokens in the agent's environ.** `printenv` and
|
|
`cat /proc/self/environ` from the agent's shell return only
|
|
URLs pointing at `cred-proxy:<PORT>/...`. None of the
|
|
`cred_proxy.routes[].token_ref` host env-var values appear.
|
|
2. **Container boundary holds.** From the agent's shell, `ps aux`
|
|
does not list the cred-proxy process; there is no `/proc/<X>`
|
|
entry for it to read. The sidecar's hostname (`cred-proxy`)
|
|
resolves only on the bottle's internal network — from a
|
|
different bottle or from the host, the name does not resolve.
|
|
3. **Anthropic API works.** `claude` makes a successful streaming
|
|
tool-use round-trip via `ANTHROPIC_BASE_URL` →
|
|
`cred-proxy:<PORT>/anthropic`. SSE chunks arrive without
|
|
buffering; `anthropic-version`, `anthropic-beta`, and
|
|
`X-Claude-Code-Session-Id` headers round-trip untouched.
|
|
4. **`tea` / REST API against declared upstreams works.**
|
|
`tea pr list` against a route's upstream succeeds; the
|
|
upstream sees the proxy-injected token, not the agent's.
|
|
`git push` is *not* on the cred-proxy path — that goes
|
|
through `bottle.git` / git-gate (where gitleaks runs).
|
|
5. **npm install works.** `npm install <public-package>`
|
|
succeeds against the registry pointed at the proxy. A scoped
|
|
install that requires the token (e.g. against a private
|
|
registry) also succeeds.
|
|
6. **Wrong token rejected at the source, not silently swapped.**
|
|
If the agent tries to send its own `Authorization: …` header,
|
|
the proxy strips and replaces with the configured one. A
|
|
manifest token revoked at the upstream produces a 401 to the
|
|
agent, not a 5xx. Git smart-HTTP push paths
|
|
(`/git-receive-pack`, `/info/refs?service=git-receive-pack`)
|
|
return 403 unconditionally — push must go through git-gate's
|
|
gitleaks-scanned SSH path.
|
|
|
|
## Non-goals
|
|
|
|
- **AWS / SigV4.** Per-request signing is a different shape; a
|
|
bearer-injecting proxy doesn't help. Hold for a future PRD
|
|
(likely an IMDS emulator sidecar handing out short-lived STS
|
|
credentials).
|
|
- **DB-backed credential store.** Flat env / mode-600 file only.
|
|
The LiteLLM CVE-2026-42208 incident is the cautionary tale:
|
|
any DB-backed credential gateway is itself a high-value attack
|
|
target.
|
|
- **Generic LLM-gateway features.** No cost tracking, no
|
|
fallbacks, no virtual keys, no multi-tenant routing, no usage
|
|
metering. The proxy is a credential-injection trust endpoint,
|
|
not a gateway.
|
|
- **Subsuming pipelock.** pipelock keeps its egress-allowlist
|
|
role. It drops the `api.anthropic.com` TLS-MITM job because
|
|
cred-proxy is now the trust endpoint for that host; everything
|
|
else pipelock does stays.
|
|
- **TLS interception inside the bottle.** The agent talks plain
|
|
HTTP to loopback; cred-proxy speaks real HTTPS outbound. No
|
|
container-local CA, no `golang/go#28866` loopback workaround.
|
|
- **Cross-bottle credential sharing.** One proxy per bottle, same
|
|
one-sidecar-per-agent posture as pipelock and git-gate.
|
|
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
|
|
the OAuth token. Not in bot-bottle's flow today.
|
|
- **MCP-server tokens, package-installer tokens for languages
|
|
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
|
|
needed; the routing pattern generalizes.
|
|
|
|
## Scope
|
|
|
|
### In scope
|
|
|
|
- **Manifest field.** `bottle.cred_proxy.routes: [Route, ...]`.
|
|
Each route carries `path` (agent-facing prefix), `upstream`
|
|
(HTTPS upstream URL), `auth_scheme` (`Bearer` or `token`),
|
|
`token_ref` (name of a host env var the CLI resolves at launch
|
|
time), and an optional `role` (string or list of strings — see
|
|
"Agent-side rewrites" below). Routes are independent — there is
|
|
no `Kind` enum or per-kind hardcoded path/upstream mapping; the
|
|
manifest is the source of truth for the proxy's runtime route
|
|
table.
|
|
- **cred-proxy sidecar.** Runs as its own container on the
|
|
bottle's internal docker network with hostname `cred-proxy`,
|
|
listening on `0.0.0.0:<PORT>` bound to the internal interface.
|
|
No host port published. Holds the tokens in the sidecar
|
|
container's environ — never on argv, never written to disk.
|
|
Per-route handler: inject the configured header, forward over
|
|
TLS, stream the response back without buffering.
|
|
- **Agent-side rewrites.** A route's `role` (string or list of
|
|
strings) drives optional agent-side dotfile/env writes when the
|
|
sidecar comes up. Known roles:
|
|
- `anthropic-base-url` (singleton): sets
|
|
`ANTHROPIC_BASE_URL=http://cred-proxy:<PORT><route.path>` in
|
|
the agent's environ. Used for the Anthropic OAuth path.
|
|
- `npm-registry` (singleton): writes
|
|
`registry=http://cred-proxy:<PORT><route.path>` to `~/.npmrc`.
|
|
- `git-insteadof`: writes a `[url "http://cred-proxy:<PORT><route.path>"]
|
|
insteadOf = <route.upstream>/` block to `~/.gitconfig`.
|
|
Suppressed when `bottle.git` already brokers the same host:
|
|
git-gate is the canonical git path there — its pre-receive
|
|
runs gitleaks before forwarding pushes; a cred-proxy
|
|
`https://<host>/` rewrite would route HTTPS git ops around
|
|
the gate. (cred-proxy independently refuses smart-HTTP push
|
|
paths at runtime — see "Smart-HTTP push refused" below — but
|
|
suppressing the rewrite means `git clone https://<host>/...`
|
|
doesn't have a tempting shortcut that just confuses later.)
|
|
- `tea-login`: adds a `logins:` entry to
|
|
`~/.config/tea/config.yml` pointing at the proxy. Used for
|
|
Gitea instances; combine with `git-insteadof` for full agent
|
|
coverage.
|
|
|
|
Routes without a `role` are pure proxy entries — the proxy
|
|
handles them at runtime, but no agent-side rewrite happens. The
|
|
singleton roles must appear on at most one route per bottle
|
|
(manifest validation enforces this).
|
|
- **Sidecar lifecycle.** Mirrors `DockerGitGate` /
|
|
`DockerPipelockProxy` in shape: `prepare` is host-side and
|
|
side-effect-free; `start` does `docker create` + `docker start`
|
|
on the bottle's internal network with hostname `cred-proxy`;
|
|
`stop` is idempotent `docker rm -f`. Container name:
|
|
`bot-bottle-cred-proxy-<slug>`. The agent container starts
|
|
after the sidecar is up so DNS resolution succeeds on the
|
|
agent's first call.
|
|
- **pipelock interop.** cred-proxy's outbound HTTPS traverses
|
|
pipelock: the sidecar's environ sets `HTTPS_PROXY` /
|
|
`HTTP_PROXY` to the per-bottle pipelock URL, and the cred-proxy
|
|
image's entrypoint runs `update-ca-certificates` over the
|
|
per-bottle pipelock CA (`docker cp`'d into
|
|
`/usr/local/share/ca-certificates/pipelock.crt` before start)
|
|
so cred-proxy's HTTPS client trusts pipelock's bumped certs.
|
|
Pipelock's allowlist + body scanner therefore apply to
|
|
cred-proxy → upstream the same way they apply to direct agent
|
|
traffic. Only `api.anthropic.com` stays on
|
|
`passthrough_domains` (its bodies are LLM conversation text
|
|
that legitimately trips DLP heuristics); github / gitea / npm
|
|
hosts are auto-added to the allowlist (so cred-proxy can reach
|
|
them) but NOT to passthrough, so pipelock body-scans them.
|
|
- **Smart-HTTP push refused.** cred-proxy returns 403 for paths
|
|
matching `/info/refs?service=git-receive-pack` and any path
|
|
ending in `/git-receive-pack`. Fetch (upload-pack) is allowed.
|
|
Push must go through `bottle.git` / git-gate, where the
|
|
gitleaks pre-receive hook runs. This holds even when no
|
|
matching `bottle.git` entry exists — the proxy is not a
|
|
scanned-push path, period.
|
|
- **Plan rendering.** `bottle_plan.py` and the y/N preflight
|
|
show: which tokens are configured (kind + ref name, not the
|
|
value), the proxy port, the routes the proxy will publish.
|
|
- **Drop the existing `CLAUDE_CODE_OAUTH_TOKEN` forward in
|
|
`prepare.py`.** Today it lands in the agent's environ; once
|
|
this PRD ships, it lands in the cred-proxy sidecar's environ
|
|
instead.
|
|
- **Tests.** Integration tests for each of the six success
|
|
criteria; unit tests for manifest parsing, route table
|
|
generation, header injection.
|
|
|
|
### Out of scope
|
|
|
|
- AWS / SigV4 (see Non-goals).
|
|
- Per-method / per-path allowlist *inside* a kind. Defer to a
|
|
follow-up once observed traffic stabilizes.
|
|
- Replacing `bottle.env` for non-token secrets. The proxy
|
|
handles the four kinds listed above; other env vars keep their
|
|
current path.
|
|
- Migrating an in-flight bottle from "token in agent env" to
|
|
"token via proxy" mid-session. Restart required.
|
|
- Audit logging. The proxy doesn't write request logs in v1.
|
|
Add only if a concrete debugging need surfaces.
|
|
|
|
## Proposed Design
|
|
|
|
### Architecture
|
|
|
|
```
|
|
┌── Host (macOS) ──────────────────────────────────────────────────┐
|
|
│ Secrets at rest (keychain / .env): │
|
|
│ BOT_BOTTLE_CLAUDE_OAUTH_TOKEN, GITHUB_TOKEN, │
|
|
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
|
|
│ │ docker run -e KEY (no =VALUE on argv) │
|
|
│ ▼ │
|
|
│ ┌── per-bottle internal docker network ──────────────────────┐ │
|
|
│ │ │ │
|
|
│ │ ┌── agent container ─────────────────────────────────┐ │ │
|
|
│ │ │ claude as node (UID 1000) │ │ │
|
|
│ │ │ --dangerously-skip-permissions │ │ │
|
|
│ │ │ environ: URLs only, no plaintext tokens │ │ │
|
|
│ │ │ ANTHROPIC_BASE_URL=http://cred-proxy:PORT/an.. │ │ │
|
|
│ │ │ npm registry → http://cred-proxy:PORT/npm/ │ │ │
|
|
│ │ │ git insteadOf → http://cred-proxy:PORT/... │ │ │
|
|
│ │ │ tea --url → http://cred-proxy:PORT/gite │ │ │
|
|
│ │ └────────────┬───────────────────────────────────────┘ │ │
|
|
│ │ │ HTTP, DNS → cred-proxy │ │
|
|
│ │ ▼ │ │
|
|
│ │ ┌── cred-proxy sidecar ──────────────────────────────┐ │ │
|
|
│ │ │ distroless image, no shell, runs as root │ │ │
|
|
│ │ │ hostname: cred-proxy listens 0.0.0.0:PORT │ │ │
|
|
│ │ │ tokens live ONLY in this container's environ │ │ │
|
|
│ │ │ /anthropic → api.anthropic.com Bearer │ │ │
|
|
│ │ │ /gh-api → api.github.com Bearer │ │ │
|
|
│ │ │ /gh-git → github.com Bearer │ │ │
|
|
│ │ │ /gitea → gitea.dideric.is token │ │ │
|
|
│ │ │ /npm → registry.npmjs.org Bearer │ │ │
|
|
│ │ │ SSE pass-through, no buffering │ │ │
|
|
│ │ └────────────┬───────────────────────────────────────┘ │ │
|
|
│ │ │ HTTPS │ │
|
|
│ │ ▼ │ │
|
|
│ │ ┌── pipelock sidecar (egress allowlist) ─────────────┐ │ │
|
|
│ │ │ allow: api.anthropic.com, api.github.com, │ │ │
|
|
│ │ │ github.com, gitea.dideric.is, │ │ │
|
|
│ │ │ registry.npmjs.org │ │ │
|
|
│ │ │ block: statsig, sentry, autoupdater, * │ │ │
|
|
│ │ └────────────┬───────────────────────────────────────┘ │ │
|
|
│ └────────────────┼───────────────────────────────────────────┘ │
|
|
│ ▼ │
|
|
└────────────────────┼─────────────────────────────────────────────┘
|
|
▼
|
|
Upstream APIs
|
|
|
|
|
|
Why the agent can't reach the sidecar's environ:
|
|
┌───────────────────────────────────────────────────────────────┐
|
|
│ Different container = different PID, mount, and network ns. │
|
|
│ The agent's /proc shows only the agent's own processes; │
|
|
│ the cred-proxy PID is not visible — no /proc/<X>/environ │
|
|
│ to read, no PID to ptrace, no shared memory. │
|
|
│ │
|
|
│ Reaching the sidecar's environ requires escaping the agent │
|
|
│ container — the same threshold pipelock and git-gate rely │
|
|
│ on. Default Docker isolation is the boundary. │
|
|
└───────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### New components
|
|
|
|
- **`bot_bottle/cred_proxy.py`** (new): abstract `CredProxy`
|
|
+ `CredProxyPlan` dataclass. `prepare` is host-side and
|
|
side-effect-free; renders the route table and resolves
|
|
`TokenRef`s against host env. Mirrors the existing `GitGate` /
|
|
`Pipelock` shape.
|
|
- **`bot_bottle/backend/docker/cred_proxy.py`** (new):
|
|
`DockerCredProxy` concrete subclass. `start` does
|
|
`docker create` on the bottle's internal network with hostname
|
|
`cred-proxy`, copies the route-table file into the container,
|
|
then `docker start`. `stop` is idempotent `docker rm -f`.
|
|
Container name: `bot-bottle-cred-proxy-<slug>`.
|
|
- **`bot_bottle/backend/docker/provision/cred_proxy.py`**
|
|
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
|
|
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
|
|
into the agent's home for each declared kind — all pointing at
|
|
`http://cred-proxy:<PORT>/...`.
|
|
- **cred-proxy image.** Minimal base + the proxy binary, no
|
|
shell. Pinned by digest, baked at build time. Footprint sized
|
|
to match git-gate's image rather than the full agent image.
|
|
|
|
### Existing code touched
|
|
|
|
- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
|
|
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
|
|
+ validate route shape, role enum, path uniqueness, singleton-
|
|
role constraints.
|
|
- **`bot_bottle/backend/docker/prepare.py`** — drop the
|
|
legacy `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` → `CLAUDE_CODE_OAUTH_TOKEN`
|
|
forward entirely. cred-proxy is the only path the Anthropic
|
|
OAuth token reaches the bottle. When a route claims the
|
|
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
|
|
(pointing at the proxy) plus a non-secret placeholder for
|
|
`CLAUDE_CODE_OAUTH_TOKEN` (claude-code refuses to start
|
|
otherwise; the proxy strips & replaces on every request).
|
|
Bottles that need claude-code to authenticate must declare
|
|
the route; there is no fallback.
|
|
- **`bot_bottle/backend/docker/backend.py`** — instantiate
|
|
`DockerCredProxy` alongside `DockerPipelockProxy` and
|
|
`DockerGitGate`; thread its `prepare` / `start` / `stop`
|
|
through `resolve_plan` / `launch`.
|
|
- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
|
|
start/stop to the `ExitStack` after pipelock and before the
|
|
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
|
|
on the cred-proxy plan so its outbound HTTPS routes through
|
|
pipelock.
|
|
- **`bot_bottle/backend/docker/bottle_plan.py`** — new
|
|
`cred_proxy_plan` field; preflight shows route count + token
|
|
refs + a path→upstream line per route; `to_dict` emits a
|
|
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
|
|
roles}`.
|
|
- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
|
|
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
|
|
map). Allowlist auto-includes them; passthrough does not (the
|
|
proxy trusts pipelock's CA so MITM works).
|
|
- **`README.md`** — architecture diagram includes the cred-proxy
|
|
lane; manifest section documents `bottle.cred_proxy.routes`.
|
|
- **`bot-bottle.example.json`** — one bottle demonstrates the
|
|
four common routes (Anthropic, GitHub, Gitea, npm).
|
|
- **Tests** — manifest parsing/validation, route lift + token-env
|
|
slot assignment, role-based dispatch in the provisioner,
|
|
pipelock allowlist derivation from routes. Integration test
|
|
exercises header inject + smart-HTTP push refusal.
|
|
|
|
### Data model changes
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class CredProxyRoute:
|
|
Path: str # "/anthropic/" — must start and end with /
|
|
Upstream: str # "https://api.anthropic.com" — https only
|
|
AuthScheme: str # "Bearer" or "token"
|
|
TokenRef: str # name of host env var
|
|
Role: tuple[str, ...] = () # provisioner tags; see CRED_PROXY_ROLES
|
|
UpstreamHost: str = "" # derived from Upstream
|
|
|
|
@dataclass(frozen=True)
|
|
class CredProxyConfig:
|
|
routes: tuple[CredProxyRoute, ...] = ()
|
|
|
|
@dataclass(frozen=True)
|
|
class Bottle:
|
|
...
|
|
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
|
|
```
|
|
|
|
Validation:
|
|
|
|
- `Path` non-empty, starts and ends with `/`; unique across all
|
|
routes in a bottle (the proxy routes by longest-prefix match).
|
|
- `Upstream` is `https://...` with a non-empty host.
|
|
- `AuthScheme` is one of `Bearer`, `token`.
|
|
- `TokenRef` non-empty; its value is resolved against
|
|
`os.environ` at launch (fail fast with a clear "host env var X
|
|
is unset" if missing).
|
|
- `Role` items are one of `anthropic-base-url`, `npm-registry`,
|
|
`git-insteadof`, `tea-login`. Single string accepted as sugar
|
|
for a one-item list.
|
|
- Singleton roles (`anthropic-base-url`, `npm-registry`) appear
|
|
on at most one route per bottle.
|
|
- A route MAY name the same host as a `bottle.git` entry. The
|
|
two paths broker different protocols — git-gate holds an SSH
|
|
`IdentityFile` for push/fetch and runs gitleaks; cred-proxy
|
|
holds a PAT for HTTPS REST API calls (`tea`, `gh`, octokit).
|
|
The common dev setup uses both on the same host. The
|
|
provisioner's `git-insteadof` role is suppressed in that case
|
|
(see Agent-side rewrites).
|
|
|
|
### Example routes
|
|
|
|
| Common upstream | Route |
|
|
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
|
|
| Anthropic API | `{path: "/anthropic/", upstream: "https://api.anthropic.com", auth_scheme: "Bearer", token_ref: "…", role: "anthropic-base-url"}` |
|
|
| GitHub REST API | `{path: "/gh-api/", upstream: "https://api.github.com", auth_scheme: "Bearer", token_ref: "…"}` |
|
|
| GitHub git transport | `{path: "/gh-git/", upstream: "https://github.com", auth_scheme: "Bearer", token_ref: "…", role: "git-insteadof"}` |
|
|
| Gitea instance | `{path: "/gitea/<host>/", upstream: "https://<host>", auth_scheme: "token", token_ref: "…", role: ["git-insteadof", "tea-login"]}` |
|
|
| npm registry | `{path: "/npm/", upstream: "https://registry.npmjs.org", auth_scheme: "Bearer", token_ref: "…", role: "npm-registry"}` |
|
|
|
|
Gitea uses `Authorization: token` rather than `Bearer` to
|
|
sidestep `go-gitea/gitea#16734`. The proxy strips any incoming
|
|
`Authorization` header before injecting its own — the agent
|
|
cannot smuggle a stolen token through this path.
|
|
|
|
### External dependencies
|
|
|
|
The proxy binary. Two real options:
|
|
|
|
- **Python (stdlib)** — `http.server` + `urllib`/`http.client`,
|
|
no new pip packages. Matches AGENTS.md's "Python, stdlib-first,
|
|
low-deps" posture. SSE pass-through is fiddly but doable.
|
|
- **Go single binary** — cleaner SSE story, smaller runtime,
|
|
one static binary in a scratch/distroless image. New build
|
|
dependency.
|
|
|
|
Default: Python in a minimal `python:3.X-slim` image (or alpine
|
|
if we want smaller). Reconsider in the implementation PR if SSE
|
|
behavior is troublesome under load.
|
|
|
|
No new Python packages. No DB. No admin API. The proxy's
|
|
configuration is a single mode-600 JSON file copied into the
|
|
sidecar at `docker create` time and read by the proxy at startup
|
|
from `/run/cred-proxy/routes.json`.
|
|
|
|
## Future work
|
|
|
|
- **AWS / SigV4.** Likely an IMDS emulator sidecar handing out
|
|
short-lived STS tokens. Different threat model (the agent
|
|
ends up holding the STS creds — the proxy just shortens
|
|
their lifetime). Separate PRD.
|
|
- **Per-method / per-path allowlist** inside a kind. Once the
|
|
set of API operations claude actually performs is observed,
|
|
reject everything else. Narrows the within-allowlist surface.
|
|
- **Short-lived token minting.** For services that support it
|
|
(GitHub Apps, GitLab project-access tokens, fine-grained
|
|
PATs with TTL), have the proxy mint a fresh per-session
|
|
child credential from a long-lived parent.
|
|
- **Smolmachines colocation.** Same packing question as
|
|
pipelock / git-gate; under a future microVM backend the
|
|
cred-proxy could share a VM with the agent (today's per-bottle
|
|
network gives it its own container, not its own VM) or sit in
|
|
its own VM (stricter isolation, an extra TCP hop). Backend
|
|
decision, not a manifest decision.
|
|
- **More kinds.** PyPI, Bun, cargo, Docker Hub. The routing
|
|
pattern generalizes; add as needed.
|
|
|
|
## Considered alternatives
|
|
|
|
### In-container proxy (root inside the agent container)
|
|
|
|
Run cred-proxy as PID 1 of the agent container, listening on
|
|
`127.0.0.1:<PORT>`, with claude exec'd as `node` (UID 1000) only
|
|
after the proxy is bound. The boundary in that shape is the
|
|
kernel's cross-UID `ptrace_may_access` check — `node` cannot read
|
|
root's `/proc/<pid>/environ` and cannot `ptrace` attach.
|
|
|
|
Pros: one less container per bottle; slightly faster bottle
|
|
startup; no extra docker create/start/stop dance.
|
|
|
|
Rejected because:
|
|
|
|
- **Weaker isolation.** The boundary collapses to UID separation
|
|
alone. Any container-root compromise inside the agent (setuid
|
|
bug in the image, accidentally mounted docker socket, a kernel
|
|
CVE, accidental `--privileged`) reads the proxy's environ via
|
|
`/proc/<pid>/environ`. The sidecar's namespace separation
|
|
cannot be bypassed from inside the agent container without a
|
|
container escape.
|
|
- **Inconsistent with the existing topology.** pipelock and
|
|
git-gate are already sidecars on the bottle's internal network.
|
|
cred-proxy slots into the same shape and reuses the same
|
|
lifecycle abstractions (`BottleBackend.prepare/start/stop`,
|
|
`ExitStack` ordering, plan rendering).
|
|
- **Coupled to the agent image.** The proxy binary, its
|
|
entrypoint, and its priv-drop logic would all live in the
|
|
agent's Dockerfile. A sidecar image evolves independently —
|
|
agents can change base, language, or tooling without touching
|
|
the proxy.
|
|
- **PID-1 babysitting.** The "proxy supervises, then `exec
|
|
setpriv → node`" entrypoint introduces a class of issues
|
|
(zombie reaping, signal forwarding, exit-code propagation) that
|
|
the sidecar shape avoids.
|
|
|
|
## Open questions
|
|
|
|
- **~~Field name.~~** Resolved during iteration: routes live at
|
|
`bottle.cred_proxy.routes` (the nested object reserves room for
|
|
per-bottle proxy settings later). Each route is independent;
|
|
no `Kind` enum on the route. A `role` field drives the
|
|
optional agent-side rewrites — see "Agent-side rewrites" in
|
|
Scope.
|
|
- **Python vs Go for the proxy.** Default: Python, revisit
|
|
during implementation if SSE pass-through is unreliable.
|
|
- **Sidecar image base.** Distroless (smallest, no shell — hardest
|
|
to debug), Python slim (debuggable, larger), or scratch + a
|
|
statically-linked Go binary (smallest if Go). Default: whatever
|
|
fits the chosen language with the smallest non-shell base;
|
|
revisit if debuggability bites during implementation.
|
|
- **Belt-and-braces on outbound telemetry.** Set
|
|
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and
|
|
`DISABLE_ERROR_REPORTING=1` in the agent's environ by
|
|
default? Default: yes — they don't route through
|
|
`ANTHROPIC_BASE_URL`, so the proxy doesn't catch them; the
|
|
flags are the only off switch.
|
|
- **`git push` over a rewritten URL vs. credential-helper
|
|
shim.** `[url "http://…"] insteadOf = "https://github.com/"`
|
|
captures push/fetch/clone/pull/ls-remote in one config knob;
|
|
a credential helper would need separate wiring. Default:
|
|
`insteadOf`.
|
|
- **Token-refresh story for the Anthropic OAuth token.** The
|
|
token is ~1-year and there's no client-side refresh, so the
|
|
proxy holds a static value. The 1-year blast radius is the
|
|
cost, documented in
|
|
[`claude-code-token-revocation.md`](../research/claude-code-token-revocation.md).
|
|
No design change here; flagged for awareness.
|
|
- **`anthropics/claude-code#36998`.** Older claude-code
|
|
versions bypassed `ANTHROPIC_BASE_URL` for some startup
|
|
calls (auth validation, org lookup). Marked closed upstream;
|
|
the implementation PR verifies with `strace -e connect`
|
|
against the pinned claude-code build before trusting the
|
|
isolation.
|
|
|
|
## References
|
|
|
|
- [`docs/research/agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
|
|
— landscape research; this PRD is the build path that note
|
|
recommends.
|
|
- [`docs/research/secret-minimization-over-dlp.md`](../research/secret-minimization-over-dlp.md)
|
|
— architectural framing: why moving the credential matters
|
|
more than scanning egress.
|
|
- PRD 0006: pipelock TLS interception — the
|
|
`api.anthropic.com` TLS-MITM responsibility cred-proxy takes
|
|
over.
|
|
- PRD 0008: Git gate — the credential-broker pattern this PRD
|
|
reuses (gate holds creds, agent gets a rewritten URL, gate
|
|
makes the upstream connection).
|
|
- [`anthropics/claude-code#36998`](https://github.com/anthropics/claude-code/issues/36998)
|
|
— historic `ANTHROPIC_BASE_URL` bypass.
|
|
- [`go-gitea/gitea#16734`](https://github.com/go-gitea/gitea/issues/16734)
|
|
— why Gitea uses `Authorization: token`, not `Bearer`.
|
|
- [`golang/go#28866`](https://github.com/golang/go/issues/28866)
|
|
— the `HTTPS_PROXY` loopback bug; not hit here because we're
|
|
a reverse proxy, not a forward proxy.
|