#[cfg(target_os = "macos")]
use crate::capability_ext::new_exact_path_capability;
use nono::{AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::debug;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[derive(Debug, Serialize, Deserialize)]
pub struct SandboxState {
pub fs: Vec<FsCapState>,
pub net_blocked: bool,
pub allowed_commands: Vec<String>,
pub blocked_commands: Vec<String>,
#[serde(default, alias = "override_deny_paths")]
pub bypass_protection_paths: Vec<String>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domain_endpoints: Vec<DomainEndpointState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainEndpointState {
pub domain: String,
pub endpoints: Vec<EndpointRuleState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointRuleState {
pub method: String,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FsCapState {
pub original: String,
pub path: String,
pub access: String,
pub is_file: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl SandboxState {
pub fn from_caps(
caps: &CapabilitySet,
bypass_protection_paths: &[PathBuf],
allowed_domains: &[String],
domain_endpoints: &[DomainEndpointState],
) -> Self {
Self {
fs: caps
.fs_capabilities()
.iter()
.map(|c| FsCapState {
original: c.original.display().to_string(),
path: c.resolved.display().to_string(),
access: match c.access {
AccessMode::Read => "read".to_string(),
AccessMode::Write => "write".to_string(),
AccessMode::ReadWrite => "readwrite".to_string(),
},
is_file: c.is_file,
source: Some(c.source.to_string()),
})
.collect(),
net_blocked: caps.is_network_blocked(),
allowed_commands: caps.allowed_commands().to_vec(),
blocked_commands: caps.blocked_commands().to_vec(),
bypass_protection_paths: bypass_protection_paths
.iter()
.map(|p| p.display().to_string())
.collect(),
allowed_domains: allowed_domains.to_vec(),
domain_endpoints: domain_endpoints.to_vec(),
}
}
pub fn bypass_protection_as_paths(&self) -> Vec<PathBuf> {
self.bypass_protection_paths
.iter()
.map(PathBuf::from)
.collect()
}
pub fn to_caps(&self) -> Result<CapabilitySet> {
let mut caps = CapabilitySet::new();
for fs_cap in &self.fs {
let access = parse_access_mode(&fs_cap.access)?;
let source = parse_capability_source(fs_cap.source.as_deref())?;
let cap = if fs_cap.is_file {
restore_exact_path_capability(fs_cap, access, &source)?
} else {
restore_directory_capability(fs_cap, access, &source)?
};
caps.add_fs(cap);
}
if !self.allowed_domains.is_empty() {
caps.set_network_mode_mut(nono::NetworkMode::ProxyOnly {
port: 0,
bind_ports: vec![],
});
} else {
caps.set_network_blocked(self.net_blocked);
}
for cmd in &self.allowed_commands {
caps.add_allowed_command(cmd.clone());
}
for cmd in &self.blocked_commands {
caps.add_blocked_command(cmd.clone());
}
Ok(caps)
}
pub fn write_to_file(&self, path: &std::path::Path) -> Result<()> {
let json = serde_json::to_string_pretty(self).map_err(|e| {
NonoError::ConfigParse(format!("Failed to serialize sandbox state: {}", e))
})?;
#[cfg(unix)]
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|e| NonoError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
#[cfg(not(unix))]
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.map_err(|e| NonoError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(json.as_bytes())
.map_err(|e| NonoError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
}
fn parse_access_mode(access: &str) -> Result<AccessMode> {
match access {
"read" => Ok(AccessMode::Read),
"write" => Ok(AccessMode::Write),
"readwrite" => Ok(AccessMode::ReadWrite),
other => Err(NonoError::ConfigParse(format!(
"invalid access mode in sandbox state: {other}"
))),
}
}
fn parse_capability_source(source: Option<&str>) -> Result<CapabilitySource> {
match source {
None | Some("") | Some("user") => Ok(CapabilitySource::User),
Some("profile") => Ok(CapabilitySource::Profile),
Some("system") => Ok(CapabilitySource::System),
Some(group) if let Some(name) = group.strip_prefix("group:") => {
if name.is_empty() {
return Err(NonoError::ConfigParse(
"invalid capability source in sandbox state: empty group name".to_string(),
));
}
Ok(CapabilitySource::Group(name.to_string()))
}
Some(other) => Err(NonoError::ConfigParse(format!(
"invalid capability source in sandbox state: {other}"
))),
}
}
fn validate_restored_path(fs_cap: &FsCapState, actual: &Path) -> Result<()> {
let serialized = Path::new(&fs_cap.path);
if actual != serialized {
return Err(NonoError::ConfigParse(format!(
"sandbox state path drifted at reload: serialized resolved={}, actual resolved={}",
serialized.display(),
actual.display(),
)));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn restore_exact_path_capability(
fs_cap: &FsCapState,
access: AccessMode,
source: &CapabilitySource,
) -> Result<FsCapability> {
let mut cap = new_exact_path_capability(Path::new(&fs_cap.original), access)?;
validate_restored_path(fs_cap, &cap.resolved)?;
cap.source = source.clone();
Ok(cap)
}
#[cfg(not(target_os = "macos"))]
fn restore_exact_path_capability(
fs_cap: &FsCapState,
access: AccessMode,
source: &CapabilitySource,
) -> Result<FsCapability> {
let mut cap = FsCapability::new_file(&fs_cap.original, access)?;
validate_restored_path(fs_cap, &cap.resolved)?;
cap.source = source.clone();
Ok(cap)
}
fn restore_directory_capability(
fs_cap: &FsCapState,
access: AccessMode,
source: &CapabilitySource,
) -> Result<FsCapability> {
let mut cap = FsCapability::new_dir(&fs_cap.original, access)?;
validate_restored_path(fs_cap, &cap.resolved)?;
cap.source = source.clone();
Ok(cap)
}
const MAX_CAP_FILE_SIZE: u64 = 1_048_576;
fn acceptable_temp_roots() -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
let candidates = vec![
std::env::temp_dir(),
PathBuf::from("/tmp"),
PathBuf::from("/var/folders"),
];
#[cfg(not(target_os = "macos"))]
let candidates = vec![std::env::temp_dir(), PathBuf::from("/tmp")];
let mut roots: Vec<PathBuf> = Vec::with_capacity(candidates.len());
for candidate in candidates {
if let Ok(canonical) = candidate.canonicalize()
&& !roots.contains(&canonical)
{
roots.push(canonical);
}
}
roots
}
fn validate_cap_file_path(path_str: &str) -> Result<PathBuf> {
let path = PathBuf::from(path_str);
if !path.is_absolute() {
return Err(NonoError::EnvVarValidation {
var: "NONO_CAP_FILE".to_string(),
reason: "path must be absolute".to_string(),
});
}
let canonical = path
.canonicalize()
.map_err(|e| NonoError::CapFileValidation {
reason: format!("failed to canonicalize path: {}", e),
})?;
let roots = acceptable_temp_roots();
if !roots.iter().any(|root| canonical.starts_with(root)) {
return Err(NonoError::CapFileValidation {
reason: format!(
"path must be in a temp directory, got: {}",
canonical.display()
),
});
}
let file_name = canonical
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| NonoError::CapFileValidation {
reason: "invalid file name".to_string(),
})?;
if !file_name.starts_with(".nono-") || !file_name.ends_with(".json") {
return Err(NonoError::CapFileValidation {
reason: format!(
"file name must match pattern .nono-*.json, got: {}",
file_name
),
});
}
let metadata = std::fs::metadata(&canonical).map_err(|e| NonoError::CapFileValidation {
reason: format!("failed to read file metadata: {}", e),
})?;
if metadata.len() > MAX_CAP_FILE_SIZE {
return Err(NonoError::CapFileTooLarge {
size: metadata.len(),
max: MAX_CAP_FILE_SIZE,
});
}
if !metadata.is_file() {
return Err(NonoError::CapFileValidation {
reason: "path must be a regular file".to_string(),
});
}
Ok(canonical)
}
pub fn load_sandbox_state() -> Option<SandboxState> {
let cap_file_str = std::env::var("NONO_CAP_FILE").ok()?;
let validated_path = validate_cap_file_path(&cap_file_str).unwrap_or_else(|e| {
eprintln!("SECURITY: NONO_CAP_FILE validation failed: {}", e);
eprintln!("SECURITY: This may indicate an attack attempt or a bug in nono");
std::process::exit(1);
});
let content = std::fs::read_to_string(&validated_path).unwrap_or_else(|e| {
eprintln!("Error reading capability state file: {}", e);
std::process::exit(1);
});
let state: SandboxState = serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!("Error parsing capability state file: {}", e);
std::process::exit(1);
});
Some(state)
}
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
let nix_pid = Pid::from_raw(pid as i32);
match kill(nix_pid, None) {
Ok(()) => true,
Err(nix::errno::Errno::ESRCH) => false,
Err(nix::errno::Errno::EPERM) => true,
_ => true,
}
}
#[cfg(not(unix))]
fn is_process_running(_pid: u32) -> bool {
true
}
pub fn cleanup_stale_state_files() {
let temp_dir = std::env::temp_dir();
let entries = match std::fs::read_dir(&temp_dir) {
Ok(entries) => entries,
Err(e) => {
debug!("Failed to read temp directory for cleanup: {}", e);
return;
}
};
let current_pid = std::process::id();
let mut cleaned_count = 0;
let mut skipped_count = 0;
for entry in entries.flatten() {
let file_name = match entry.file_name().to_str() {
Some(name) => name.to_string(),
None => continue,
};
if !file_name.starts_with(".nono-") || !file_name.ends_with(".json") {
continue;
}
let pid_str = file_name
.trim_start_matches(".nono-")
.trim_end_matches(".json");
let pid = match pid_str.parse::<u32>() {
Ok(p) => p,
Err(_) => {
debug!("Skipping state file with invalid PID: {}", file_name);
continue;
}
};
if pid == current_pid {
continue;
}
if is_process_running(pid) {
skipped_count += 1;
continue;
}
let file_path = temp_dir.join(&file_name);
match std::fs::remove_file(&file_path) {
Ok(()) => {
debug!("Cleaned up stale state file for PID {}: {}", pid, file_name);
cleaned_count += 1;
}
Err(e) => {
debug!("Failed to remove stale state file {}: {}", file_name, e);
}
}
}
if cleaned_count > 0 {
debug!(
"Cleanup complete: removed {} stale state file(s), {} active",
cleaned_count, skipped_count
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use nono::CapabilitySource;
use tempfile::tempdir;
#[test]
fn test_sandbox_state_roundtrip() {
let mut caps = CapabilitySet::new().block_network();
caps.add_allowed_command("pip".to_string());
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
assert!(state.net_blocked);
assert_eq!(state.allowed_commands, vec!["pip"]);
let restored = state
.to_caps()
.expect("to_caps failed on network-only state");
assert!(restored.is_network_blocked());
assert_eq!(restored.allowed_commands(), vec!["pip"]);
}
#[test]
fn test_sandbox_state_write_and_read() {
let dir = tempdir().expect("Failed to create temp dir");
let file_path = dir.path().join("test_state.json");
let caps = CapabilitySet::new().block_network();
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
state
.write_to_file(&file_path)
.expect("Failed to write state");
let content = std::fs::read_to_string(&file_path).expect("Failed to read file");
let loaded: SandboxState = serde_json::from_str(&content).expect("Failed to parse state");
assert!(loaded.net_blocked);
}
#[test]
fn test_sandbox_state_roundtrip_preserves_source() {
let dir = tempdir().expect("tempdir");
let file_path = dir.path().join("granted.txt");
std::fs::write(&file_path, b"ok").expect("write test file");
let mut cap = FsCapability::new_file(&file_path, AccessMode::Read).expect("create cap");
cap.source = CapabilitySource::Group("system_read_macos".to_string());
let mut caps = CapabilitySet::new();
caps.add_fs(cap);
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
let restored = state.to_caps().expect("restore caps");
assert_eq!(restored.fs_capabilities().len(), 1);
assert_eq!(
restored.fs_capabilities()[0].source,
CapabilitySource::Group("system_read_macos".to_string())
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_sandbox_state_restores_missing_future_file() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("future.lock");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: missing.clone(),
resolved: dir
.path()
.canonicalize()
.expect("canonicalize dir")
.join("future.lock"),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
let restored = state.to_caps().expect("restore future file cap");
assert_eq!(restored.fs_capabilities().len(), 1);
assert_eq!(restored.fs_capabilities()[0].original, missing);
assert_eq!(restored.fs_capabilities()[0].access, AccessMode::ReadWrite);
assert_eq!(
restored.fs_capabilities()[0].source,
CapabilitySource::Profile
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_sandbox_state_restores_exact_directory_literal_path() {
let dir = tempdir().expect("tempdir");
let lock_dir = dir.path().join("claude.lock");
std::fs::create_dir_all(&lock_dir).expect("create lock dir");
let child = lock_dir.join("child.txt");
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: lock_dir.clone(),
resolved: lock_dir.canonicalize().expect("canonicalize lock dir"),
access: AccessMode::ReadWrite,
is_file: true,
source: CapabilitySource::Profile,
});
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
let restored = state.to_caps().expect("restore exact directory literal");
assert_eq!(restored.fs_capabilities().len(), 1);
assert_eq!(restored.fs_capabilities()[0].original, lock_dir);
assert!(restored.fs_capabilities()[0].is_file);
assert_eq!(
restored.fs_capabilities()[0].source,
CapabilitySource::Profile
);
assert!(
!restored.path_covered(&child),
"exact-path directory literal must not recursively cover descendants"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_sandbox_state_rejects_drifted_future_file_path() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("future.lock");
let state = SandboxState {
fs: vec![FsCapState {
original: missing.display().to_string(),
path: dir
.path()
.canonicalize()
.expect("canonicalize dir")
.join("other.lock")
.display()
.to_string(),
access: "readwrite".to_string(),
is_file: true,
source: Some("profile".to_string()),
}],
net_blocked: false,
allowed_commands: vec![],
blocked_commands: vec![],
bypass_protection_paths: vec![],
allowed_domains: vec![],
domain_endpoints: vec![],
};
let err = state
.to_caps()
.expect_err("drifted future file must be rejected");
assert!(
format!("{err}").contains("sandbox state path drifted"),
"error should mention path drift"
);
}
}
#[cfg(test)]
mod cap_file_validation_tests {
use super::*;
use crate::test_env::{ENV_LOCK, EnvVarGuard};
use tempfile::tempdir;
fn write_cap_file(dir: &Path) -> PathBuf {
let caps = CapabilitySet::new().block_network();
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
let path = dir.join(".nono-abcdef0123456789.json");
state.write_to_file(&path).expect("write cap file");
path
}
#[test]
fn test_acceptable_temp_roots_non_empty_and_deduped() {
let roots = acceptable_temp_roots();
assert!(
!roots.is_empty(),
"should have at least one acceptable temp root"
);
let mut seen = std::collections::HashSet::new();
for root in &roots {
assert!(
seen.insert(root.clone()),
"acceptable_temp_roots must not contain duplicates: {roots:?}"
);
}
}
#[test]
#[cfg(target_os = "macos")]
fn test_acceptable_temp_roots_includes_var_folders_on_macos() {
let var_folders = PathBuf::from("/var/folders")
.canonicalize()
.expect("canonicalize /var/folders");
let roots = acceptable_temp_roots();
assert!(
roots.contains(&var_folders),
"macOS roots should include {}, got {roots:?}",
var_folders.display()
);
}
#[test]
fn test_validate_accepts_file_in_tempdir() {
let dir = tempdir().expect("tempdir");
let path = write_cap_file(dir.path());
let path_str = path.to_str().expect("utf8 path");
let validated = validate_cap_file_path(path_str).expect("valid cap file accepted");
let expected = path.canonicalize().expect("canonicalize cap file");
assert_eq!(validated, expected);
}
#[test]
fn test_validate_accepts_file_when_reading_tmpdir_differs() {
let _lock = ENV_LOCK.lock().expect("env lock");
let file_dir = tempfile::tempdir_in("/tmp").expect("file tempdir");
let path = write_cap_file(file_dir.path());
let path_str = path.to_str().expect("utf8 path").to_string();
let other_dir = tempfile::tempdir_in("/tmp").expect("other tempdir");
let other = other_dir.path().to_str().expect("utf8 other");
let _env = EnvVarGuard::set_all(&[("TMPDIR", other)]);
validate_cap_file_path(&path_str)
.expect("cap file under a known temp root accepted despite TMPDIR mismatch");
}
#[test]
fn test_validate_rejects_relative_path() {
let err = validate_cap_file_path(".nono-abcdef0123456789.json")
.expect_err("relative path rejected");
assert!(
format!("{err}").contains("must be absolute"),
"error should mention absolute path requirement: {err}"
);
}
#[test]
fn test_validate_rejects_nonexistent_file() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join(".nono-0000000000000000.json");
let err = validate_cap_file_path(missing.to_str().expect("utf8"))
.expect_err("nonexistent file rejected");
assert!(
format!("{err}").contains("canonicalize"),
"error should mention canonicalize failure: {err}"
);
}
#[test]
fn test_validate_rejects_path_outside_temp() {
if !Path::new("/etc/hosts").exists() {
return;
}
let err = validate_cap_file_path("/etc/hosts").expect_err("path outside temp dir rejected");
assert!(
format!("{err}").contains("temp directory"),
"error should mention temp directory: {err}"
);
}
#[test]
fn test_validate_rejects_wrong_filename_pattern() {
let dir = tempdir().expect("tempdir");
let caps = CapabilitySet::new().block_network();
let state = SandboxState::from_caps(&caps, &[], &[], &[]);
let path = dir.path().join("not-a-nono-file.json");
state.write_to_file(&path).expect("write file");
let err = validate_cap_file_path(path.to_str().expect("utf8"))
.expect_err("wrong filename pattern rejected");
assert!(
format!("{err}").contains(".nono-"),
"error should mention filename pattern: {err}"
);
}
#[test]
fn test_validate_rejects_directory() {
let dir = tempdir().expect("tempdir");
let err = validate_cap_file_path(dir.path().to_str().expect("utf8"))
.expect_err("directory rejected");
let msg = format!("{err}");
assert!(
msg.contains("regular file") || msg.contains(".nono-"),
"directory should be rejected: {msg}"
);
}
}