claude: 🐛 Fix default-home Keychain lookup and secure credential seeding - #8
Merged
Merged
Conversation
Context: every isolated `claude` run on a machine whose default home keeps its claude.ai token only in the macOS Keychain started without a token and failed with `Not logged in · Please run /login` after paying for the spawn. capt-hook's `extract_sync` calls all take this path. Summary: the core's `claude_isolation_sources` names the bare `Claude Code-credentials` item when `CLAUDE_CONFIG_DIR` is unset and the `-<sha256(CLAUDE_CONFIG_DIR)[:8]>` suffixed one only when it is set. Motivation: Claude Code hashes only an explicit `CLAUDE_CONFIG_DIR`, even one naming the default path; `~/.claude` with the variable unset reads the bare item. spawnllm suffixed the default home too, looked up an item that never exists, and seeded no credentials, so the isolated dir had an account pointer and nothing to sign with. Evidence: the bare item is present and the suffixed default-home item absent on a logged-in machine, `claude auth status` reports logged in with the variable unset and not logged in with it set to the same path, and the `claude -p` argv spawnllm builds returns rc=1 under an isolated dir seeded the old way and rc=0 under the default home. Details: the vector `claude_isolation_sources/default-home-darwin` pins the bare name; the custom-dir vectors are unchanged. The Python suite covers both homes with the `security` argv asserted and the seeded credentials file checked for content and mode; nothing about how or where the secret is written changes.
…nal modes Context: the security review of c902333 found that the Rust host created the isolated config dir with the process umask and each seeded file at 0644, wrote the token, and only then chmodded it to 0600. With the default-home Keychain lookup now succeeding, every isolated run passes a token through that window. Summary: the Rust and Python hosts create the dir 0700 and each seeded file with the mode the core assigns, `O_EXCL`, before writing any byte; the Go host already created files with their mode and keeps its `os.WriteFile`. Motivation: a file that is world-readable between create and chmod is readable by another local user on a shared tmp for that window, and a run killed inside it leaves the token readable for good. Creating with the mode closes the window entirely; there is no state in which the file exists with a looser mode. Details: `tempfile::Builder::permissions(0o700)` replaces the default tempdir mode, and `OpenOptions::mode(bits).create_new(true)` replaces `File::create` plus `set_permissions`; Python uses `os.open` with the mode and `O_EXCL` in place of `write_text` plus `chmod`. Rust unit tests observe the dir and the empty file right after creation; the Rust and Go fake `claude` report the dir and credentials modes they see, and the Python suite wraps `os.open` to record each file's mode and size at creation. The file-source Python test now asserts modes and fails on any Keychain call.
Context: the security review of c902333 found the core trimmed trailing slashes from CLAUDE_CONFIG_DIR before hashing it, while Claude Code hashes the value as set, so a caller under `/x/` looked up a digest Claude Code never writes. The reviewer read the derivation out of the installed CLI and the rest of its inputs came from the same bytes. Summary: the core mirrors Claude Code 2.1.274's `lb()`/`mI()`: a defined CLAUDE_SECURESTORAGE_CONFIG_DIR overrides CLAUDE_CONFIG_DIR for both the credentials file dir and the digest, an empty value of either reads the bare item, the digest is sha256 of the NFC value as set with no trim, expansion or resolve, and a set CLAUDE_CODE_CUSTOM_OAUTH_URL inserts `-custom-oauth` after `Claude Code`. Hosts pass the two new variables through, defined-vs-undefined preserved. Motivation: any divergence from the CLI's rule is a Keychain miss and a tokenless isolated run, or a foreign item when the wrong digest happens to exist. Evidence, read as raw bytes from the `__BUN` section of /Users/yasyf/.local/share/claude/versions/2.1.274 (sha256 91d82856…761725), nothing executed: - [169220606,169220714): `we` = NFC(CLAUDE_CONFIG_DIR ?? join(homedir, ".claude")). - [170928095,170928723): `lb()` picks CLAUDE_SECURESTORAGE_CONFIG_DIR when defined (empty falls to join(homedir, ".claude")), else `we()`; `mI()` suffixes `-<sha256(r).hex[:8]>` unless the chosen variable is empty or CLAUDE_CONFIG_DIR is unset, naming `Claude Code${OAUTH_FILE_SUFFIX}-credentials`. - [170936808,170936900): the credentials file is join(lb(), ".credentials.json"). - [169301176,169301776) and [169304237,169304937): `c()` is the build constant "prod", whose config sets OAUTH_FILE_SUFFIX ""; `Xt()` swaps in "-custom-oauth" when CLAUDE_CODE_CUSTOM_OAUTH_URL is set. - [170928749,170928900): `Cv()` passes `-a "$USER"`; every item here carries that account and each service name has one item, so spawnllm's lookup without `-a` reads the same item and stays as it was. Details: `unicode-normalization` joins the core for the NFC pass; the blob grows from 443,318 to 565,619 bytes for its tables. Vectors: `config-dir-env-trailing-slash-darwin` now expects the slashed digest, and new cases pin the empty, explicit-default, NFD-input, securestorage set, securestorage empty over config dir, securestorage over config dir with a trailing slash, custom-oauth and custom-oauth-empty inputs, each digest cross-checked against Python's hashlib and unicodedata. The Rust and Go fake `security` answer only the exact `find-generic-password -s <service> -w` argv for the service a test names and record what they received; Rust, Go and Python each cover unset, empty, set, trailing slash, explicit default, securestorage precedence, custom oauth and Keychain miss.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Context: PR #8 was red on the Linux runners. The fake `claude` read the isolated dir and credentials modes with `stat -f '%Lp'`, which GNU stat takes as filesystem status and answers with a block of mount details ahead of the modes, and errcheck flagged the unchecked `os.Unsetenv` in the Go Keychain table test. Summary: both fakes report the POSIX `ls -ld` permission string instead, and the Go test checks the unset's error. Motivation: the mode assertion has to hold on macOS and Linux runners alike; `ls -ld | cut -c1-10` yields `drwx------` and `-rw-------` on both, with no platform-specific stat flags. The unset keeps `t.Setenv`'s restore and fails the test on the error it used to drop. Details: the Rust and Go assertions compare the permission strings. The Rust isolation tests pass in a `rust:1.97.1-slim-bookworm` container as well as on macOS; golangci-lint reports no issues.
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
Fix isolated
claude -pcalls on macOS with Keychain-only default-home credentials. spawnllm looked upClaude Code-credentials-<sha256(config home)[:8]>, but Claude Code stores default-home credentials under the bareClaude Code-credentialsname.The bug affected spawnllm 0.5.x and later. Every caller using a Keychain-only default home failed with
Not logged in · Please run /login, including every capt-hookcall_llmthrough the Claude backend, which incurred a ~2.4s spawn before failing.Fix
c902333 uses the bare Keychain item when no config-dir override is set.
34438da creates the Rust host's isolated directory with mode
0700. Both Rust and Python now create seeded files with their final mode,create_new/O_EXCL, before writing any bytes. Previously, Rust used default directory permissions (0777minus umask) and wrote files before chmod, briefly exposing credentials in shared tmp storage with a permissive umask. Go already created files with their mode.33d8673 matches Claude Code 2.1.274's service-name and credentials-path behavior, verified by reading the installed binary's embedded source without running it:
CLAUDE_SECURESTORAGE_CONFIG_DIRtakes precedence when defined. Defined-empty selects the bare name and default credentials path.CLAUDE_CONFIG_DIRapplies; unset or empty selects the bare name.-custom-oauthis inserted whenCLAUDE_CODE_CUSTOM_OAUTH_URLis set.CLAUDE_SECURESTORAGE_CONFIG_DIRalso relocates the credentials file path.Tests
Conformance vectors cover each service-name and path case. Fake
securityscripts in Rust and Go answer only the exact expected argv. Permission tests observe file modes at creation.uv run pytestpasses 441, skips 2.cargo test --workspace --all-featurespasses, clippy and fmt are clean.go test ./...passes. A real isolatedextract_syncsucceeded with exit code 0.Tradeoffs and remaining work
Unicode normalization tables increased the wasm core blob by ~120 KB.
Tracked separately: a process killed by signal leaves its isolated directory, including its
0600token copy, behind because cleanup is exit-bound.