Files
bot-bottle/docs/prds/0010-cred-proxy.md
T
didericis 32b62cbacc
test / unit (pull_request) Successful in 13s
test / integration (pull_request) Successful in 23s
feat(cred_proxy)!: cred-proxy is the only Anthropic auth path
Removes the legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` -> `CLAUDE_CODE_OAUTH_TOKEN`
forward in prepare.py. Bottles that need claude-code to authenticate
must declare a cred_proxy route with role: "anthropic-base-url" — there
is no fallback that hands the token to the agent directly.

Drops the now-dead BottleSpec.forward_oauth_token field, the CLI
setter that read CLAUDE_BOTTLE_OAUTH_TOKEN from the host env at
prepare time, and the forward_oauth_token=False arg in the six
pipelock integration tests.

PRD 0010 and README updated; the dev ~/claude-bottle.json gains an
anthropic-base-url route so the implementer/researcher agents keep
working.

BREAKING: bottles previously relying on the implicit OAuth forward
will now produce an agent environ without any Anthropic credential.
Verified with --dry-run: a bottle with no anthropic-base-url route
yields env_names: [] (no token at all); a bottle that declares the
route yields ANTHROPIC_BASE_URL plus a non-secret placeholder for
CLAUDE_CODE_OAUTH_TOKEN.
2026-05-24 12:56:09 -04:00

29 KiB

PRD 0010: Credential proxy for agent-bound API tokens

  • Status: Draft
  • Author: didericis
  • Created: 2026-05-13

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 surveys the existing tools and concludes that a small claude-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_URLcred-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 claude-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: claude-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):                             │
│     CLAUDE_BOTTLE_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

  • claude_bottle/cred_proxy.py (new): abstract CredProxy
    • CredProxyPlan dataclass. prepare is host-side and side-effect-free; renders the route table and resolves TokenRefs against host env. Mirrors the existing GitGate / Pipelock shape.
  • claude_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: claude-bottle-cred-proxy-<slug>.
  • claude_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

  • claude_bottle/manifest.py — add CredProxyRoute, CredProxyConfig, Bottle.cred_proxy: CredProxyConfig. Parse
    • validate route shape, role enum, path uniqueness, singleton- role constraints.
  • claude_bottle/backend/docker/prepare.py — drop the legacy CLAUDE_BOTTLE_OAUTH_TOKENCLAUDE_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.
  • claude_bottle/backend/docker/backend.py — instantiate DockerCredProxy alongside DockerPipelockProxy and DockerGitGate; thread its prepare / start / stop through resolve_plan / launch.
  • claude_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.
  • claude_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}.
  • claude_bottle/pipelock.pypipelock_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.
  • claude-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

@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 CLAUDE.md's "bash-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. 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