Skip to content

claude: πŸ”’οΈ Pass isolated OAuth tokens through the child environment - #9

Merged
yasyf merged 3 commits into
mainfrom
token-env
Sep 17, 2026
Merged

yasyf merged 3 commits into
mainfrom
token-env

Conversation

@yasyf

@yasyf yasyf commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Isolated Claude runs now receive the OAuth access token through CLAUDE_CODE_OAUTH_TOKEN. They no longer seed a .credentials.json that can survive cleanup or become a persistent Keychain copy. A non-empty process token takes precedence over stored credentials, and Python resolves the token on every call so the next run can use renewed credentials.

Previously, claude_isolation_seed copied the full credential JSON into a 0600 file under $TMPDIR/spawnllm-claude-config-*. Python atexit, Go deferred cleanup, and Rust TempDir Drop cannot remove it when the parent is killed before cleanup, including at a Claude Code hook timeout. This machine had 77 leaked directories, including a token copy from September 14. PR #8 listed this as remaining work.

Verification also found a second leak: Claude Code migrated seeded credentials into Keychain items named Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[:8]>. The login Keychain held 345 such items; the earliest sampled item was created on September 9, 2026. Those copies outlived the temporary directories.

Fix

The core's claude_isolation_sources op takes claude_code_oauth_token_env. When it is set and non-empty, the op returns credentials_path: null and keychain_service: null. All three hosts accept the nullable path and skip both the credential file read and the security call. The seed carries no token, so the child inherits the process token. An unset or empty value still resolves stored credentials.

For stored credentials, the shared core parses claudeAiOauth.accessToken from credentials_json and returns it in an env map beside files. The only seeded file is the account pointer .claude.json, with mcpServers removed and mode 0644. Malformed credentials JSON returns an error; credentials without claudeAiOauth produce no token, as with a Keychain miss.

All three hosts apply the seed environment to the child. Python carries it in ClaudeIsolation(config_dir, env) and merges it in invocation() and env(). Go returns it from seedClaudeIsolation and merges it with maps.Copy. Rust carries it alongside TempDir in Isolation and applies it with cmd.envs. Each host already applied RunSpec.env last, so an explicit per-run override continues to win.

Python now caches only _isolated_config_dir. Source resolution, credential reads, and seed computation run on every call. The config directory stays the same, while a renewed Keychain token reaches the next run of the same backend.

The changelog records the security fix under Unreleased, including token precedence and per-run resolution. The already shipped 0.13.3 changes get their 2026-09-17 release heading, with the 0.13.2 and 0.13.3 compare links added.

Tests

  • Regenerated 5 isolation seed vectors and replayed them through Python and Go against the rebuilt wasm blob. Added oauth-token-env-darwin and oauth-token-env-empty-darwin; 13 existing sources vectors gained the new null input field.
  • Updated host isolation tests to assert that the child receives the access token in its environment and no .credentials.json is seeded. Fake Keychain credentials use the real JSON shape.
  • Added inherited-token tests in Python, Go, and Rust with stored credentials containing not json and a Keychain lookup that must not run. These tests cover bypassing credential sources when the process supplies the token.
  • Added test_env_resolves_the_token_on_every_call: the same Python backend sees credentials rewritten between two calls, returns the renewed token, and reuses the config directory.
  • Isolation tests clear CLAUDE_CODE_OAUTH_TOKEN from the test environment, including through Go's clearHostEnv helper and Python's autouse fixture. The suites also pass with CLAUDE_CODE_OAUTH_TOKEN=review-sentinel exported.
  • Added test_killed_run_leaves_no_token_on_disk: a child interpreter runs ClaudeCliBackend().execute with a sleeping fake CLI under a private TMPDIR. After SIGKILL, the config directory remains, but no file under TMPDIR contains the token. The marker is published with write-then-mv so readers cannot see a partial write, and the parent is killed in finally.
  • cargo test --workspace passed, Clippy reported 0 warnings, and cargo fmt --check passed; go vet and go test ./... passed; uv run pytest passed with two environment skips. Ruff and ty were unchanged, with 21 pre-existing ty diagnostics.

With Claude Code 2.1.274, claude -p --model haiku authenticated using the Keychain access token in the environment and an empty config directory. It exited 0, wrote no .credentials.json or token bytes under the directory, and created no Keychain item for its hash. An invalid token exited 1 with Failed to authenticate. API Error: 401 OAuth access token is invalid.

Tradeoffs and remaining work

Claude Code does not refresh a user-supplied environment token on 401. An expired access token therefore fails explicitly; a running Claude Code session keeps the source Keychain token current. Previously, the isolated child received the refresh token and stored refreshed credentials under the temporary directory's hashed Keychain item.

With only a per-run RunSpec.env token override, malformed stored credentials JSON still raises an error before the override is applied. The error reports the corrupt stored credential.

There is no pre-spawn expiresAt check because the child's 401 already reports authentication failure. Directory cleanup still depends on normal exit, but isolation seeding no longer places a credential in that directory.

The 345 existing Keychain items and leaked directories require operator cleanup. This change prevents new copies; it does not remove existing ones.

Context: Isolated Claude runs copied the full credential JSON into a
temporary config directory. Signal-killed parents bypassed cleanup, and
Claude Code migrated the seeded file into a persistent Keychain item named
after the temporary directory.

Summary: Extract the access token in the shared core and return it as
CLAUDE_CODE_OAUTH_TOKEN in the seed env map. Pass that map to the child in
Python, Go, and Rust, seeding only the account pointer on disk.

Motivation: Prevent credentials from surviving isolated runs in leaked
temporary directories or accumulating as Keychain copies. Authentication
must not depend on exit handlers removing a secret file.

Details: Preserve mcpServers stripping, update 5 conformance vectors and
host isolation tests, and add a Python SIGKILL regression. Record the
security fix and restore the 0.13.3 changelog heading and compare links.
Expired tokens fail with an explicit 401; existing leaked credentials remain
for operator cleanup.

Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5
Context: Review of 161b85b found that the seed environment overwrote a host
CLAUDE_CODE_OAUTH_TOKEN and Python cached the resolved token for the
backend's lifetime.

Summary: Skip credential sources in the core when the host token is
non-empty. Make credentials_path nullable in all 3 hosts. Cache only
Python's config directory, resolving sources, credentials, and the seed
environment on every call.

Motivation: Honor Claude Code's environment-token precedence and let
long-lived Python backends pick up renewed credentials. RunSpec.env already
applies last in all 3 hosts and continues to override the child environment.

Details: Add 2 sources vectors, update 13 existing vectors, and cover
inherited tokens in all 3 hosts plus Python token renewal. Clear ambient
tokens in isolation tests, publish the SIGKILL marker with write-then-mv,
kill the parent in finally, and extend the Security changelog entry. Keep
malformed stored JSON errors, expiry handling, and operator cleanup
unchanged.

Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5
Context: the Security entry sat under Unreleased while this branch cuts
v0.13.4 the moment it merges.

Summary: retitle the entry as 0.13.4 dated today and add its compare link.

Motivation: 0.13.3 shipped with its entries under Unreleased and had to be
relabelled after the fact; the heading lands with the release this time.

Details: the Unreleased compare link now spans from v0.13.4.

Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5
@yasyf
yasyf merged commit b716ba9 into main Sep 17, 2026
15 checks passed
yasyf added a commit to yasyf/captain-hook that referenced this pull request Sep 17, 2026
…no token on disk

Context: spawnllm 0.13.3 and earlier ran their isolated `claude -p` calls against a temp config directory and wrote a `.credentials.json` copy of the OAuth token into it for the child to read. Every spawn killed before its cleanup left that copy on disk, and Claude Code migrated each one into a login-Keychain item. One machine carried 345.

Summary: `spawnllm>=0.13.2,<0.14` becomes `spawnllm>=0.13.4,<0.14`, and `uv lock` moves the pin from 0.13.2 to 0.13.4.

Motivation: the `<0.14` ceiling already admitted 0.13.4, so a fresh resolve picked the fix up while an existing lock stayed on 0.13.2. Raising the floor makes the fixed version the only resolution, which is what a security fix needs.

Details: spawnllm 0.13.4 (yasyf/spawnllm#9) passes the token through `CLAUDE_CODE_OAUTH_TOKEN`, so there is nothing on disk for a kill to strand. capt-hook drives those spawns from `captain_hook/context.py` and `captain_hook/review/pipeline.py`, on every LLM hook and every review, and a hook whose caller's deadline abandons it is killed by design. The lock diff carries spawnllm's own version and artifacts and no transitive dependency change.
yasyf added a commit to yasyf/captain-hook that referenced this pull request Sep 17, 2026
…no token on disk (#121)

Context: spawnllm 0.13.3 and earlier ran their isolated `claude -p` calls against a temp config directory and wrote a `.credentials.json` copy of the OAuth token into it for the child to read. Every spawn killed before its cleanup left that copy on disk, and Claude Code migrated each one into a login-Keychain item. One machine carried 345.

Summary: `spawnllm>=0.13.2,<0.14` becomes `spawnllm>=0.13.4,<0.14`, and `uv lock` moves the pin from 0.13.2 to 0.13.4.

Motivation: the `<0.14` ceiling already admitted 0.13.4, so a fresh resolve picked the fix up while an existing lock stayed on 0.13.2. Raising the floor makes the fixed version the only resolution, which is what a security fix needs.

Details: spawnllm 0.13.4 (yasyf/spawnllm#9) passes the token through `CLAUDE_CODE_OAUTH_TOKEN`, so there is nothing on disk for a kill to strand. capt-hook drives those spawns from `captain_hook/context.py` and `captain_hook/review/pipeline.py`, on every LLM hook and every review, and a hook whose caller's deadline abandons it is killed by design. The lock diff carries spawnllm's own version and artifacts and no transitive dependency change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant