docs: switch cred-proxy to sidecar shape
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 28s

Make the cred-proxy a per-bottle sidecar container on the bottle's
internal docker network instead of a root-owned process inside the
agent container. The boundary becomes container namespace
separation, matching pipelock and git-gate. Update summary,
problem, goals, in-scope, architecture diagram, components,
existing code touched, external deps, and open questions; add a
"Considered alternatives" section recording the rejected
in-container shape.
This commit is contained in:
2026-05-13 00:40:16 -04:00
parent 7dc3914abc
commit fe9d05664c
+145 -88
View File
@@ -6,15 +6,16 @@
## Summary ## Summary
Per-bottle reverse proxy that holds API tokens (Anthropic OAuth, Per-bottle sidecar container that holds API tokens (Anthropic
GitHub PAT, Gitea PAT, npm token) in a root-owned process inside OAuth, GitHub PAT, Gitea PAT, npm token). The agent container
the agent container. The agent (`node`, UID 1000) keeps only URLs keeps only URLs in its environ; the sidecar injects the right
in its environ; the proxy injects the right `Authorization` header `Authorization` header and forwards over TLS to the upstream. The
and forwards over TLS. The boundary that makes this meaningful is boundary is the container line — PID, mount, and network
the kernel's `ptrace_may_access` check: `node` cannot read root's namespaces separate the agent's container from the sidecar's, so
`/proc/<pid>/environ` and cannot `ptrace` attach without from inside the agent the sidecar's processes are not visible in
`CAP_SYS_PTRACE` / `CAP_PERFMON`, which claude-bottle does not `/proc`, cannot be `ptrace`'d, and share no memory. Reaching the
grant. 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, 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 not header injection, and does not fit this proxy's shape. If a
@@ -34,11 +35,10 @@ nothing else).
Linux has no per-env-var ACL — once a variable is in a process's 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 environ, the process and its descendants own it. The credible
boundary is process-level: hold the credential in a different boundary is container-level: hold the credential in a separate
process the agent cannot read. Default Docker already enforces container the agent cannot reach. Default Docker's namespace
that boundary at the kernel line via `ptrace_may_access`, the isolation enforces that the same property pipelock and git-gate
same property the (removed) ssh-gate and the current git-gate already rely on.
rely on.
The research note The research note
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md) [`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
@@ -55,15 +55,16 @@ supported kinds (anthropic, github, gitea, npm):
1. **No plaintext tokens in the agent's environ.** `printenv` and 1. **No plaintext tokens in the agent's environ.** `printenv` and
`cat /proc/self/environ` from the agent's shell return only `cat /proc/self/environ` from the agent's shell return only
URLs pointing at `127.0.0.1:<PORT>/...`. None of the URLs pointing at `cred-proxy:<PORT>/...`. None of the
`bottle.tokens[].TokenRef` values appear. `bottle.tokens[].TokenRef` values appear.
2. **Kernel boundary holds.** From the agent's shell, 2. **Container boundary holds.** From the agent's shell, `ps aux`
`cat /proc/<cred-proxy-pid>/environ` returns `EACCES` and does not list the cred-proxy process; there is no `/proc/<X>`
`gdb -p <cred-proxy-pid>` / `strace -p <cred-proxy-pid>` fails entry for it to read. The sidecar's hostname (`cred-proxy`)
with `EPERM`. 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 3. **Anthropic API works.** `claude` makes a successful streaming
tool-use round-trip via `ANTHROPIC_BASE_URL` tool-use round-trip via `ANTHROPIC_BASE_URL`
`127.0.0.1:<PORT>/anthropic`. SSE chunks arrive without `cred-proxy:<PORT>/anthropic`. SSE chunks arrive without
buffering; `anthropic-version`, `anthropic-beta`, and buffering; `anthropic-version`, `anthropic-beta`, and
`X-Claude-Code-Session-Id` headers round-trip untouched. `X-Claude-Code-Session-Id` headers round-trip untouched.
4. **Git push to declared remotes works.** `git push` against a 4. **Git push to declared remotes works.** `git push` against a
@@ -117,36 +118,41 @@ supported kinds (anthropic, github, gitea, npm):
`npm`), an optional `Url` (required for `gitea`, defaulted for `npm`), an optional `Url` (required for `gitea`, defaulted for
the others), and `TokenRef` (the name of a host env var the the others), and `TokenRef` (the name of a host env var the
CLI resolves at launch time). CLI resolves at launch time).
- **cred-proxy process.** Runs as root inside the agent - **cred-proxy sidecar.** Runs as its own container on the
container, listens on `127.0.0.1:<PORT>`. Holds the tokens in bottle's internal docker network with hostname `cred-proxy`,
its own environ — never on argv, never written to disk. 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-`Kind` route handler: inject the right header, forward Per-`Kind` route handler: inject the right header, forward
over TLS, stream the response back to the client without over TLS, stream the response back without buffering.
buffering.
- **Agent-side rewrites.** Provisioner writes: - **Agent-side rewrites.** Provisioner writes:
- `ANTHROPIC_BASE_URL=http://127.0.0.1:<PORT>/anthropic` to - `ANTHROPIC_BASE_URL=http://cred-proxy:<PORT>/anthropic` to
the agent's environ the agent's environ
- `~/.npmrc` `registry = http://127.0.0.1:<PORT>/npm/` - `~/.npmrc` `registry = http://cred-proxy:<PORT>/npm/`
- `~/.gitconfig` `[url …] insteadOf = …` for each declared - `~/.gitconfig` `[url …] insteadOf = …` for each declared
`github` / `gitea` upstream `github` / `gitea` upstream
- `~/.config/tea/config.yml` with the proxy URL for each - `~/.config/tea/config.yml` with the proxy URL for each
declared `gitea` entry declared `gitea` entry
- **Process lifecycle.** Container entrypoint launches the proxy - **Sidecar lifecycle.** Mirrors `DockerGitGate` /
first as root, waits for it to bind, then `exec setpriv … `DockerPipelockProxy` in shape: `prepare` is host-side and
--reuid=node --regid=node …` for the claude child. Proxy side-effect-free; `start` does `docker create` + `docker start`
death is fatal (the container exits); this is also the on the bottle's internal network with hostname `cred-proxy`;
PID-1-zombie story. `stop` is idempotent `docker rm -f`. Container name:
- **pipelock interop.** Drop `api.anthropic.com` from pipelock's `claude-bottle-cred-proxy-<slug>`. The agent container starts
TLS-MITM list; keep it on the allowlist as a plain HTTPS host after the sidecar is up so DNS resolution succeeds on the
(cred-proxy is the trust endpoint now). Verify pipelock still agent's first call.
lets cred-proxy's HTTPS connections out for the four upstream - **pipelock interop.** cred-proxy's outbound HTTPS still
hosts. traverses pipelock — pipelock keeps its egress-allowlist role
for the four upstream hosts. Drop `api.anthropic.com` from
pipelock's TLS-MITM list (cred-proxy is now the trust endpoint
for that host); the host stays on the plain HTTPS allowlist.
- **Plan rendering.** `bottle_plan.py` and the y/N preflight - **Plan rendering.** `bottle_plan.py` and the y/N preflight
show: which tokens are configured (kind + ref name, not the show: which tokens are configured (kind + ref name, not the
value), the proxy port, the routes the proxy will publish. value), the proxy port, the routes the proxy will publish.
- **Drop the existing `CLAUDE_CODE_OAUTH_TOKEN` forward in - **Drop the existing `CLAUDE_CODE_OAUTH_TOKEN` forward in
`prepare.py`.** Today it lands in the agent's environ; once `prepare.py`.** Today it lands in the agent's environ; once
this PRD ships, it lands in the proxy's environ instead. this PRD ships, it lands in the cred-proxy sidecar's environ
instead.
- **Tests.** Integration tests for each of the six success - **Tests.** Integration tests for each of the six success
criteria; unit tests for manifest parsing, route table criteria; unit tests for manifest parsing, route table
generation, header injection. generation, header injection.
@@ -175,22 +181,23 @@ supported kinds (anthropic, github, gitea, npm):
│ GITEA_SERVER_TOKEN, NPM_TOKEN │ │ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │ │ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │ │ ▼ │
│ ┌── Bottle container ────────────────────────────────────────┐ │ │ ┌── per-bottle internal docker network ──────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ ┌── UID 1000 (node) ─────────────────────────────────┐ │ │ │ │ ┌── agent container ─────────────────────────────────┐ │ │
│ │ │ claude --dangerously-skip-permissions │ │ │ │ │ │ claude as node (UID 1000) │ │ │
│ │ │ --dangerously-skip-permissions │ │ │
│ │ │ environ: URLs only, no plaintext tokens │ │ │ │ │ │ environ: URLs only, no plaintext tokens │ │ │
│ │ │ ANTHROPIC_BASE_URL=http://127.0.0.1:PORT/anth.. │ │ │ │ │ │ ANTHROPIC_BASE_URL=http://cred-proxy:PORT/an.. │ │ │
│ │ │ npm registry → http://127.0.0.1:PORT/npm/ │ │ │ │ │ │ npm registry → http://cred-proxy:PORT/npm/ │ │ │
│ │ │ git remote.url → http://127.0.0.1:PORT/... │ │ │ │ │ │ git insteadOf → http://cred-proxy:PORT/... │ │ │
│ │ │ tea --url → http://127.0.0.1:PORT/gitea │ │ │ │ │ │ tea --url → http://cred-proxy:PORT/gite │ │ │
│ │ └────────────┬───────────────────────────────────────┘ │ │ │ │ └────────────┬───────────────────────────────────────┘ │ │
│ │ │ plain HTTP, loopback │ │ │ │ │ HTTP, DNS → cred-proxy │ │
│ │ ▼ │ │ │ │ ▼ │ │
│ │ ┌── UID 0 (root) ────────────────────────────────────┐ │ │ │ │ ┌── cred-proxy sidecar ──────────────────────────────┐ │ │
│ │ │ cred-proxy listens 127.0.0.1:PORT │ │ │ │ │ │ distroless image, no shell, runs as root │ │ │
│ │ │ tokens live ONLY in this process's environ │ │ │ │ │ │ hostname: cred-proxy listens 0.0.0.0:PORT │ │ │
│ │ │ per-route: inject auth header, forward over TLS │ │ │ │ │ │ tokens live ONLY in this container's environ │ │ │
│ │ │ /anthropic → api.anthropic.com Bearer │ │ │ │ │ │ /anthropic → api.anthropic.com Bearer │ │ │
│ │ │ /gh-api → api.github.com Bearer │ │ │ │ │ │ /gh-api → api.github.com Bearer │ │ │
│ │ │ /gh-git → github.com Bearer │ │ │ │ │ │ /gh-git → github.com Bearer │ │ │
@@ -200,7 +207,7 @@ supported kinds (anthropic, github, gitea, npm):
│ │ └────────────┬───────────────────────────────────────┘ │ │ │ │ └────────────┬───────────────────────────────────────┘ │ │
│ │ │ HTTPS │ │ │ │ │ HTTPS │ │
│ │ ▼ │ │ │ │ ▼ │ │
│ │ ┌── pipelock (egress allowlist) ─────────────────────┐ │ │ │ │ ┌── pipelock sidecar (egress allowlist) ─────────────┐ │ │
│ │ │ allow: api.anthropic.com, api.github.com, │ │ │ │ │ │ allow: api.anthropic.com, api.github.com, │ │ │
│ │ │ github.com, gitea.dideric.is, │ │ │ │ │ │ github.com, gitea.dideric.is, │ │ │
│ │ │ registry.npmjs.org │ │ │ │ │ │ registry.npmjs.org │ │ │
@@ -213,35 +220,40 @@ supported kinds (anthropic, github, gitea, npm):
Upstream APIs Upstream APIs
Why node@1000 can't just steal the tokens: Why the agent can't reach the sidecar's environ:
┌─────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────────
node tries: Different container = different PID, mount, and network ns.
cat /proc/<cred-proxy-pid>/environ → EACCES The agent's /proc shows only the agent's own processes;
ptrace(PTRACE_ATTACH, <cred-proxy-pid>, ...) → EPERM the cred-proxy PID is not visible — no /proc/<X>/environ
Kernel's ptrace_may_access rejects: UID mismatch to read, no PID to ptrace, no shared memory.
and no CAP_SYS_PTRACE / CAP_PERFMON in the container.
└─────────────────────────────────────────────────────────┘ │ 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 ### New components
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy` - **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
+ `CredProxyPlan` dataclass. `prepare` is host-side and + `CredProxyPlan` dataclass. `prepare` is host-side and
side-effect-free on Docker; renders the route table and side-effect-free; renders the route table and resolves
resolves `TokenRef`s against host env. Mirrors the existing `TokenRef`s against host env. Mirrors the existing `GitGate` /
`GitGate` / `Pipelock` shape. `Pipelock` shape.
- **`claude_bottle/backend/docker/cred_proxy.py`** (new): - **`claude_bottle/backend/docker/cred_proxy.py`** (new):
`DockerCredProxy` concrete subclass. Bakes the proxy binary `DockerCredProxy` concrete subclass. `start` does
into the agent image; `start` writes the route table to a `docker create` on the bottle's internal network with hostname
mode-600 file under `stage_dir` and arranges the entrypoint `cred-proxy`, copies the route-table file into the container,
so the proxy boots first. then `docker start`. `stop` is idempotent `docker rm -f`.
Container name: `claude-bottle-cred-proxy-<slug>`.
- **`claude_bottle/backend/docker/provision/cred_proxy.py`** - **`claude_bottle/backend/docker/provision/cred_proxy.py`**
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`, (new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml` `~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
into the agent's home for each declared kind. into the agent's home for each declared kind — all pointing at
- **The proxy binary itself.** Bundled into the agent image at `http://cred-proxy:<PORT>/...`.
`/usr/local/libexec/cred-proxy`. See "External dependencies" - **cred-proxy image.** Minimal base + the proxy binary, no
for the language choice. 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 ### Existing code touched
@@ -251,14 +263,17 @@ Why node@1000 can't just steal the tokens:
carry multiple Urls). carry multiple Urls).
- **`claude_bottle/backend/docker/prepare.py`** — delete the - **`claude_bottle/backend/docker/prepare.py`** — delete the
`CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` branch `CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` branch
in the agent's forwarded env. The OAuth token now flows to in the agent's forwarded env. The OAuth token is forwarded
the proxy's environ via the cred-proxy lifecycle. into the cred-proxy sidecar's environ at sidecar `docker create`
time instead.
- **`claude_bottle/backend/docker/backend.py`** — instantiate - **`claude_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy`; thread its `prepare` / `start` / `stop` `DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`. through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy - **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
start before the cred-proxy provisioner runs (provisioner start/stop to the `ExitStack` alongside pipelock and git-gate;
writes URLs that reference the proxy port, so it must be up). the sidecar must be up before the agent container starts so
DNS resolution for `cred-proxy` succeeds on first contact.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new - **`claude_bottle/backend/docker/bottle_plan.py`** — new
`CredProxyPlan` field; preflight shows kind + ref name + `CredProxyPlan` field; preflight shows kind + ref name +
port + route table. port + route table.
@@ -330,14 +345,17 @@ The proxy binary. Two real options:
no new pip packages. Matches CLAUDE.md's "bash-first, low-deps" no new pip packages. Matches CLAUDE.md's "bash-first, low-deps"
posture. SSE pass-through is fiddly but doable. posture. SSE pass-through is fiddly but doable.
- **Go single binary** — cleaner SSE story, smaller runtime, - **Go single binary** — cleaner SSE story, smaller runtime,
one static binary baked into the image. New build dependency. one static binary in a scratch/distroless image. New build
dependency.
Default: Python, baked into the agent image. Reconsider in the Default: Python in a minimal `python:3.X-slim` image (or alpine
implementation PR if SSE behavior is troublesome under load. 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 No new Python packages. No DB. No admin API. The proxy's
configuration is a single mode-600 JSON file passed in via configuration is a single mode-600 JSON file copied into the
`/run/cred-proxy/routes.json`. sidecar at `docker create` time and read by the proxy at startup
from `/run/cred-proxy/routes.json`.
## Future work ## Future work
@@ -353,12 +371,51 @@ configuration is a single mode-600 JSON file passed in via
PATs with TTL), have the proxy mint a fresh per-session PATs with TTL), have the proxy mint a fresh per-session
child credential from a long-lived parent. child credential from a long-lived parent.
- **Smolmachines colocation.** Same packing question as - **Smolmachines colocation.** Same packing question as
pipelock / git-gate; the cred-proxy can sit inside the agent pipelock / git-gate; under a future microVM backend the
VM (current shape) or in a separate VM (stricter isolation, cred-proxy could share a VM with the agent (today's per-bottle
per-bottle TCP hop). Backend decision, not a manifest decision. 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 - **More kinds.** PyPI, Bun, cargo, Docker Hub. The routing
pattern generalizes; add as needed. 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 ## Open questions
- **Field name.** `bottle.tokens` is the working name. The - **Field name.** `bottle.tokens` is the working name. The
@@ -368,11 +425,11 @@ configuration is a single mode-600 JSON file passed in via
`bottle.cred_proxy`. Default: `bottle.tokens`. `bottle.cred_proxy`. Default: `bottle.tokens`.
- **Python vs Go for the proxy.** Default: Python, revisit - **Python vs Go for the proxy.** Default: Python, revisit
during implementation if SSE pass-through is unreliable. during implementation if SSE pass-through is unreliable.
- **Process inside the agent container vs sidecar container.** - **Sidecar image base.** Distroless (smallest, no shell — hardest
v1: inside (simpler lifecycle, no extra container; ptrace to debug), Python slim (debuggable, larger), or scratch + a
boundary is enough). The sidecar option becomes attractive statically-linked Go binary (smallest if Go). Default: whatever
only if we want a network-layer split between proxy and agent fits the chosen language with the smallest non-shell base;
on top of the UID split. revisit if debuggability bites during implementation.
- **Belt-and-braces on outbound telemetry.** Set - **Belt-and-braces on outbound telemetry.** Set
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and
`DISABLE_ERROR_REPORTING=1` in the agent's environ by `DISABLE_ERROR_REPORTING=1` in the agent's environ by