Files
bot-bottle/docs/research/oauth-token-exposure-to-claude.md
T
didericis cc5e772519
test / run tests/run_tests.py (push) Successful in 13s
docs: replace stale .sh paths with claude_bottle/*.py equivalents
Cleans up references to the pre-refactor bash layout (cli.sh,
lib/*.sh, scripts/*.sh) across README, Dockerfile, the pipelock PRD,
and research notes. Refreshes line numbers in the oauth-token note
against the current cli/start.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:27:25 -04:00

8.6 KiB
Raw Blame History

OAuth token exposure to claude inside the bottle

Research into whether CLAUDE_CODE_OAUTH_TOKEN — as currently forwarded into each claude-bottle container — is reachable by claude itself, what (if any) deeper-than-prompt mechanisms could hide it, and whether routing claude through an auth-injecting proxy is viable.

Summary

Yes, claude can read CLAUDE_CODE_OAUTH_TOKEN trivially today. There is no Linux primitive for "this env var exists in my process but I cannot read it"; the only credible boundary is to put the credential in a different process that claude cannot read. Default Docker enforces that boundary at the kernel level (a non-root process cannot read /proc/<root-pid>/environ), so a root-owned auth-injecting reverse proxy listening on 127.0.0.1 is a realistic design. Claude Code's ANTHROPIC_BASE_URL officially supports this routing pattern with bearer auth, with documented caveats around SSE, header passthrough, and out-of-band outbound traffic (telemetry, npm, etc.) that does not route through ANTHROPIC_BASE_URL at all.

How the token reaches claude today

  1. claude_bottle/cli/start.py (around line 237238) — host's CLAUDE_BOTTLE_OAUTH_TOKEN is exported into the launcher process as CLAUDE_CODE_OAUTH_TOKEN, then forwarded with docker run -e CLAUDE_CODE_OAUTH_TOKEN (no =value, so the value never lands on argv — good).
  2. claude_bottle/cli/start.py (around line 318325) — claude is launched via docker exec -it <container> claude …, which inherits the container PID 1's env, including the token.
  3. claude runs as node (UID 1000) with --dangerously-skip-permissions. Its Bash tool can run printenv CLAUDE_CODE_OAUTH_TOKEN, cat /proc/self/environ, node -e 'console.log(process.env)', etc. and capture the value into the conversation.

A prompt-injection vector — a poisoned skill, a malicious string in a file claude reads, or a hijacked MCP server — can extract the token and exfiltrate it through any allowed outbound channel. The local-vs-remote-agent-execution.md note already flags static long-lived tokens as the biggest credential risk; this is exactly that risk, present in the local topology today.

Hiding env vars "at a deeper level"

Linux has no primitive to mark an individual env var as unreadable to the process that holds it. Once a var is in a process's environ, the process and its descendants have full access. The deeper-level lever is process boundary, not env-var ACL: put the credential in a different process that claude cannot read.

Default Docker enforces this for you. The kernel's ptrace_may_access check rejects /proc/<pid>/environ reads when the caller's UID/GID don't match the target's and the caller lacks CAP_SYS_PTRACE or CAP_PERFMON. A node-uid claude process attempting to read a root-owned proxy's environ gets EACCES. Escape hatches are explicit and not used by claude-bottle: --cap-add=SYS_PTRACE, --cap-add=PERFMON, --privileged. Yama ptrace_scope is irrelevant here because it only relaxes the same-UID relationship check; the cross-UID UID-match requirement still blocks the read.

The apiKeyHelper setting in claude-code is not a boundary. The helper is invoked by claude's own process, so claude can just call it via Bash and capture stdout. Same trust domain.

The only credible designs:

  • Header-injecting reverse proxy — claude points at a localhost URL; proxy holds the credential; proxy adds Authorization: Bearer and forwards. (See next section.)
  • Network namespace + outbound proxy — claude runs with --network none and a unix-socket proxy that holds the credential and enforces an egress allowlist. Anthropic's secure-deployment docs describe this pattern; the existing research note on remote agents recommends adding it locally first as the highest-leverage change.
  • Don't ship the OAuth token at all — fall back to per-session login or short-lived tokens. Operationally heavier, and the long-lived OAuth token is the chosen design here precisely because it's portable across hosts (Keychain on macOS, file on Linux).

Proxy auth: viable, with caveats

Pattern:

  • Run a small reverse proxy as root inside the container, listening on 127.0.0.1:N (or a root-owned unix socket with SO_PEERCRED checks).
  • Set ANTHROPIC_BASE_URL=http://127.0.0.1:N (or the socket path) in claude's env. Claude as node only sees the URL, not the token.
  • The proxy injects Authorization: Bearer $TOKEN and forwards to https://api.anthropic.com.
  • Token lives only in the root proxy's env; node-uid claude cannot read /proc/<root-pid>/environ (kernel-enforced).

ANTHROPIC_BASE_URL is documented as routing for proxies/gateways, not just Bedrock/Vertex, and works alongside bearer auth. Confirmed gotchas:

  • SSE streaming: proxy must not buffer responses (nginx proxy_buffering off, or use a streaming-aware proxy). Claude Code uses SSE only — no websockets.

  • Forward anthropic-version, anthropic-beta, and X-Claude-Code-Session-Id untouched — stripping them breaks tool use / extended thinking / session aggregation.

  • GitHub issue #36998: interactive mode historically bypassed ANTHROPIC_BASE_URL for some startup calls (auth validation / org lookup), connecting directly to api.anthropic.com. Marked closed but verify with tcpdump or strace -e connect against the pinned 2.1.126 build before trusting the isolation.

  • Tool search (ENABLE_TOOL_SEARCH): disabled by default when ANTHROPIC_BASE_URL is non-Anthropic; re-enable explicitly if needed.

  • Out-of-band outbound traffic is the weak link. None of these route through ANTHROPIC_BASE_URL:

    • statsig.anthropic.com — telemetry (disable: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, DISABLE_TELEMETRY=1)
    • Sentry error reporting (disable: DISABLE_ERROR_REPORTING=1)
    • registry.npmjs.org, github.com, release-assets.githubusercontent.com — MCP installs and autoupdater
    • pypi.org, bun.sh — if the Bash tool installs Python or Bun packages during a session

    A hijacked claude could exfiltrate the captured token (or any other data) through these channels even with the proxy in place. Pair the proxy with an explicit egress allowlist (iptables / Docker network policy) for the full benefit.

  • Token refresh: claude setup-token issues a ~1-year token with no client-side refresh, so a static proxy value is fine.

  • No request signing / anti-replay on the Messages API; header rewriting is safe.

  • --bare mode does not read CLAUDE_CODE_OAUTH_TOKEN at all (only ANTHROPIC_API_KEY). Not relevant to the interactive flow claude-bottle ships, but worth noting if --bare is ever wired in.

In priority order:

  1. --network none + a localhost (or unix-socket) auth-injecting proxy that holds the token. Highest-leverage change: credential isolation and egress containment in one pass. Aligns with the recommendation already in local-vs-remote-agent-execution.md.
  2. Layer in CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 plus an explicit egress allowlist (api.anthropic.com only, plus the per-agent set of MCP / git / package-registry hosts) so a hijacked claude can't exfiltrate through statsig / Sentry / npm.

The current docker run -e CLAUDE_CODE_OAUTH_TOKEN pattern is fine for argv hygiene on the host, but inside the container the token is fully exposed to claude. The proxy pattern moves it across a real kernel-enforced boundary.

Sources