Skip to content

claude: 🐛 Fix default-home Keychain lookup and secure credential seeding - #8

Merged
yasyf merged 4 commits into
mainfrom
fix/default-home-keychain
Sep 17, 2026
Merged

yasyf merged 4 commits into
mainfrom
fix/default-home-keychain

Conversation

@yasyf

@yasyf yasyf commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Summary

Fix isolated claude -p calls on macOS with Keychain-only default-home credentials. spawnllm looked up Claude Code-credentials-<sha256(config home)[:8]>, but Claude Code stores default-home credentials under the bare Claude Code-credentials name.

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-hook call_llm through 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 (0777 minus 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_DIR takes precedence when defined. Defined-empty selects the bare name and default credentials path.
  • Otherwise, CLAUDE_CONFIG_DIR applies; unset or empty selects the bare name.
  • The digest is SHA-256 of the NFC-normalized value exactly as set, hex, first 8 characters — no trailing-slash trim, tilde expansion, or path resolution.
  • -custom-oauth is inserted when CLAUDE_CODE_CUSTOM_OAUTH_URL is set.
  • CLAUDE_SECURESTORAGE_CONFIG_DIR also relocates the credentials file path.

Tests

Conformance vectors cover each service-name and path case. Fake security scripts in Rust and Go answer only the exact expected argv. Permission tests observe file modes at creation.

uv run pytest passes 441, skips 2. cargo test --workspace --all-features passes, clippy and fmt are clean. go test ./... passes. A real isolated extract_sync succeeded 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 0600 token copy, behind because cleanup is exit-bound.

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.
@socket-security

socket-security Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​unicode-normalization@​0.1.25100100100100100

View full report

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.
@yasyf
yasyf merged commit b5bc712 into main Sep 17, 2026
17 checks passed
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