7a38b8da23
Walks the current `docker run -e CLAUDE_CODE_OAUTH_TOKEN` flow, why claude can read the token trivially via its Bash tool, why no Linux primitive hides an env var from its own process, and why a root-owned localhost auth-injecting reverse proxy (paired with an egress allowlist) is the realistic mitigation. Documents `ANTHROPIC_BASE_URL` caveats (SSE, header passthrough, issue #36998, out-of-band traffic).
161 lines
8.6 KiB
Markdown
161 lines
8.6 KiB
Markdown
# 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. `cli.sh:526–528` — 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. `cli.sh:603–605` — 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](https://github.com/anthropics/claude-code/issues/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.
|
||
|
||
## Recommended path forward
|
||
|
||
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
|
||
|
||
- [Authentication — Claude Code Docs](https://code.claude.com/docs/en/authentication)
|
||
- [LLM gateway configuration — Claude Code Docs](https://code.claude.com/docs/en/llm-gateway)
|
||
- [Claude Code Environment Variables](https://code.claude.com/docs/en/env-vars)
|
||
- [GitHub issue #36998 — Interactive mode ignores ANTHROPIC_BASE_URL](https://github.com/anthropics/claude-code/issues/36998)
|
||
- [GitHub issue #11587 — Auth conflict: CLAUDE_CODE_OAUTH_TOKEN and apiKeyHelper](https://github.com/anthropics/claude-code/issues/11587)
|
||
- [proc_pid_environ(5) Linux manual page](https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html)
|
||
- [Documenting ptrace access mode checking — LWN.net](https://lwn.net/Articles/692203/)
|
||
- [StepSecurity — Claude Code Action outbound network analysis](https://www.stepsecurity.io/blog/anthropics-claude-code-action-security-how-to-secure-claude-code-in-github-actions-with-harden-runner)
|
||
- [Manage API key environment variables — Claude Help Center](https://support.claude.com/en/articles/12304248-manage-api-key-environment-variables-in-claude-code)
|