#[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 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 temp_dir =
std::env::temp_dir()
.canonicalize()
.map_err(|e| NonoError::CapFileValidation {
reason: format!("failed to canonicalize temp directory: {}", e),
})?;
if !canonical.starts_with(&temp_dir) {
return Err(NonoError::CapFileValidation {
reason: format!(
"path must be in temp directory ({}), got: {}",
temp_dir.display(),
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"
);
}
}