diff options
Diffstat (limited to 'crates/phone-opus/src/mcp')
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 293 |
1 files changed, 278 insertions, 15 deletions
diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 5b38c4b..bd9b31f 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; @@ -10,6 +10,7 @@ use libmcp::{Generation, SurfaceKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +use users::get_current_uid; use uuid::Uuid; use crate::mcp::fault::{FaultRecord, FaultStage}; @@ -541,6 +542,173 @@ impl ConsultResponse { } } +const SYSTEMD_RUN_BINARY: &str = "systemd-run"; +const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; +const PHONE_OPUS_STATE_ROOT_NAME: &str = "phone_opus"; +const CLAUDE_HOME_DIR_NAME: &str = "claude-home"; +const XDG_CONFIG_DIR_NAME: &str = "xdg-config"; +const XDG_CACHE_DIR_NAME: &str = "xdg-cache"; +const XDG_STATE_DIR_NAME: &str = "xdg-state"; +const SHARED_TMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"]; +const CLAUDE_SEED_FILES: [&str; 5] = [ + ".credentials.json", + "settings.json", + "settings.local.json", + ".claude/settings.local.json", + "CLAUDE.md", +]; +const SERVICE_ENV_ALLOWLIST: [&str; 19] = [ + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "COLORTERM", + "USER", + "LOGNAME", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +]; +const SERVICE_ENV_PREFIX_ALLOWLIST: [&str; 2] = ["ANTHROPIC_", "PHONE_OPUS_TEST_"]; + +#[derive(Debug, Clone)] +struct ClaudeSandbox { + source_home: PathBuf, + state_root: PathBuf, + claude_home: PathBuf, + xdg_config_home: PathBuf, + xdg_cache_home: PathBuf, + xdg_state_home: PathBuf, +} + +impl ClaudeSandbox { + fn prepare() -> io::Result<Self> { + let source_home = caller_home_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "failed to resolve the caller home directory", + ) + })?; + let state_root = phone_opus_state_root()?; + let sandbox = Self { + source_home, + claude_home: state_root.join(CLAUDE_HOME_DIR_NAME), + xdg_config_home: state_root.join(XDG_CONFIG_DIR_NAME), + xdg_cache_home: state_root.join(XDG_CACHE_DIR_NAME), + xdg_state_home: state_root.join(XDG_STATE_DIR_NAME), + state_root, + }; + sandbox.create_layout()?; + sandbox.sync_seed_claude_files()?; + Ok(sandbox) + } + + fn create_layout(&self) -> io::Result<()> { + fs::create_dir_all(&self.claude_home)?; + fs::create_dir_all(self.claude_config_dir())?; + fs::create_dir_all(&self.xdg_config_home)?; + fs::create_dir_all(&self.xdg_cache_home)?; + fs::create_dir_all(&self.xdg_state_home)?; + Ok(()) + } + + fn claude_config_dir(&self) -> PathBuf { + self.claude_home.join(".claude") + } + + fn source_claude_dir(&self) -> PathBuf { + self.source_home.join(".claude") + } + + 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 { + sync_optional_seed_file( + source_root.join(relative).as_path(), + destination_root.join(relative).as_path(), + )?; + } + Ok(()) + } + + fn read_only_paths(&self, request: &ConsultRequest) -> BTreeSet<PathBuf> { + let cwd = request.cwd.as_path(); + let mut paths = BTreeSet::from([self.source_home.clone()]); + if self.force_read_only_cwd(cwd) { + let _ = paths.insert(cwd.to_path_buf()); + } + paths + } + + fn force_read_only_cwd(&self, cwd: &Path) -> bool { + self.read_write_paths() + .iter() + .any(|path| cwd.starts_with(path)) + && !SHARED_TMP_ROOTS.iter().any(|root| cwd == Path::new(root)) + } + + fn read_write_paths(&self) -> BTreeSet<PathBuf> { + let mut paths = BTreeSet::new(); + let _ = paths.insert(self.state_root.clone()); + for root in SHARED_TMP_ROOTS { + let _ = paths.insert(PathBuf::from(root)); + } + paths + } + + fn service_environment(&self) -> BTreeMap<String, String> { + let runtime_dir = caller_runtime_dir(); + let mut environment = BTreeMap::from([ + ("HOME".to_owned(), self.claude_home.display().to_string()), + ( + "XDG_CONFIG_HOME".to_owned(), + self.xdg_config_home.display().to_string(), + ), + ( + "XDG_CACHE_HOME".to_owned(), + self.xdg_cache_home.display().to_string(), + ), + ( + "XDG_STATE_HOME".to_owned(), + self.xdg_state_home.display().to_string(), + ), + ( + "XDG_RUNTIME_DIR".to_owned(), + runtime_dir.display().to_string(), + ), + ( + "DBUS_SESSION_BUS_ADDRESS".to_owned(), + caller_dbus_session_bus_address(runtime_dir.as_path()), + ), + ("PATH".to_owned(), caller_path()), + ]); + for name in SERVICE_ENV_ALLOWLIST { + if let Some(value) = caller_env(name) { + let _ = environment.insert(name.to_owned(), value); + } + } + for (name, value) in std::env::vars() { + if SERVICE_ENV_PREFIX_ALLOWLIST + .iter() + .any(|prefix| name.starts_with(prefix)) + { + let _ = environment.insert(name, value); + } + } + environment + } +} + fn deserialize<T: for<'de> Deserialize<'de>>( value: Value, operation: &str, @@ -824,16 +992,7 @@ fn background_job_tool_output( } fn background_consult_job_root() -> io::Result<PathBuf> { - let root = state_dir() - .map(|root| root.join("phone_opus")) - .or_else(|| home_dir().map(|home| home.join(".local").join("state").join("phone_opus"))) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "failed to resolve phone_opus state root", - ) - })?; - let path = root.join("mcp").join("consult_jobs"); + let path = phone_opus_state_root()?.join("mcp").join("consult_jobs"); fs::create_dir_all(&path)?; Ok(path) } @@ -901,8 +1060,46 @@ fn unix_ms_now() -> u64 { } fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInvocationError> { - let mut command = Command::new(claude_binary()); + let sandbox = ClaudeSandbox::prepare().map_err(ConsultInvocationError::Spawn)?; + let mut command = Command::new(SYSTEMD_RUN_BINARY); + let runtime_dir = caller_runtime_dir(); + let _ = command + .env("XDG_RUNTIME_DIR", runtime_dir.as_os_str()) + .env( + "DBUS_SESSION_BUS_ADDRESS", + caller_dbus_session_bus_address(runtime_dir.as_path()), + ) + .arg("--user") + .arg("--wait") + .arg("--pipe") + .arg("--collect") + .arg("--quiet") + .arg("--working-directory") + .arg(request.cwd.as_path()); + for property in [ + "ProtectSystem=strict", + "NoNewPrivileges=yes", + "PrivateDevices=yes", + "RestrictSUIDSGID=yes", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + ] { + let _ = command.arg("-p").arg(property); + } + for path in sandbox.read_write_paths() { + let _ = command + .arg("-p") + .arg(format!("ReadWritePaths={}", path.display())); + } + for path in sandbox.read_only_paths(request) { + let _ = command + .arg("-p") + .arg(format!("ReadOnlyPaths={}", path.display())); + } + for (name, value) in sandbox.service_environment() { + let _ = command.arg(format!("--setenv={name}={value}")); + } let _ = command + .arg(claude_binary()) .arg("-p") .arg("--output-format") .arg("json") @@ -917,8 +1114,7 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv .arg(CLAUDE_EFFORT) .arg("--tools") .arg(CLAUDE_TOOLSET) - .arg("--permission-mode") - .arg("dontAsk"); + .arg("--dangerously-skip-permissions"); if let Some(session) = request.session.as_ref() { let _ = command.arg("--resume").arg(session.display()); } @@ -926,7 +1122,6 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv let _ = command.arg("--max-turns").arg(max_turns.get().to_string()); } let output = command - .current_dir(request.cwd.as_path()) .arg(request.prompt.rendered()) .output() .map_err(ConsultInvocationError::Spawn)?; @@ -997,6 +1192,74 @@ fn claude_binary() -> PathBuf { .unwrap_or_else(|| PathBuf::from("claude")) } +fn phone_opus_state_root() -> io::Result<PathBuf> { + let base = std::env::var_os("XDG_STATE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(state_dir) + .or_else(|| caller_home_dir().map(|home| home.join(".local").join("state"))) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "failed to resolve phone_opus state root", + ) + })?; + let root = base.join(PHONE_OPUS_STATE_ROOT_NAME); + fs::create_dir_all(&root)?; + Ok(root) +} + +fn caller_home_dir() -> Option<PathBuf> { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(home_dir) +} + +fn caller_env(name: &str) -> Option<String> { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn caller_path() -> String { + caller_env("PATH").unwrap_or_else(|| DEFAULT_PATH.to_owned()) +} + +fn caller_runtime_dir() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(format!("/run/user/{}", get_current_uid()))) +} + +fn caller_dbus_session_bus_address(runtime_dir: &Path) -> String { + caller_env("DBUS_SESSION_BUS_ADDRESS") + .unwrap_or_else(|| format!("unix:path={}", runtime_dir.join("bus").display())) +} + +fn sync_optional_seed_file(source: &Path, destination: &Path) -> io::Result<()> { + if source.exists() { + let bytes = fs::read(source)?; + write_bytes_file(destination, &bytes)?; + } else if destination.exists() { + fs::remove_file(destination)?; + } + Ok(()) +} + +fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path `{}` has no parent directory", path.display()), + ) + })?; + fs::create_dir_all(parent)?; + let temp_path = path.with_extension(format!("tmp-{}", Uuid::new_v4())); + fs::write(&temp_path, bytes)?; + fs::rename(temp_path, path)?; + Ok(()) +} + fn consult_output( request: &ConsultRequest, response: &ConsultResponse, |