Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Isolated Claude runs now receive the OAuth access token through
CLAUDE_CODE_OAUTH_TOKEN. They no longer seed a.credentials.jsonthat 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_seedcopied the full credential JSON into a 0600 file under$TMPDIR/spawnllm-claude-config-*. Pythonatexit, Go deferred cleanup, and RustTempDirDrop 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_sourcesop takesclaude_code_oauth_token_env. When it is set and non-empty, the op returnscredentials_path: nullandkeychain_service: null. All three hosts accept the nullable path and skip both the credential file read and thesecuritycall. 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.accessTokenfromcredentials_jsonand returns it in anenvmap besidefiles. The only seeded file is the account pointer.claude.json, withmcpServersremoved and mode 0644. Malformed credentials JSON returns an error; credentials withoutclaudeAiOauthproduce 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 ininvocation()andenv(). Go returns it fromseedClaudeIsolationand merges it withmaps.Copy. Rust carries it alongsideTempDirinIsolationand applies it withcmd.envs. Each host already appliedRunSpec.envlast, 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-17release heading, with the 0.13.2 and 0.13.3 compare links added.Tests
oauth-token-env-darwinandoauth-token-env-empty-darwin; 13 existing sources vectors gained the new null input field..credentials.jsonis seeded. Fake Keychain credentials use the real JSON shape.not jsonand a Keychain lookup that must not run. These tests cover bypassing credential sources when the process supplies the token.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.CLAUDE_CODE_OAUTH_TOKENfrom the test environment, including through Go'sclearHostEnvhelper and Python's autouse fixture. The suites also pass withCLAUDE_CODE_OAUTH_TOKEN=review-sentinelexported.test_killed_run_leaves_no_token_on_disk: a child interpreter runsClaudeCliBackend().executewith a sleeping fake CLI under a privateTMPDIR. After SIGKILL, the config directory remains, but no file underTMPDIRcontains the token. The marker is published with write-then-mvso readers cannot see a partial write, and the parent is killed infinally.cargo test --workspacepassed, Clippy reported 0 warnings, andcargo fmt --checkpassed;go vetandgo test ./...passed;uv run pytestpassed with two environment skips. Ruff and ty were unchanged, with 21 pre-existing ty diagnostics.With Claude Code 2.1.274,
claude -p --model haikuauthenticated using the Keychain access token in the environment and an empty config directory. It exited 0, wrote no.credentials.jsonor token bytes under the directory, and created no Keychain item for its hash. An invalid token exited 1 withFailed 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.envtoken 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
expiresAtcheck 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.