trailofbits / trailofbits/coop
Forward Claude Code OAuth credentials from host into the guest
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 243
- Forks
- 13
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 30
Description
Summary
Today coop forwards ANTHROPIC_API_KEY (via claude.api_key config or process env) into the guest, but users who authenticate Claude Code via the OAuth flow (claude /login) have no way to bring that session into the VM. They have to re-authenticate inside every guest, or switch to API-key billing they may not want.
Add a claude.oauth_token config field that resolves to CLAUDE_CODE_OAUTH_TOKEN in the guest, mirroring how claude.api_key resolves to ANTHROPIC_API_KEY.
Motivation
- Subscription users (Pro/Max) authenticate via OAuth, not API keys. Today they get no help from coop.
- The OAuth credential lives somewhere different on every host: macOS Keychain (
Claude Code-credentialsservice), Linux~/.claude/.credentials.json, or the long-lived output ofclaude setup-token. Thecmd:prefix already supported byclaude.api_keyis the right shape for resolving any of these. ~/.claude/.credentials.jsonis intentionally excluded from the staged config copy insrc/backend.rs:1258and should stay excluded — env-var forwarding keeps the secret off the guest disk.
Proposed approach
Config
Add oauth_token: Option<String> to ClaudeConfig in src/config.rs:447. Like api_key, it accepts either a literal value or a cmd: shell-out resolved at VM start.
[claude]
# Long-lived headless token from `claude setup-token`, stashed in Keychain
oauth_token = "cmd:security find-generic-password -s coop-claude-token -w"
Forwarding
Extend prepare_env_forwarding in src/backend.rs:856 with a parallel branch that calls resolve_cmd_value and sets CLAUDE_CODE_OAUTH_TOKEN in the EnvForward. SSH SendEnv carries it into the guest unchanged.
Precedence
If both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN reach the guest, the Claude CLI prefers the API key. Document this. Do not auto-suppress one when the other is set — let users decide which billing path they want.
Token sources and tradeoffs
Three credentials look interchangeable but aren't:
| Source | Lifetime | Refresh in guest | Suitable |
|---|---|---|---|
claude setup-token output |
Long, headless-purposed | N/A — long-lived | Recommended |
macOS Keychain accessToken |
~hours | Fails — guest has no writable credential store | Short sessions only |
Linux ~/.claude/.credentials.json accessToken |
~hours | Same as Keychain | Short sessions only |
The recommended setup is to run claude setup-token once, stash the result wherever the user keeps secrets (Keychain, password manager, plain env), and reference it via cmd:. Pipe directly into the store so the literal token never lands in shell history, e.g. claude setup-token | security add-generic-password -s coop-claude-token -w.
Out of scope
- Copying
~/.claude/.credentials.jsoninto the guest. Putting a refresh token on disk in a second location is a different security tradeoff and should be debated separately if proposed. - Auto-detecting and extracting tokens from Keychain / credentials.json without user config. Implicit credential reads on every VM start are exactly the kind of magic that turns into a supply-chain attack vector — explicit
cmd:is the right friction. - Changing
claude.api_keysemantics or precedence.
Security considerations
The design is sound — it reuses the env-forward + cmd: pattern that claude.api_key already establishes. The risks are all in implementation details, not the shape:
Patterns to keep (and treat as canonical for future secret-forwarding work):
- Env-var forwarding over disk staging — guest images get snapshotted, copied, and backed up; secrets must not ride along.
cmd:indirection over literal TOML values — TOML gets dotfile-synced, Debug-printed, and grepped.- Lazy resolution at VM-start, not config-parse — secret-manager prompts only fire when needed, and the resolved string lives in memory only for the duration of
prepare_env_forwarding. Do not memoize across instances. - Recommending
claude setup-tokenover access tokens — refresh-token-derived access tokens cannot refresh inside the guest (no writable credential store), so they silently expire mid-session and push users toward worse workarounds (paste-into-config, store-on-disk). - Keeping
~/.claude/.credentials.jsonexcluded — putting the refresh token on guest disk in a second location is strictly worse than env-var forwarding. - No precedence flip — let the Claude CLI's documented precedence apply, instead of coop suppressing one credential. Avoids surprise billing routes and surprise auth identities.
Pitfalls to avoid in implementation:
SendEnvvsSetEnv.SendEnv=FOOforwards from the SSH client's process env. If achieved by mutating coop's own process env, the secret leaks into anything else coop spawns. UseSetEnv=NAME=valueon the ssh CLI, or scope the env to the child ssh process only — neverstd::env::set_var. Add a test that asserts the host process env is unchanged afterprepare_env_forwarding.- Guest
AcceptEnvallowlist. AddCLAUDE_CODE_OAUTH_TOKENexplicitly. Do not take the shortcut ofAcceptEnv *— that turns the guest into a sink for arbitrary host env. - Debug/log leakage.
ClaudeConfigderivesDebug(src/config.rs:447); literaloauth_tokenvalues would be printed verbatim. Either custom Debug that redacts, or explicitly document the risk and push hard towardcmd:. Also:resolve_cmd_valueincludes command stderr in error messages (src/config.rs:63-71) — a misbehavingcmd:script that echoes the token to stderr would land it in error output. Document that resolver scripts must not emit the secret on stderr. - No tracing of values. Mirror the existing
tracing::debug!("Resolving secret via command")— no field name, no length, no truncated value. cmd:=sh -c, i.e. arbitrary code execution. Each newcmd:-resolved field grows the blast radius of a malicious or compromised config file. Acceptable today (the config is user-owned), but the moment coop ever auto-loads project-localcoop.toml,cmd:resolution must be gated on an explicit user-level trust decision. Worth a tripwire comment near the resolver for future contributors.- Claude CLI may persist the token guest-side. Verify empirically — if
claudewrites~/.claude/.credentials.jsoninside the guest after first auth, the host's no-disk policy is moot in practice. Either accept it (guest disk = trust boundary) and document it, or provide a stop-time scrub. Do not claim "never written to disk" without checking. - Test fixtures must not look like real tokens. Use obvious fakes (
cmd:echo fake-oauth) so secret scanners and pre-commit detectors do not flag the repo and so regex scrapes cannot false-positive.
Acceptance criteria
-
claude.oauth_tokenconfig field, supports literal values andcmd:resolution -
CLAUDE_CODE_OAUTH_TOKENforwarded into the guest without mutating coop's host process env (test asserts this) - Guest sshd
AcceptEnvallowlist explicitly includesCLAUDE_CODE_OAUTH_TOKEN(noAcceptEnv *) - Resolved token never appears in tracing/log output at any level, and is not printed by
DebugonClaudeConfig - Documented in README / config reference, including the access-token-vs-setup-token caveat and the pipe-into-secret-store recommendation
- Documented behavior of the Claude CLI inside the guest re: on-disk persistence of the OAuth token (verified empirically)
- Unit tests mirroring the
api_keycoverage (resolution, empty/failure paths), using obvious-fake token strings
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with ClaudeConfig in src/config.rs:447 and the existing api_key handling, then trace prepare_env_forwarding in src/backend.rs:856 and the staged-config exclusion near src/backend.rs:1258. Mirror the existing api_key tests and inspect the guest SSH AcceptEnv configuration. Done means safe OAuth forwarding, redacted diagnostics, documentation, and tests for literal, command, empty, failure, and host-environment behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- cli, infrastructure, security
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100