From c902333e0522330a95e8008d3dcf3956a9f3e370 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Wed, 16 Sep 2026 19:24:38 -0700 Subject: [PATCH 1/4] =?UTF-8?q?claude:=20=F0=9F=90=9B=20read=20the=20bare?= =?UTF-8?q?=20Keychain=20item=20for=20the=20default=20config=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `-` 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. --- CHANGELOG.md | 12 +++++++++ .../default-home-darwin.json | 2 +- rust/spawnllm-core/src/isolate.rs | 18 ++++++++----- tests/test_backends.py | 26 +++++++++++++++++-- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8b818..08e1ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Isolated `claude` runs from the default config home find the Keychain + token again.** With `CLAUDE_CONFIG_DIR` unset, Claude Code stores the + claude.ai token under the bare `Claude Code-credentials` Keychain item and + suffixes the name with `-` only when the + variable is set, even when it names the default path. The isolation seed + looked up the suffixed name for the default home too, so on a machine with + no `~/.claude/.credentials.json` file every isolated run started without a + token and failed with `Not logged in · Please run /login`. The core now + hands the bare name to the host for the default home and the suffixed one + for a set `CLAUDE_CONFIG_DIR`. + ## [0.13.2] - 2026-09-14 ### Fixed diff --git a/conformance/vectors/claude_isolation_sources/default-home-darwin.json b/conformance/vectors/claude_isolation_sources/default-home-darwin.json index 9caae05..53bcb89 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-darwin.json +++ b/conformance/vectors/claude_isolation_sources/default-home-darwin.json @@ -11,6 +11,6 @@ "expected": { "account_path": "/Users/testuser/.claude.json", "credentials_path": "/Users/testuser/.claude/.credentials.json", - "keychain_service": "Claude Code-credentials-1cc69f60" + "keychain_service": "Claude Code-credentials" } } diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index b27b0ba..306ff06 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -79,20 +79,26 @@ impl Formatter for PythonFormatter { fn isolation_sources(input: IsolationSourcesInput) -> IsolationSources { let host = input.host; - let (account_path, config_home) = match host.claude_config_dir_env { + // Claude Code suffixes the item only when CLAUDE_CONFIG_DIR is set, even to the + // default path; the unset default home reads the bare item. + let (account_path, config_home, keychain_suffix) = match host.claude_config_dir_env { Some(config_home) => { let config_home = config_home.trim_end_matches('/').to_owned(); - (format!("{config_home}/.claude.json"), config_home) + let digest = format!("{:x}", Sha256::digest(config_home.as_bytes())); + ( + format!("{config_home}/.claude.json"), + config_home, + format!("-{}", &digest[..8]), + ) } None => ( format!("{}/.claude.json", host.home), format!("{}/.claude", host.home), + String::new(), ), }; - let keychain_service = (host.platform == "darwin").then(|| { - let digest = format!("{:x}", Sha256::digest(config_home.as_bytes())); - format!("Claude Code-credentials-{}", &digest[..8]) - }); + let keychain_service = + (host.platform == "darwin").then(|| format!("Claude Code-credentials{keychain_suffix}")); IsolationSources { account_path, credentials_path: format!("{config_home}/.credentials.json"), diff --git a/tests/test_backends.py b/tests/test_backends.py index f105bbd..5bbcdcf 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -319,7 +319,30 @@ def test_env_seeds_from_claude_config_dir_over_home(self, tmp_path: Path, monkey "claudeAiOauth": {"accessToken": "acct-tok"} } - def test_env_falls_back_to_keychain_for_credentials(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_env_default_home_falls_back_to_the_bare_keychain_item( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "c"}})) + (tmp_path / ".claude").mkdir() + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> object: + calls.append(argv) + return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() + + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) + config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + assert calls == [["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]] + credentials = config_dir / ".credentials.json" + assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "kc-tok"}} + assert credentials.stat().st_mode & 0o777 == 0o600 + + def test_env_config_dir_falls_back_to_the_suffixed_keychain_item( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: (account_home := tmp_path / "acct").mkdir() monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_home)) (account_home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "c"}})) @@ -332,7 +355,6 @@ def fake_run(argv: list[str], **kwargs: object) -> object: monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) - # The service name hashes the effective home path, matching the CLI's Keychain item. digest = hashlib.sha256(str(account_home).encode()).hexdigest()[:8] assert calls == [["security", "find-generic-password", "-s", f"Claude Code-credentials-{digest}", "-w"]] credentials = config_dir / ".credentials.json" From 34438dad907fbffd7047829f0723984d3a33136a Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Wed, 16 Sep 2026 19:46:31 -0700 Subject: [PATCH 2/4] =?UTF-8?q?claude:=20=F0=9F=94=92=EF=B8=8F=20create=20?= =?UTF-8?q?the=20isolated=20config=20dir=20and=20its=20files=20with=20thei?= =?UTF-8?q?r=20final=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 8 ++++ go/exec_test.go | 5 +++ go/testdata/bin/claude | 11 +++++- rust/spawnllm/src/isolate.rs | 63 +++++++++++++++++++++++++------ rust/spawnllm/tests/common/mod.rs | 1 + rust/spawnllm/tests/isolation.rs | 8 ++++ spawnllm/backends/claude.py | 6 +-- tests/test_backends.py | 32 +++++++++++++++- 8 files changed, 117 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e1ad3..16b0dc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 token and failed with `Not logged in · Please run /login`. The core now hands the bare name to the host for the default home and the suffixed one for a set `CLAUDE_CONFIG_DIR`. +- **The isolated config dir and its credentials file are owner-only from the + moment they exist.** The Rust host created the temp dir with the process + umask (0755 under the usual 022) and every seeded file at 0644, wrote the + token, and only then chmodded it to 0600, so another local user on a shared + tmp could read the token in that window, and a run killed inside it left + the file readable. The Python host wrote the file before its chmod too. Both + now create the dir 0700 and each file with its final mode, `O_EXCL`, before + writing a byte; the Go host already did. ## [0.13.2] - 2026-09-14 diff --git a/go/exec_test.go b/go/exec_test.go index c416823..a8f35f0 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -26,6 +26,8 @@ type claudeOutput struct { Seeded bool `json:"seeded"` AccountHasMCP bool `json:"account_has_mcp"` CredsPresent bool `json:"creds_present"` + ConfigDirMode string `json:"config_dir_mode"` + CredsMode string `json:"creds_mode"` } func TestClaudeStdinAndStdoutFile(t *testing.T) { @@ -312,6 +314,9 @@ func TestClaudeIsolationSeeding(t *testing.T) { if !out.CredsPresent { t.Fatal("isolated config dir was not seeded with .credentials.json") } + if out.ConfigDirMode != "700" || out.CredsMode != "600" { + t.Fatalf("isolated config dir mode %q, credentials mode %q; want 700 and 600", out.ConfigDirMode, out.CredsMode) + } if _, err := os.Stat(out.ConfigDir); !os.IsNotExist(err) { t.Fatalf("isolated config dir was not cleaned up: stat err = %v", err) } diff --git a/go/testdata/bin/claude b/go/testdata/bin/claude index 96341d2..0abf15f 100755 --- a/go/testdata/bin/claude +++ b/go/testdata/bin/claude @@ -18,7 +18,13 @@ config_dir="${CLAUDE_CONFIG_DIR:-}" seeded=false account_has_mcp=false creds_present=false +config_dir_mode="" +creds_mode="" +mode_of() { + stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" +} if [ -n "$config_dir" ]; then + config_dir_mode="$(mode_of "$config_dir")" if [ -f "$config_dir/.claude.json" ]; then seeded=true if grep -q mcpServers "$config_dir/.claude.json"; then @@ -27,6 +33,7 @@ if [ -n "$config_dir" ]; then fi if [ -f "$config_dir/.credentials.json" ]; then creds_present=true + creds_mode="$(mode_of "$config_dir/.credentials.json")" fi fi @@ -43,5 +50,5 @@ if [ -n "$FAKE_TRANSIENT_COUNTER" ]; then fi fi -printf '{"type":"result","is_error":false,"result":"%s","stdout_regular":%s,"config_dir":"%s","seeded":%s,"account_has_mcp":%s,"creds_present":%s}\n' \ - "$prompt" "$stdout_regular" "$config_dir" "$seeded" "$account_has_mcp" "$creds_present" +printf '{"type":"result","is_error":false,"result":"%s","stdout_regular":%s,"config_dir":"%s","seeded":%s,"account_has_mcp":%s,"creds_present":%s,"config_dir_mode":"%s","creds_mode":"%s"}\n' \ + "$prompt" "$stdout_regular" "$config_dir" "$seeded" "$account_has_mcp" "$creds_present" "$config_dir_mode" "$creds_mode" diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index 0b04d89..505efe0 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -1,3 +1,4 @@ +use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; #[cfg(any(target_os = "macos", test))] @@ -56,19 +57,26 @@ pub(crate) async fn seed_isolation() -> Result { json!({ "account_json": account_json, "credentials_json": credentials_json }), )?; - let dir = tempfile::Builder::new() - .prefix("spawnllm-claude-config-") - .tempdir()?; + let dir = private_tempdir()?; for file in &seed.files { - let path = dir.path().join(&file.name); - let mut handle = std::fs::File::create(&path)?; + let mut handle = create_with_mode(&dir.path().join(&file.name), &file.mode)?; handle.write_all(file.content.as_bytes())?; handle.flush()?; - set_mode(&path, &file.mode)?; } Ok(dir) } +fn private_tempdir() -> std::io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix("spawnllm-claude-config-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + builder.tempdir() +} + async fn keychain_credentials(service: &str) -> Option { #[cfg(target_os = "macos")] { @@ -100,16 +108,20 @@ async fn timed_command_output( } #[cfg(unix)] -fn set_mode(path: &Path, mode: &str) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; +fn create_with_mode(path: &Path, mode: &str) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; let bits = u32::from_str_radix(mode, 8).expect("core emits octal file modes"); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits)) + OpenOptions::new() + .write(true) + .create_new(true) + .mode(bits) + .open(path) } #[cfg(not(unix))] -fn set_mode(_path: &Path, _mode: &str) -> std::io::Result<()> { - Ok(()) +fn create_with_mode(path: &Path, _mode: &str) -> std::io::Result { + OpenOptions::new().write(true).create_new(true).open(path) } #[cfg(test)] @@ -118,6 +130,35 @@ mod tests { use super::*; + #[cfg(unix)] + #[test] + fn private_tempdir_is_owner_only_at_creation() { + use std::os::unix::fs::PermissionsExt; + + let dir = private_tempdir().unwrap(); + + assert_eq!( + dir.path().metadata().unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + + #[cfg(unix)] + #[test] + fn create_with_mode_applies_the_mode_before_any_byte_is_written() { + use std::os::unix::fs::PermissionsExt; + + let dir = private_tempdir().unwrap(); + let path = dir.path().join(".credentials.json"); + let mut handle = create_with_mode(&path, "0600").unwrap(); + let created = path.metadata().unwrap(); + + assert_eq!(created.len(), 0); + assert_eq!(created.permissions().mode() & 0o777, 0o600); + handle.write_all(b"{}").unwrap(); + assert!(create_with_mode(&path, "0600").is_err()); + } + #[tokio::test] async fn timed_command_output_returns_none_on_spawn_failure() { let mut command = tokio::process::Command::new("spawnllm-command-that-does-not-exist"); diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index 6fa3c38..d33fab6 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -42,6 +42,7 @@ if [ -n "$SPAWNLLM_FAKE_MARKER" ]; then fi if [ -n "$SPAWNLLM_FAKE_CRED_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.credentials.json" > "$SPAWNLLM_FAKE_CRED_OUT" 2>/dev/null || true; fi if [ -n "$SPAWNLLM_FAKE_ACCOUNT_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.claude.json" > "$SPAWNLLM_FAKE_ACCOUNT_OUT" 2>/dev/null || true; fi +if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { stat -f '%Lp' "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json" 2>/dev/null || stat -c '%a' "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json"; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi if [ -n "$SPAWNLLM_FAKE_EXIT" ]; then printf 'boom' >&2; exit "$SPAWNLLM_FAKE_EXIT"; fi if [ -n "$SPAWNLLM_FAKE_SLEEP" ]; then sleep "$SPAWNLLM_FAKE_SLEEP"; fi if [ -n "$SPAWNLLM_FAKE_COUNTER" ]; then diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index c3a0c9b..bab92b1 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -47,13 +47,16 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { let cred_out = tempfile::NamedTempFile::new().unwrap(); let account_out = tempfile::NamedTempFile::new().unwrap(); + let modes_out = tempfile::NamedTempFile::new().unwrap(); let cred_path = cred_out.path().to_str().unwrap().to_owned(); let account_path = account_out.path().to_str().unwrap().to_owned(); + let modes_path = modes_out.path().to_str().unwrap().to_owned(); set_config_dir(source.path()); let spec = RunSpec::new("hi", "haiku").env(env(&[ ("SPAWNLLM_FAKE_CRED_OUT", &cred_path), ("SPAWNLLM_FAKE_ACCOUNT_OUT", &account_path), + ("SPAWNLLM_FAKE_MODES_OUT", &modes_path), ])); let response = spawnllm::run_on(&Backend::Claude, spec).await; clear_config_dir(); @@ -63,6 +66,11 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { std::fs::read_to_string(&cred_path).unwrap(), r#"{"token": "abc"}"# ); + assert_eq!( + std::fs::read_to_string(&modes_path).unwrap(), + "700\n600\n", + "config dir mode then credentials file mode" + ); let account: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&account_path).unwrap()).unwrap(); assert!( diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index 80c118e..d2b689c 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -94,9 +94,9 @@ def claude_isolation(self) -> str: ) config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-")) for file in seed["files"]: - path = config_dir / file["name"] - path.write_text(file["content"]) - path.chmod(int(file["mode"], 8)) + fd = os.open(config_dir / file["name"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, int(file["mode"], 8)) + with os.fdopen(fd, "w") as handle: + handle.write(file["content"]) atexit.register(shutil.rmtree, config_dir, ignore_errors=True) self._isolated_config_dir = str(config_dir) return self._isolated_config_dir diff --git a/tests/test_backends.py b/tests/test_backends.py index 5bbcdcf..f9c7917 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -3,6 +3,7 @@ import dataclasses import hashlib import json +import os from collections.abc import Callable from pathlib import Path @@ -296,16 +297,45 @@ def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monke ) (tmp_path / ".claude").mkdir() (tmp_path / ".claude" / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "tok"}}') + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", self.fail_on_keychain_call) backend = ClaudeCliBackend() env = backend.env(RunSpec(prompt="hi", model="haiku")) config_dir = Path(env["CLAUDE_CONFIG_DIR"]) assert config_dir.is_dir() + assert config_dir.stat().st_mode & 0o777 == 0o700 assert backend.env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"] == str(config_dir) # The token is substituted in place of the plan's ${isolated_config_dir} placeholder. assert "${isolated_config_dir}" not in env["CLAUDE_CONFIG_DIR"] # The account pointer is seeded sans host mcpServers; the OAuth token comes along. assert json.loads((config_dir / ".claude.json").read_text()) == {"oauthAccount": {"accountUuid": "a"}} - assert json.loads((config_dir / ".credentials.json").read_text()) == {"claudeAiOauth": {"accessToken": "tok"}} + credentials = config_dir / ".credentials.json" + assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "tok"}} + assert credentials.stat().st_mode & 0o777 == 0o600 + + @staticmethod + def fail_on_keychain_call(argv: list[str], **kwargs: object) -> object: + raise AssertionError(f"unexpected Keychain call: {argv}") + + def test_env_seeds_each_file_with_its_mode_before_writing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "a"}})) + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "tok"}}') + real_open = os.open + created: list[tuple[str, int, int]] = [] + + def observing_open(path: str | os.PathLike[str], flags: int, mode: int = 0o777) -> int: + fd = real_open(path, flags, mode) + stat = os.stat(fd) + created.append((Path(path).name, stat.st_mode & 0o777, stat.st_size)) + return fd + + monkeypatch.setattr("spawnllm.backends.claude.os.open", observing_open) + ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) + assert created == [(".claude.json", 0o644, 0), (".credentials.json", 0o600, 0)] def test_env_seeds_from_claude_config_dir_over_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HOME", str(tmp_path / "home")) From 33d86737731365358ce2c6ddec16f723503817c1 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Wed, 16 Sep 2026 20:05:47 -0700 Subject: [PATCH 3/4] =?UTF-8?q?claude:=20=F0=9F=90=9B=20name=20the=20Keych?= =?UTF-8?q?ain=20item=20exactly=20as=20Claude=20Code=202.1.274=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `-` 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 -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. --- CHANGELOG.md | 11 + .../config-dir-env-darwin.json | 4 +- .../config-dir-env-decomposed-darwin.json | 18 ++ .../config-dir-env-default-path-darwin.json | 18 ++ .../config-dir-env-empty-darwin.json | 18 ++ .../config-dir-env-linux.json | 4 +- .../config-dir-env-trailing-slash-darwin.json | 6 +- .../custom-oauth-url-darwin.json | 18 ++ .../custom-oauth-url-empty-darwin.json | 18 ++ .../default-home-darwin.json | 4 +- .../default-home-linux.json | 4 +- .../securestorage-env-darwin.json | 18 ++ ...rage-env-empty-over-config-dir-darwin.json | 18 ++ ...urestorage-env-over-config-dir-darwin.json | 18 ++ go/coreops.go | 15 +- go/exec_test.go | 142 +++++++++++ go/testdata/bin/security | 23 +- rust/Cargo.lock | 11 + rust/conformance-gen/src/cases.rs | 121 +++++++-- rust/spawnllm-core/Cargo.toml | 1 + rust/spawnllm-core/src/isolate.rs | 57 +++-- rust/spawnllm/Cargo.toml | 1 + rust/spawnllm/src/isolate.rs | 2 + rust/spawnllm/tests/common/mod.rs | 9 +- rust/spawnllm/tests/isolation.rs | 237 +++++++++++++++++- spawnllm/backends/claude.py | 2 + tests/test_backends.py | 79 +++++- 27 files changed, 808 insertions(+), 69 deletions(-) create mode 100644 conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b0dc7..8eff2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the file readable. The Python host wrote the file before its chmod too. Both now create the dir 0700 and each file with its final mode, `O_EXCL`, before writing a byte; the Go host already did. +- **The Keychain service name follows Claude Code 2.1.274's rule exactly.** + The core trimmed trailing slashes before hashing `CLAUDE_CONFIG_DIR`, but + Claude Code hashes the NFC form of the variable exactly as set, so `/x/` + named a different item than Claude Code wrote and the run started without + a token. The digest now covers the value as set, NFC-normalized; only the + filesystem paths joined under it are trimmed. A defined + `CLAUDE_SECURESTORAGE_CONFIG_DIR` takes over both the credentials file + location and the digest (empty means the default home and the bare item), + and a set `CLAUDE_CODE_CUSTOM_OAUTH_URL` selects the + `Claude Code-custom-oauth-credentials` items, as they do in Claude Code. + An empty `CLAUDE_CONFIG_DIR` reads the bare item, as an unset one does. ## [0.13.2] - 2026-09-14 diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json index c47c7d0..5acda06 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json @@ -5,7 +5,9 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": "/Users/testuser/.acct" + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json new file mode 100644 index 0000000..06cc355 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-decomposed-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/résumé", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/résumé/.claude.json", + "credentials_path": "/Users/testuser/résumé/.credentials.json", + "keychain_service": "Claude Code-credentials-767ccf48" + } +} diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json new file mode 100644 index 0000000..ff515b3 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-default-path-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.claude", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.claude/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials-1cc69f60" + } +} diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json new file mode 100644 index 0000000..8e681f7 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-empty-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials" + } +} diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json index 68f21c2..4a50449 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json @@ -5,7 +5,9 @@ "host": { "platform": "linux", "home": "/home/testuser", - "claude_config_dir_env": "/home/testuser/.acct" + "claude_config_dir_env": "/home/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json index fd7c1c7..43e244b 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json @@ -5,12 +5,14 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": "/Users/testuser/.acct/" + "claude_config_dir_env": "/Users/testuser/.acct/", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { "account_path": "/Users/testuser/.acct/.claude.json", "credentials_path": "/Users/testuser/.acct/.credentials.json", - "keychain_service": "Claude Code-credentials-c157f0be" + "keychain_service": "Claude Code-credentials-101a62ee" } } diff --git a/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json new file mode 100644 index 0000000..219bc9e --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "custom-oauth-url-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": "https://oauth.example.test" + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-custom-oauth-credentials" + } +} diff --git a/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json b/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json new file mode 100644 index 0000000..2792ae8 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "custom-oauth-url-empty-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": "" + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.acct/.credentials.json", + "keychain_service": "Claude Code-credentials-c157f0be" + } +} diff --git a/conformance/vectors/claude_isolation_sources/default-home-darwin.json b/conformance/vectors/claude_isolation_sources/default-home-darwin.json index 53bcb89..c9d6057 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-darwin.json +++ b/conformance/vectors/claude_isolation_sources/default-home-darwin.json @@ -5,7 +5,9 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": null + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/default-home-linux.json b/conformance/vectors/claude_isolation_sources/default-home-linux.json index 2296f57..e06aed3 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-linux.json +++ b/conformance/vectors/claude_isolation_sources/default-home-linux.json @@ -5,7 +5,9 @@ "host": { "platform": "linux", "home": "/home/testuser", - "claude_config_dir_env": null + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json new file mode 100644 index 0000000..92a2c9a --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": "/Users/testuser/.secure", + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.secure/.credentials.json", + "keychain_service": "Claude Code-credentials-205e9e3d" + } +} diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json new file mode 100644 index 0000000..b021c7b --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-empty-over-config-dir-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": "", + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials" + } +} diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json new file mode 100644 index 0000000..426dad6 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-over-config-dir-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": "/Users/testuser/.secure/", + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.secure/.credentials.json", + "keychain_service": "Claude Code-credentials-ea269d8c" + } +} diff --git a/go/coreops.go b/go/coreops.go index ecd9bfd..6a1688a 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -3,6 +3,7 @@ package spawnllm import ( "encoding/json" "fmt" + "os" "github.com/yasyf/spawnllm/go/internal/core" ) @@ -197,10 +198,22 @@ func coreAuthProbes(provider Provider) (authProbes, error) { } func coreIsolationSources() (isolationSources, error) { - host := map[string]any{"platform": platform(), "home": home(), "claude_config_dir_env": nil} + host := map[string]any{ + "platform": platform(), + "home": home(), + "claude_config_dir_env": nil, + "claude_securestorage_config_dir_env": nil, + "claude_code_custom_oauth_url_env": nil, + } if dir := configDirEnv(); dir != "" { host["claude_config_dir_env"] = dir } + if dir, defined := os.LookupEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR"); defined { + host["claude_securestorage_config_dir_env"] = dir + } + if url, defined := os.LookupEnv("CLAUDE_CODE_CUSTOM_OAUTH_URL"); defined { + host["claude_code_custom_oauth_url_env"] = url + } return coreInto[isolationSources]("claude_isolation_sources", struct { Host map[string]any `json:"host"` }{Host: host}) diff --git a/go/exec_test.go b/go/exec_test.go index a8f35f0..b5dedee 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -2,10 +2,13 @@ package spawnllm import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -322,6 +325,145 @@ func TestClaudeIsolationSeeding(t *testing.T) { } } +func suffixedKeychainService(configDirEnv string) string { + digest := sha256.Sum256([]byte(configDirEnv)) + return "Claude Code-credentials-" + hex.EncodeToString(digest[:])[:8] +} + +func keychainSeededRun(t *testing.T, service string) (claudeOutput, string) { + t.Helper() + if runtime.GOOS != "darwin" { + t.Skip("the Keychain fallback runs only on darwin") + } + withFakeBin(t) + argvOut := filepath.Join(t.TempDir(), "argv") + t.Setenv("FAKE_SECURITY_ARGV_OUT", argvOut) + t.Setenv("FAKE_KEYCHAIN_SERVICE", service) + t.Setenv("FAKE_KEYCHAIN_CREDENTIAL", `{"claudeAiOauth":{"accessToken":"kc-tok"}}`) + + resp, err := RunOn(context.Background(), ClaudeBackend(), RunSpec{Prompt: "iso", Model: "haiku"}) + if err != nil { + t.Fatalf("RunOn: %v", err) + } + if resp.Err != nil { + t.Fatalf("unexpected provider error: %v", resp.Err) + } + var out claudeOutput + if err := json.Unmarshal([]byte(resp.Output), &out); err != nil { + t.Fatalf("decode output %q: %v", resp.Output, err) + } + argv, err := os.ReadFile(argvOut) + if err != nil { + t.Fatalf("fake security recorded no argv: %v", err) + } + return out, string(argv) +} + +func writeAccountPointer(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".claude.json"), []byte(`{"oauthAccount":{"accountUuid":"a"}}`), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestClaudeIsolationKeychain(t *testing.T) { + cases := []struct { + name string + env func(home, acct string) map[string]string + service func(home, acct string) string + }{ + { + name: "unset env reads the bare item", + env: func(string, string) map[string]string { return nil }, + service: func(string, string) string { return "Claude Code-credentials" }, + }, + { + name: "set env reads the suffixed item", + env: func(_, acct string) map[string]string { return map[string]string{"CLAUDE_CONFIG_DIR": acct} }, + service: func(_, acct string) string { return suffixedKeychainService(acct) }, + }, + { + name: "trailing slash is hashed as set", + env: func(_, acct string) map[string]string { return map[string]string{"CLAUDE_CONFIG_DIR": acct + "/"} }, + service: func(_, acct string) string { return suffixedKeychainService(acct + "/") }, + }, + { + name: "env naming the default path is still suffixed", + env: func(home, _ string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude")} + }, + service: func(home, _ string) string { return suffixedKeychainService(filepath.Join(home, ".claude")) }, + }, + { + name: "empty securestorage env reads the bare item over config dir env", + env: func(_, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": ""} + }, + service: func(string, string) string { return "Claude Code-credentials" }, + }, + { + name: "securestorage env is hashed over config dir env", + env: func(home, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": home + "/secure"} + }, + service: func(home, _ string) string { return suffixedKeychainService(home + "/secure") }, + }, + { + name: "custom oauth url names the custom-oauth item", + env: func(_, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example.test"} + }, + service: func(_, acct string) string { + return strings.Replace(suffixedKeychainService(acct), "Claude Code-credentials", "Claude Code-custom-oauth-credentials", 1) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + acct := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + writeAccountPointer(t, home) + writeAccountPointer(t, acct) + for _, name := range []string{"CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"} { + t.Setenv(name, "") + os.Unsetenv(name) + } + for name, value := range tc.env(home, acct) { + t.Setenv(name, value) + } + service := tc.service(home, acct) + + out, argv := keychainSeededRun(t, service) + + if want := "find-generic-password\n-s\n" + service + "\n-w\n"; argv != want { + t.Fatalf("security argv = %q, want %q", argv, want) + } + if !out.CredsPresent || out.CredsMode != "600" { + t.Fatalf("keychain credentials seeded = %v with mode %q; want seeded at 600", out.CredsPresent, out.CredsMode) + } + }) + } +} + +func TestClaudeIsolationKeychainMissSeedsNoCredentials(t *testing.T) { + acct := t.TempDir() + writeAccountPointer(t, acct) + t.Setenv("CLAUDE_CONFIG_DIR", acct) + + out, argv := keychainSeededRun(t, "Claude Code-credentials-someone-else") + + if want := "find-generic-password\n-s\n" + suffixedKeychainService(acct) + "\n-w\n"; argv != want { + t.Fatalf("security argv = %q, want %q", argv, want) + } + if out.CredsPresent { + t.Fatal("a Keychain miss must seed no credentials file") + } +} + type echoResult struct { Echo string `json:"echo"` } diff --git a/go/testdata/bin/security b/go/testdata/bin/security index 864e0be..7faebf7 100755 --- a/go/testdata/bin/security +++ b/go/testdata/bin/security @@ -1,20 +1,19 @@ #!/bin/sh -# Fake macOS `security` for spawnllm integration tests. Answers -# find-generic-password: -w prints FAKE_KEYCHAIN_CREDENTIAL (empty exits 1, a -# keychain miss), and the account-existence probe exits per FAKE_KEYCHAIN_EXISTS. -want_w=false -for arg in "$@"; do - if [ "$arg" = "-w" ]; then - want_w=true - fi -done +# Fake macOS `security` for spawnllm integration tests. Records its argv to +# FAKE_SECURITY_ARGV_OUT. A password read (`find-generic-password -s -w`) +# prints FAKE_KEYCHAIN_CREDENTIAL only for the service FAKE_KEYCHAIN_SERVICE names +# (anything else exits 44, a keychain miss); the account-existence probe exits per +# FAKE_KEYCHAIN_EXISTS. +if [ -n "$FAKE_SECURITY_ARGV_OUT" ]; then + printf '%s\n' "$@" >"$FAKE_SECURITY_ARGV_OUT" +fi -if [ "$want_w" = true ]; then - if [ -n "$FAKE_KEYCHAIN_CREDENTIAL" ]; then +if [ "$#" -eq 4 ] && [ "$1" = "find-generic-password" ] && [ "$2" = "-s" ] && [ "$4" = "-w" ]; then + if [ -n "$FAKE_KEYCHAIN_CREDENTIAL" ] && [ "$3" = "$FAKE_KEYCHAIN_SERVICE" ]; then printf '%s' "$FAKE_KEYCHAIN_CREDENTIAL" exit 0 fi - exit 1 + exit 44 fi if [ "${FAKE_KEYCHAIN_EXISTS:-0}" = "1" ]; then diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 02673c9..95e944d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -959,6 +959,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2", "spawnllm-core", "tempfile", "thiserror", @@ -973,6 +974,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "unicode-normalization", ] [[package]] @@ -1202,6 +1204,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 81657f4..8460f07 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -1024,20 +1024,39 @@ fn capabilities_cases() -> Vec { }] } -fn iso_sources_case( - name: &str, - platform: &str, - home: &str, - claude_config_dir_env: Option<&str>, -) -> Case { +struct IsoHost { + platform: &'static str, + home: &'static str, + config_dir_env: Option<&'static str>, + securestorage_config_dir_env: Option<&'static str>, + custom_oauth_url_env: Option<&'static str>, +} + +const ISO_DARWIN: IsoHost = IsoHost { + platform: "darwin", + home: HOME_DARWIN, + config_dir_env: None, + securestorage_config_dir_env: None, + custom_oauth_url_env: None, +}; + +const ISO_LINUX: IsoHost = IsoHost { + platform: "linux", + home: HOME_LINUX, + ..ISO_DARWIN +}; + +fn iso_sources_case(name: &str, host: IsoHost) -> Case { Case { op: "claude_isolation_sources", name: name.to_owned(), input: json!({ "host": { - "platform": platform, - "home": home, - "claude_config_dir_env": claude_config_dir_env, + "platform": host.platform, + "home": host.home, + "claude_config_dir_env": host.config_dir_env, + "claude_securestorage_config_dir_env": host.securestorage_config_dir_env, + "claude_code_custom_oauth_url_env": host.custom_oauth_url_env, } }), } @@ -1045,25 +1064,87 @@ fn iso_sources_case( fn iso_sources_cases() -> Vec { vec![ - iso_sources_case("default-home-darwin", "darwin", HOME_DARWIN, None), - iso_sources_case("default-home-linux", "linux", HOME_LINUX, None), + iso_sources_case("default-home-darwin", ISO_DARWIN), + iso_sources_case("default-home-linux", ISO_LINUX), iso_sources_case( "config-dir-env-darwin", - "darwin", - HOME_DARWIN, - Some("/Users/testuser/.acct"), + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + ..ISO_DARWIN + }, ), iso_sources_case( "config-dir-env-trailing-slash-darwin", - "darwin", - HOME_DARWIN, - Some("/Users/testuser/.acct/"), + IsoHost { + config_dir_env: Some("/Users/testuser/.acct/"), + ..ISO_DARWIN + }, ), iso_sources_case( "config-dir-env-linux", - "linux", - HOME_LINUX, - Some("/home/testuser/.acct"), + IsoHost { + config_dir_env: Some("/home/testuser/.acct"), + ..ISO_LINUX + }, + ), + iso_sources_case( + "config-dir-env-empty-darwin", + IsoHost { + config_dir_env: Some(""), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "config-dir-env-default-path-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.claude"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "config-dir-env-decomposed-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/re\u{0301}sume\u{0301}"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-darwin", + IsoHost { + securestorage_config_dir_env: Some("/Users/testuser/.secure"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-empty-over-config-dir-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + securestorage_config_dir_env: Some(""), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-over-config-dir-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + securestorage_config_dir_env: Some("/Users/testuser/.secure/"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "custom-oauth-url-darwin", + IsoHost { + custom_oauth_url_env: Some("https://oauth.example.test"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "custom-oauth-url-empty-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + custom_oauth_url_env: Some(""), + ..ISO_DARWIN + }, ), ] } diff --git a/rust/spawnllm-core/Cargo.toml b/rust/spawnllm-core/Cargo.toml index 9db0f82..3e20ab6 100644 --- a/rust/spawnllm-core/Cargo.toml +++ b/rust/spawnllm-core/Cargo.toml @@ -12,3 +12,4 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] } regex-lite = "0.1" sha2 = "0.10" +unicode-normalization = "0.1" diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index 306ff06..d6fe7bb 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::ser::Formatter; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; use crate::{OpError, OpResult, from_input, unimplemented}; @@ -17,6 +18,10 @@ struct IsolationHost { platform: String, home: String, claude_config_dir_env: Option, + #[serde(default)] + claude_securestorage_config_dir_env: Option, + #[serde(default)] + claude_code_custom_oauth_url_env: Option, } #[derive(Debug, Serialize)] @@ -77,31 +82,55 @@ impl Formatter for PythonFormatter { } } +fn is_set(value: &Option) -> bool { + value.as_deref().is_some_and(|value| !value.is_empty()) +} + +fn config_dir_digest(config_dir_env: &str) -> String { + let digest = format!( + "{:x}", + Sha256::digest(config_dir_env.nfc().collect::().as_bytes()) + ); + format!("-{}", &digest[..8]) +} + +// Mirrors Claude Code 2.1.274's lb()/mI(): a defined CLAUDE_SECURESTORAGE_CONFIG_DIR +// overrides CLAUDE_CONFIG_DIR for the storage dir and the digest of the NFC value as set. fn isolation_sources(input: IsolationSourcesInput) -> IsolationSources { let host = input.host; - // Claude Code suffixes the item only when CLAUDE_CONFIG_DIR is set, even to the - // default path; the unset default home reads the bare item. - let (account_path, config_home, keychain_suffix) = match host.claude_config_dir_env { - Some(config_home) => { - let config_home = config_home.trim_end_matches('/').to_owned(); - let digest = format!("{:x}", Sha256::digest(config_home.as_bytes())); + let default_home = format!("{}/.claude", host.home); + let (account_path, config_home) = match &host.claude_config_dir_env { + Some(config_dir_env) if !config_dir_env.is_empty() => { + let config_home = config_dir_env.trim_end_matches('/'); ( format!("{config_home}/.claude.json"), - config_home, - format!("-{}", &digest[..8]), + config_home.to_owned(), ) } + _ => (format!("{}/.claude.json", host.home), default_home.clone()), + }; + let (credentials_home, hashed_dir) = match &host.claude_securestorage_config_dir_env { + Some(dir) if dir.is_empty() => (default_home, None), + Some(dir) => (dir.trim_end_matches('/').to_owned(), Some(dir.as_str())), None => ( - format!("{}/.claude.json", host.home), - format!("{}/.claude", host.home), - String::new(), + config_home, + host.claude_config_dir_env + .as_deref() + .filter(|dir| !dir.is_empty()), ), }; - let keychain_service = - (host.platform == "darwin").then(|| format!("Claude Code-credentials{keychain_suffix}")); + let oauth_file_suffix = if is_set(&host.claude_code_custom_oauth_url_env) { + "-custom-oauth" + } else { + "" + }; + let keychain_service = (host.platform == "darwin").then(|| { + let digest = hashed_dir.map(config_dir_digest).unwrap_or_default(); + format!("Claude Code{oauth_file_suffix}-credentials{digest}") + }); IsolationSources { account_path, - credentials_path: format!("{config_home}/.credentials.json"), + credentials_path: format!("{credentials_home}/.credentials.json"), keychain_service, } } diff --git a/rust/spawnllm/Cargo.toml b/rust/spawnllm/Cargo.toml index fc03f95..83e1120 100644 --- a/rust/spawnllm/Cargo.toml +++ b/rust/spawnllm/Cargo.toml @@ -30,6 +30,7 @@ tokio = { version = "1", features = ["process", "time", "rt", "rt-multi-thread", serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1" +sha2 = "0.10" tempfile = "3" [package.metadata.docs.rs] diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index 505efe0..f7bb791 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -40,6 +40,8 @@ pub(crate) async fn seed_isolation() -> Result { "platform": platform(), "home": home(), "claude_config_dir_env": std::env::var("CLAUDE_CONFIG_DIR").ok().filter(|value| !value.is_empty()), + "claude_securestorage_config_dir_env": std::env::var("CLAUDE_SECURESTORAGE_CONFIG_DIR").ok(), + "claude_code_custom_oauth_url_env": std::env::var("CLAUDE_CODE_CUSTOM_OAUTH_URL").ok(), } }), )?; diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index d33fab6..5f7fb74 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -82,10 +82,11 @@ fi "#; const SECURITY_FAKE: &str = r#"#!/bin/sh -for a in "$@"; do - if [ "$a" = "-w" ]; then printf 'keychain-token-xyz'; exit 0; fi -done -exit 1 +if [ -n "$SPAWNLLM_FAKE_SECURITY_ARGV_OUT" ]; then printf '%s\n' "$@" > "$SPAWNLLM_FAKE_SECURITY_ARGV_OUT"; fi +if [ "$1" = "find-generic-password" ] && [ "$2" = "-s" ] && [ "$3" = "$SPAWNLLM_FAKE_KEYCHAIN_SERVICE" ] && [ "$4" = "-w" ] && [ $# -eq 4 ]; then + printf 'keychain-token-xyz'; exit 0 +fi +exit 44 "#; /// Materialize the fake CLIs once and prepend their dir to `PATH`; returns the dir. diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index bab92b1..229aa1d 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -121,10 +121,66 @@ async fn empty_claude_config_dir_uses_the_default_home() { ); } +#[cfg(target_os = "macos")] +fn suffixed_keychain_service(config_dir_env: &str) -> String { + use sha2::{Digest, Sha256}; + + let digest = format!("{:x}", Sha256::digest(config_dir_env.as_bytes())); + format!("Claude Code-credentials-{}", &digest[..8]) +} + +#[cfg(target_os = "macos")] +const HOST_KEYCHAIN_VARS: [&str; 3] = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_SECURESTORAGE_CONFIG_DIR", + "CLAUDE_CODE_CUSTOM_OAUTH_URL", +]; + +#[cfg(target_os = "macos")] +async fn keychain_seeded_run( + host_env: &[(&str, &str)], + keychain_service: Option<&str>, +) -> (String, String) { + let cred_out = tempfile::NamedTempFile::new().unwrap(); + let argv_out = tempfile::NamedTempFile::new().unwrap(); + let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let argv_path = argv_out.path().to_str().unwrap().to_owned(); + + // SAFETY: gated by ENV_LOCK, held by the caller; see set_config_dir. + unsafe { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + for (var, value) in host_env { + std::env::set_var(var, value); + } + match keychain_service { + Some(service) => std::env::set_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE", service), + None => std::env::remove_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE"), + } + std::env::set_var("SPAWNLLM_FAKE_SECURITY_ARGV_OUT", &argv_path); + } + let spec = RunSpec::new("hi", "haiku").env(env(&[("SPAWNLLM_FAKE_CRED_OUT", &cred_path)])); + let response = spawnllm::run_on(&Backend::Claude, spec).await; + unsafe { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + std::env::remove_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE"); + std::env::remove_var("SPAWNLLM_FAKE_SECURITY_ARGV_OUT"); + } + + response.outcome.expect("isolated claude run succeeds"); + ( + std::fs::read_to_string(&cred_path).unwrap(), + std::fs::read_to_string(&argv_path).unwrap(), + ) +} + #[cfg(target_os = "macos")] #[allow(clippy::await_holding_lock)] #[tokio::test] -async fn isolation_falls_back_to_the_keychain_for_credentials() { +async fn config_dir_env_falls_back_to_the_suffixed_keychain_item() { common::fixtures(); let _guard = common::ENV_LOCK .lock() @@ -132,18 +188,179 @@ async fn isolation_falls_back_to_the_keychain_for_credentials() { let source = tempfile::tempdir().unwrap(); std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let service = suffixed_keychain_service(config_dir_env); - let cred_out = tempfile::NamedTempFile::new().unwrap(); - let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", config_dir_env)], Some(&service)).await; - set_config_dir(source.path()); - let spec = RunSpec::new("hi", "haiku").env(env(&[("SPAWNLLM_FAKE_CRED_OUT", &cred_path)])); - let response = spawnllm::run_on(&Backend::Claude, spec).await; - clear_config_dir(); + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} - response.outcome.expect("isolated claude run succeeds"); +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn empty_securestorage_env_reads_the_bare_item_over_config_dir_env() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_SECURESTORAGE_CONFIG_DIR", ""), + ], + Some("Claude Code-credentials"), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); assert_eq!( - std::fs::read_to_string(&cred_path).unwrap(), - "keychain-token-xyz" + argv, + "find-generic-password\n-s\nClaude Code-credentials\n-w\n" + ); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn securestorage_env_names_the_hashed_item_over_config_dir_env() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let secure = tempfile::tempdir().unwrap(); + let securestorage_env = secure.path().to_str().unwrap(); + let service = suffixed_keychain_service(securestorage_env); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_SECURESTORAGE_CONFIG_DIR", securestorage_env), + ], + Some(&service), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn custom_oauth_url_env_names_the_custom_oauth_item() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let service = suffixed_keychain_service(config_dir_env).replace( + "Claude Code-credentials", + "Claude Code-custom-oauth-credentials", + ); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example.test"), + ], + Some(&service), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn trailing_slash_config_dir_env_is_hashed_as_set() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = format!("{}/", source.path().to_str().unwrap()); + let service = suffixed_keychain_service(&config_dir_env); + assert_ne!( + service, + suffixed_keychain_service(source.path().to_str().unwrap()) + ); + + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", &config_dir_env)], Some(&service)).await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn default_home_falls_back_to_the_bare_keychain_item() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join(".claude")).unwrap(); + std::fs::write(home.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let original_home = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home.path()) }; + + let (credentials, argv) = keychain_seeded_run(&[], Some("Claude Code-credentials")).await; + + match original_home { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!( + argv, + "find-generic-password\n-s\nClaude Code-credentials\n-w\n" + ); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn keychain_miss_seeds_no_credentials() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", config_dir_env)], None).await; + + assert_eq!(credentials, ""); + assert_eq!( + argv, + format!( + "find-generic-password\n-s\n{}\n-w\n", + suffixed_keychain_service(config_dir_env) + ) ); } diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index d2b689c..3e22cd7 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -82,6 +82,8 @@ def claude_isolation(self) -> str: "platform": sys.platform, "home": str(Path.home()), "claude_config_dir_env": os.environ.get("CLAUDE_CONFIG_DIR") or None, + "claude_securestorage_config_dir_env": os.environ.get("CLAUDE_SECURESTORAGE_CONFIG_DIR"), + "claude_code_custom_oauth_url_env": os.environ.get("CLAUDE_CODE_CUSTOM_OAUTH_URL"), } }, ) diff --git a/tests/test_backends.py b/tests/test_backends.py index f9c7917..990cc82 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -288,6 +288,10 @@ def fake_capture_cli(argv: list[str], **kwargs: object) -> RunResult: assert plan_calls == 2 +def suffixed_keychain_service(config_dir_env: str) -> str: + return f"Claude Code-credentials-{hashlib.sha256(config_dir_env.encode()).hexdigest()[:8]}" + + class TestClaudeIsolation: def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) @@ -385,12 +389,83 @@ def fake_run(argv: list[str], **kwargs: object) -> object: monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) - digest = hashlib.sha256(str(account_home).encode()).hexdigest()[:8] - assert calls == [["security", "find-generic-password", "-s", f"Claude Code-credentials-{digest}", "-w"]] + service = suffixed_keychain_service(str(account_home)) + assert calls == [["security", "find-generic-password", "-s", service, "-w"]] credentials = config_dir / ".credentials.json" assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "kc-tok"}} assert credentials.stat().st_mode & 0o777 == 0o600 + @pytest.mark.parametrize( + "config_dir_env, expected_service", + [ + (lambda home, acct: {"CLAUDE_CONFIG_DIR": ""}, lambda home, acct: "Claude Code-credentials"), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": f"{acct}/"}, + lambda home, acct: suffixed_keychain_service(f"{acct}/"), + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": f"{home}/.claude"}, + lambda home, acct: suffixed_keychain_service(f"{home}/.claude"), + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": ""}, + lambda home, acct: "Claude Code-credentials", + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": f"{home}/secure"}, + lambda home, acct: suffixed_keychain_service(f"{home}/secure"), + ), + ( + lambda home, acct: { + "CLAUDE_CONFIG_DIR": acct, + "CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example.test", + }, + lambda home, acct: suffixed_keychain_service(acct).replace( + "Claude Code-credentials", "Claude Code-custom-oauth-credentials" + ), + ), + ], + ids=[ + "empty-env-reads-the-bare-item", + "trailing-slash-hashed-as-set", + "default-path-env-still-suffixed", + "empty-securestorage-env-reads-the-bare-item", + "securestorage-env-hashed-over-config-dir", + "custom-oauth-url-names-the-custom-oauth-item", + ], + ) + def test_env_keychain_service_follows_the_env_value_as_set( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + config_dir_env: Callable[[str, str], dict[str, str]], + expected_service: Callable[[str, str], str], + ) -> None: + (home := tmp_path / "home").mkdir() + (home / ".claude").mkdir() + (acct := tmp_path / "acct").mkdir() + for account_home in (home, acct): + (account_home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "e"}})) + monkeypatch.setenv("HOME", str(home)) + for name in ("CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"): + monkeypatch.delenv(name, raising=False) + for name, value in config_dir_env(str(home), str(acct)).items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> object: + calls.append(argv) + return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() + + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) + config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + service = expected_service(str(home), str(acct)) + assert calls == [["security", "find-generic-password", "-s", service, "-w"]] + assert json.loads((config_dir / ".credentials.json").read_text()) == { + "claudeAiOauth": {"accessToken": "kc-tok"} + } + def test_env_keychain_miss_seeds_no_credentials(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (account_home := tmp_path / "acct").mkdir() monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_home)) From 59ae89774ef099d89dd7dca5c0ed7c025b44fb19 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Wed, 16 Sep 2026 20:21:44 -0700 Subject: [PATCH 4/4] =?UTF-8?q?tests:=20=F0=9F=90=9B=20read=20seeded=20mod?= =?UTF-8?q?es=20portably=20and=20check=20the=20env=20unset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go/exec_test.go | 12 +++++++----- go/testdata/bin/claude | 2 +- rust/spawnllm/tests/common/mod.rs | 2 +- rust/spawnllm/tests/isolation.rs | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/go/exec_test.go b/go/exec_test.go index b5dedee..fa31d04 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -317,8 +317,8 @@ func TestClaudeIsolationSeeding(t *testing.T) { if !out.CredsPresent { t.Fatal("isolated config dir was not seeded with .credentials.json") } - if out.ConfigDirMode != "700" || out.CredsMode != "600" { - t.Fatalf("isolated config dir mode %q, credentials mode %q; want 700 and 600", out.ConfigDirMode, out.CredsMode) + if out.ConfigDirMode != "drwx------" || out.CredsMode != "-rw-------" { + t.Fatalf("isolated config dir mode %q, credentials mode %q; want drwx------ and -rw-------", out.ConfigDirMode, out.CredsMode) } if _, err := os.Stat(out.ConfigDir); !os.IsNotExist(err) { t.Fatalf("isolated config dir was not cleaned up: stat err = %v", err) @@ -430,7 +430,9 @@ func TestClaudeIsolationKeychain(t *testing.T) { writeAccountPointer(t, acct) for _, name := range []string{"CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"} { t.Setenv(name, "") - os.Unsetenv(name) + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } } for name, value := range tc.env(home, acct) { t.Setenv(name, value) @@ -442,8 +444,8 @@ func TestClaudeIsolationKeychain(t *testing.T) { if want := "find-generic-password\n-s\n" + service + "\n-w\n"; argv != want { t.Fatalf("security argv = %q, want %q", argv, want) } - if !out.CredsPresent || out.CredsMode != "600" { - t.Fatalf("keychain credentials seeded = %v with mode %q; want seeded at 600", out.CredsPresent, out.CredsMode) + if !out.CredsPresent || out.CredsMode != "-rw-------" { + t.Fatalf("keychain credentials seeded = %v with mode %q; want seeded at -rw-------", out.CredsPresent, out.CredsMode) } }) } diff --git a/go/testdata/bin/claude b/go/testdata/bin/claude index 0abf15f..968882d 100755 --- a/go/testdata/bin/claude +++ b/go/testdata/bin/claude @@ -21,7 +21,7 @@ creds_present=false config_dir_mode="" creds_mode="" mode_of() { - stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" + ls -ld "$1" | cut -c1-10 } if [ -n "$config_dir" ]; then config_dir_mode="$(mode_of "$config_dir")" diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index 5f7fb74..7128964 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -42,7 +42,7 @@ if [ -n "$SPAWNLLM_FAKE_MARKER" ]; then fi if [ -n "$SPAWNLLM_FAKE_CRED_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.credentials.json" > "$SPAWNLLM_FAKE_CRED_OUT" 2>/dev/null || true; fi if [ -n "$SPAWNLLM_FAKE_ACCOUNT_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.claude.json" > "$SPAWNLLM_FAKE_ACCOUNT_OUT" 2>/dev/null || true; fi -if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { stat -f '%Lp' "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json" 2>/dev/null || stat -c '%a' "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json"; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi +if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { ls -ld "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json" | cut -c1-10; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi if [ -n "$SPAWNLLM_FAKE_EXIT" ]; then printf 'boom' >&2; exit "$SPAWNLLM_FAKE_EXIT"; fi if [ -n "$SPAWNLLM_FAKE_SLEEP" ]; then sleep "$SPAWNLLM_FAKE_SLEEP"; fi if [ -n "$SPAWNLLM_FAKE_COUNTER" ]; then diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index 229aa1d..faa5c91 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -68,7 +68,7 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { ); assert_eq!( std::fs::read_to_string(&modes_path).unwrap(), - "700\n600\n", + "drwx------\n-rw-------\n", "config dir mode then credentials file mode" ); let account: serde_json::Value =