From 690b4851ea0afd8b214ddaa5450eec3a8c3a7ec9 Mon Sep 17 00:00:00 2001 From: main Date: Tue, 24 Mar 2026 01:19:25 -0400 Subject: Share live Claude credentials with sandbox --- crates/phone-opus/src/mcp/service.rs | 42 +++++++++++++++++++++++++++++--- crates/phone-opus/tests/mcp_hardening.rs | 35 +++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) (limited to 'crates') diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 64cb778..d958a81 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -1,6 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{self, BufRead, Write}; +#[cfg(unix)] +use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -543,8 +545,7 @@ const CONSULT_OUTPUT_KEEP_COUNT: usize = 256; const CONSULT_OUTPUT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); const CONSULT_TIMESTAMP_FORMAT: &[time::format_description::FormatItem<'static>] = time::macros::format_description!("[year][month][day]T[hour][minute][second]Z"); -const CLAUDE_SEED_FILES: [&str; 5] = [ - ".credentials.json", +const CLAUDE_MIRROR_FILES: [&str; 4] = [ "settings.json", "settings.local.json", ".claude/settings.local.json", @@ -622,15 +623,24 @@ impl ClaudeSandbox { self.source_home.join(".claude") } + fn source_credentials_path(&self) -> PathBuf { + self.source_claude_dir().join(".credentials.json") + } + + fn destination_credentials_path(&self) -> PathBuf { + self.claude_config_dir().join(".credentials.json") + } + fn sync_seed_claude_files(&self) -> io::Result<()> { let source_root = self.source_claude_dir(); let destination_root = self.claude_config_dir(); - for relative in CLAUDE_SEED_FILES { + for relative in CLAUDE_MIRROR_FILES { sync_optional_seed_file( source_root.join(relative).as_path(), destination_root.join(relative).as_path(), )?; } + self.sync_live_credentials()?; Ok(()) } @@ -653,6 +663,9 @@ impl ClaudeSandbox { fn read_write_paths(&self) -> BTreeSet { let mut paths = BTreeSet::new(); let _ = paths.insert(self.state_root.clone()); + if self.source_credentials_path().exists() { + let _ = paths.insert(self.source_credentials_path()); + } for root in SHARED_TMP_ROOTS { let _ = paths.insert(PathBuf::from(root)); } @@ -700,6 +713,20 @@ impl ClaudeSandbox { } environment } + + fn sync_live_credentials(&self) -> io::Result<()> { + let source = self.source_credentials_path(); + let destination = self.destination_credentials_path(); + if !source.exists() { + remove_optional_path(destination.as_path())?; + return Ok(()); + } + if fs::read_link(destination.as_path()).ok().as_ref() == Some(&source) { + return Ok(()); + } + remove_optional_path(destination.as_path())?; + symlink(source.as_path(), destination.as_path()) + } } #[derive(Debug, Clone)] @@ -1329,6 +1356,15 @@ fn sync_optional_seed_file(source: &Path, destination: &Path) -> io::Result<()> Ok(()) } +fn remove_optional_path(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), + Ok(_) => fs::remove_file(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> { let parent = path.parent().ok_or_else(|| { io::Error::new( diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs index 107a578..06861f8 100644 --- a/crates/phone-opus/tests/mcp_hardening.rs +++ b/crates/phone-opus/tests/mcp_hardening.rs @@ -209,6 +209,15 @@ if [ -n "${PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE:-}" ]; then printf 'write_failed\n' >"$PHONE_OPUS_TEST_CWD_WRITE_PROBE_FILE" fi fi +if [ -n "${PHONE_OPUS_TEST_CREDENTIAL_WRITE_PROBE_FILE:-}" ]; then + credentials_target="${HOME}/.claude/.credentials.json" + credentials_error="${PHONE_OPUS_TEST_CREDENTIAL_WRITE_ERROR_FILE:-/tmp/phone-opus-credentials.err}" + if : >>"$credentials_target" 2>"$credentials_error"; then + printf 'write_succeeded\n' >"$PHONE_OPUS_TEST_CREDENTIAL_WRITE_PROBE_FILE" + else + printf 'write_failed\n' >"$PHONE_OPUS_TEST_CREDENTIAL_WRITE_PROBE_FILE" + fi +fi if [ -n "${PHONE_OPUS_TEST_STDERR:-}" ]; then printf '%s\n' "$PHONE_OPUS_TEST_STDERR" >&2 fi @@ -331,6 +340,8 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki let env_file = root.join("env.txt"); let cwd_probe_file = root.join("cwd-write-probe.txt"); let cwd_probe_error_file = root.join("cwd-write-probe.err"); + let credential_probe_file = root.join("credential-write-probe.txt"); + let credential_probe_error_file = root.join("credential-write-probe.err"); let resumed_session = "81f218eb-568b-409b-871b-f6e86d8f666f"; write_fake_claude_script(&fake_claude)?; must( @@ -372,6 +383,8 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki let env_path = env_file.display().to_string(); let cwd_probe_path = cwd_probe_file.display().to_string(); let cwd_probe_error_path = cwd_probe_error_file.display().to_string(); + let credential_probe_path = credential_probe_file.display().to_string(); + let credential_probe_error_path = credential_probe_error_file.display().to_string(); let caller_home_path = caller_home.display().to_string(); let env = [ ("HOME", caller_home_path.as_str()), @@ -388,6 +401,14 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki "PHONE_OPUS_TEST_CWD_WRITE_ERROR_FILE", cwd_probe_error_path.as_str(), ), + ( + "PHONE_OPUS_TEST_CREDENTIAL_WRITE_PROBE_FILE", + credential_probe_path.as_str(), + ), + ( + "PHONE_OPUS_TEST_CREDENTIAL_WRITE_ERROR_FILE", + credential_probe_error_path.as_str(), + ), ]; let mut harness = McpHarness::spawn(&state_home, &env)?; let _ = harness.initialize()?; @@ -492,10 +513,17 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki assert!(env_dump.contains(format!("XDG_CACHE_HOME={}", xdg_cache_home.display()).as_str())); assert!(env_dump.contains(format!("XDG_STATE_HOME={}", xdg_state_home.display()).as_str())); + assert_eq!( + must( + fs::read_link(claude_home.join(".claude").join(".credentials.json")), + "read credentials symlink" + )?, + caller_home.join(".claude").join(".credentials.json") + ); assert_eq!( must( fs::read_to_string(claude_home.join(".claude").join(".credentials.json")), - "read mirrored credentials" + "read linked credentials" )?, "{\n \"auth\": \"token\"\n}\n" ); @@ -538,6 +566,11 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki "read cwd write probe result", )?; assert_eq!(cwd_probe.trim(), "write_failed"); + let credential_probe = must( + fs::read_to_string(&credential_probe_file), + "read credential write probe result", + )?; + assert_eq!(credential_probe.trim(), "write_succeeded"); let telemetry = harness.call_tool(4, "telemetry_snapshot", json!({}))?; assert_tool_ok(&telemetry); -- cgit v1.2.3