swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/phone-opus/src
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-23 16:51:01 -0400
committermain <main@swarm.moe>2026-03-23 16:51:01 -0400
commit10d4e08bc5d18daa59ddec19a3e2bf345331ccfc (patch)
treee0a702e4abff8059dfc7a72bbef599e1e79f896b /crates/phone-opus/src
parentc3ad44cf3ec3bcd080f62c19d915ac1749576302 (diff)
downloadphone_opus-10d4e08bc5d18daa59ddec19a3e2bf345331ccfc.zip
Externalize Claude sandboxing with systemd-run
Diffstat (limited to 'crates/phone-opus/src')
-rw-r--r--crates/phone-opus/src/mcp/service.rs293
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,