//! Sandbox profiles. Built-in: `workspace`, `devbox`, `read-only`, `strict`, //! `off`. Custom profiles via `~/.grok/sandbox.toml` or `.grok/sandbox.toml`. //! A custom profile's `deny` list is kernel-enforced (read + write/rename) on //! both platforms. #[cfg(all(feature = "enforce", unix))] use nono::{AccessMode, CapabilitySet}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; #[cfg(all(feature = "enforce", unix))] use crate::deny::{ apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths, partition_deny_entries, }; use crate::paths::grok_home; #[cfg(all(feature = "enforce", unix))] use crate::paths::{DEVICE_DIRS, DEVICE_FILES}; use crate::paths::{essential_writable_paths, essential_writable_paths_minimal}; /// A resolved sandbox profile ready to be converted to a `CapabilitySet`. #[derive(Debug, Clone)] pub struct SandboxProfile { /// Display name pub name: String, /// Paths the agent can read (but not write) pub read_only: Vec, /// Paths the agent can read and write pub read_write: Vec, /// Paths denied entirely (overrides read_only/read_write) pub deny: Vec, /// Whether to grant read access to the entire filesystem by default pub default_read: bool, /// Whether child processes should have network blocked pub restrict_network: bool, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ProfileConfig { #[serde(default)] pub extends: Option, #[serde(default)] pub restrict_network: Option, #[serde(default)] pub read_only: Vec, #[serde(default)] pub read_write: Vec, #[serde(default)] pub deny: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SandboxConfig { #[serde(default)] pub profiles: HashMap, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ProfileName { #[default] Workspace, Devbox, ReadOnly, Strict, Off, Custom(String), } impl ProfileName { pub(crate) fn restricts_network(&self) -> bool { matches!(self, Self::ReadOnly | Self::Strict) } } impl std::fmt::Display for ProfileName { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Workspace => write!(f, "workspace"), Self::Devbox => write!(f, "devbox"), Self::ReadOnly => write!(f, "read-only"), Self::Strict => write!(f, "strict"), Self::Off => write!(f, "off"), Self::Custom(name) => write!(f, "{name}"), } } } impl std::str::FromStr for ProfileName { type Err = String; fn from_str(s: &str) -> Result { match s { "workspace" => Ok(Self::Workspace), "devbox" => Ok(Self::Devbox), "read-only" | "readonly" => Ok(Self::ReadOnly), "strict" => Ok(Self::Strict), "off" | "none" => Ok(Self::Off), // Anything else is treated as a custom profile name. // Validation happens when we try to load it from config. other => Ok(Self::Custom(other.to_string())), } } } /// Load sandbox config from `~/.grok/sandbox.toml` and `.grok/sandbox.toml`. /// /// Project config may **add** new profile names only. It cannot redefine a /// name already present in the global config — last-write-wins would let a /// malicious workspace hollow out a user/enterprise custom profile (e.g. /// empty `deny` / broad `read_write`) while keeping the trusted name. pub fn load_sandbox_config(workspace: &Path) -> SandboxConfig { let mut config = SandboxConfig::default(); // Global config: ~/.grok/sandbox.toml let global_path = grok_home().join("sandbox.toml"); if let Some(global) = load_config_file(&global_path) { config = global; } // Project config: /.grok/sandbox.toml (additive only) let project_path = workspace.join(".grok").join("sandbox.toml"); if let Some(project) = load_config_file(&project_path) { merge_project_profiles(&mut config, project); } config } pub fn sandbox_profile_conflicts(workspace: &Path) -> Vec { let global = load_config_file(&grok_home().join("sandbox.toml")).unwrap_or_default(); let project = load_config_file(&workspace.join(".grok").join("sandbox.toml")).unwrap_or_default(); mismatched_profile_names(&global, &project) } fn mismatched_profile_names(global: &SandboxConfig, project: &SandboxConfig) -> Vec { let mut names: Vec = project .profiles .iter() .filter(|(name, _)| matches!(name.parse(), Ok(ProfileName::Custom(_)))) .filter_map(|(name, project_profile)| { global .profiles .get(name) .filter(|global_profile| *global_profile != project_profile) .map(|_| name.to_owned()) }) .collect(); names.sort_unstable(); names } /// Merge project profiles into `config`. Names already defined globally are /// ignored so a workspace cannot replace a global custom profile's policy. fn merge_project_profiles(config: &mut SandboxConfig, project: SandboxConfig) { for (name, profile) in project.profiles { config.profiles.entry(name).or_insert(profile); } } fn load_config_file(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; match toml::from_str(&content) { Ok(config) => Some(config), Err(e) => { tracing::warn!(path = %path.display(), error = %e, "Failed to parse sandbox config"); None } } } impl ProfileName { /// Convert this profile into a nono `CapabilitySet` for the given workspace. #[cfg(all(feature = "enforce", unix))] pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result { let config = load_sandbox_config(workspace); self.to_capability_set_with_config(workspace, &config) } /// Convert using an already-loaded config (avoids re-reading disk). /// /// A custom profile's own `deny` list is kernel-enforced (read + write/rename) /// on top of the base profile. #[cfg(all(feature = "enforce", unix))] pub fn to_capability_set_with_config( &self, workspace: &Path, config: &SandboxConfig, ) -> anyhow::Result { if *self == Self::Off { return Ok(CapabilitySet::new()); } let profile = self.resolve_profile(workspace, config)?; Self::capability_set_from_profile(workspace, &profile) } #[cfg(all(feature = "enforce", unix))] pub(crate) fn capability_set_from_profile( workspace: &Path, profile: &SandboxProfile, ) -> anyhow::Result { let mut caps = CapabilitySet::new(); // Default read access if profile.default_read { caps = caps.allow_path("/", AccessMode::Read)?; } // Explicit read-only paths — skip non-existent (nothing to read) for path in &profile.read_only { if !path.exists() { continue; } let Some(path_str) = path.to_str() else { tracing::warn!(path = ?path, "Skipping non-UTF8 read_only path"); continue; }; caps = caps.allow_path(path_str, AccessMode::Read)?; } // Read-write paths. nono/Landlock need the directory to exist at // apply time (it opens an O_PATH fd), but new files within it can // be created freely after the sandbox is applied. Pre-create // directories like ~/.grok/ that may not exist on first run. for path in &profile.read_write { if !path.exists() && std::fs::create_dir_all(path).is_err() { tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping"); continue; } let Some(path_str) = path.to_str() else { tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path"); continue; }; caps = caps.allow_path(path_str, AccessMode::ReadWrite)?; } // Device special files (character devices like /dev/null, /dev/tty, etc.). for dev in DEVICE_FILES { let p = Path::new(dev); if !p.exists() { continue; } if let Err(e) = caps.allow_file_mut(p, AccessMode::ReadWrite) { tracing::warn!(path = dev, error = %e, "Could not allow device file"); } } // Device directories (e.g. /dev/pts for PTY slaves on Linux). for dev in DEVICE_DIRS { let p = Path::new(dev); if p.exists() && p.is_dir() { caps = caps.allow_path(dev, AccessMode::ReadWrite)?; } } // Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over. // The effective deny set is the profile's own `deny` (custom profiles only; // built-ins carry an empty `deny`). An empty set means there is nothing to // enforce. Keying on emptiness rather than profile type avoids enforcing // unintentional denies. // // Split exact paths from globs: exact paths keep the literal/subpath flow; // globs become anchored Seatbelt regexes on macOS (a no-op here on Linux, // where they are expanded and bound over at bwrap re-exec). let (exact_deny, glob_deny) = partition_deny_entries(&profile.deny); let all_denied = effective_deny_paths(workspace, &exact_deny); if !all_denied.is_empty() { apply_deny_paths_to_capability_set(&mut caps, &all_denied)?; } if !glob_deny.is_empty() { apply_deny_globs_to_capability_set(&mut caps, workspace, &glob_deny)?; } Ok(caps) } /// Resolve this profile into a fully-specified `SandboxProfile` for logging. pub fn resolve_profile( &self, workspace: &Path, config: &SandboxConfig, ) -> anyhow::Result { self.resolve(workspace, config) } fn resolve(&self, workspace: &Path, config: &SandboxConfig) -> anyhow::Result { match self { // Selected `off` is handled before resolve (empty CapabilitySet / // early return in apply). Reaching here is almost always a custom // profile with `extends = "off"` / `"none"` — return Err, never panic. Self::Off => anyhow::bail!( "sandbox profile 'off' cannot be resolved as a base profile; \ choose a built-in base (workspace, devbox, read-only, strict)" ), Self::Workspace => Ok(SandboxProfile { name: "workspace".to_string(), read_only: vec![], read_write: essential_writable_paths(workspace), deny: vec![], default_read: true, restrict_network: false, }), Self::Devbox => { // Everything writable except /data. Enumerate top-level // dirs and skip the exclusion list. Can't grant "/" because // Landlock has no deny_path — sub-path exceptions are // only possible by not granting the parent. // // /data is excluded from read_write here (so it is not writable) // but is deliberately NOT a kernel-deny: it stays readable via // default_read, and its Linux write-deny comes from the // bwrap_reexec_command(&["/data"]) re-exec, not from profile.deny. // Keeping deny empty stops a custom profile that extends devbox // from inheriting /data into the enforced kernel-deny set. let exclude = [PathBuf::from("/data")]; let mut read_write = vec![workspace.to_path_buf()]; if let Ok(entries) = std::fs::read_dir("/") { for entry in entries.flatten() { let path = entry.path(); if exclude.contains(&path) { continue; } // Skip virtual filesystems (handled separately) if matches!(path.to_str(), Some("/proc" | "/sys" | "/dev")) { continue; } if path.is_dir() { read_write.push(path); } } } Ok(SandboxProfile { name: "devbox".to_string(), read_only: vec![], read_write, deny: vec![], default_read: true, restrict_network: false, }) } Self::ReadOnly => Ok(SandboxProfile { name: "read-only".to_string(), read_only: vec![], read_write: essential_writable_paths_minimal(), deny: vec![], default_read: true, restrict_network: true, }), Self::Strict => { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); let system_read: Vec = [ "/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/dev", "/proc", "/sys", "/tmp", // Landlock realpath: /etc/resolv.conf often → /run/systemd/resolve/… "/run", // NSS/SSSD (and similar) under /var — needed beyond resolv.conf alone "/var", // macOS-specific paths (filtered by exists() below) "/System", // Security framework, dylibs, TLS certificates "/Library", // System-wide frameworks "/private", // Real path behind /etc, /tmp, /var symlinks ] .iter() .map(PathBuf::from) .filter(|p| p.exists()) // ~/Library is needed for macOS keychain access (TLS cert validation) .chain(std::iter::once(home.join("Library"))) .filter(|p| p.exists()) .chain(std::iter::once(workspace.to_path_buf())) .collect(); Ok(SandboxProfile { name: "strict".to_string(), read_only: system_read, read_write: essential_writable_paths(workspace), deny: vec![], default_read: false, restrict_network: true, }) } Self::Custom(name) => { let profile_config = config.profiles.get(name).ok_or_else(|| { anyhow::anyhow!( "Custom sandbox profile '{name}' not found. \ Define it in ~/.grok/sandbox.toml or .grok/sandbox.toml:\n\n\ [profiles.{name}]\n\ extends = \"workspace\"\n\ read_only = [\"/data\"]\n" ) })?; // Start from the base profile if `extends` is set let mut profile = if let Some(base_name) = &profile_config.extends { let base: ProfileName = base_name.parse().map_err(|e: String| { anyhow::anyhow!("Profile '{name}' extends invalid base: {e}") })?; if matches!(base, Self::Off) { anyhow::bail!( "Profile '{name}' extends '{base_name}', but 'off'/'none' \ is not a valid base profile" ); } if matches!(base, Self::Custom(_)) { anyhow::bail!( "Profile '{name}' extends '{base_name}', but custom profiles \ cannot extend other custom profiles (only built-ins)" ); } base.resolve(workspace, config)? } else { // Default: start from workspace Self::Workspace.resolve(workspace, config)? }; profile.name = name.clone(); // Apply overrides from the custom config if let Some(restrict_net) = profile_config.restrict_network { profile.restrict_network = restrict_net; } // Add custom read-only paths for path_str in &profile_config.read_only { profile.read_only.push(PathBuf::from(path_str)); } // Add custom read-write paths for path_str in &profile_config.read_write { profile.read_write.push(PathBuf::from(path_str)); } // Add custom deny paths for path_str in &profile_config.deny { profile.deny.push(PathBuf::from(path_str)); } Ok(profile) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_profile_names() { assert_eq!( "workspace".parse::().unwrap(), ProfileName::Workspace ); assert_eq!( "devbox".parse::().unwrap(), ProfileName::Devbox ); assert_eq!( "read-only".parse::().unwrap(), ProfileName::ReadOnly ); assert_eq!( "readonly".parse::().unwrap(), ProfileName::ReadOnly ); assert_eq!( "strict".parse::().unwrap(), ProfileName::Strict ); assert_eq!("off".parse::().unwrap(), ProfileName::Off); assert_eq!("none".parse::().unwrap(), ProfileName::Off); // Unknown names become Custom profiles assert_eq!( "my-custom-profile".parse::().unwrap(), ProfileName::Custom("my-custom-profile".to_string()) ); } #[test] fn display_roundtrip() { for profile in [ ProfileName::Workspace, ProfileName::Devbox, ProfileName::ReadOnly, ProfileName::Strict, ProfileName::Off, ] { let s = profile.to_string(); let parsed: ProfileName = s.parse().unwrap(); assert_eq!(parsed, profile); } } #[test] fn display_custom() { let p = ProfileName::Custom("my-custom".to_string()); assert_eq!(p.to_string(), "my-custom"); } #[test] fn built_in_network_restriction_values() { let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig::default(); for (name, expected) in [ (ProfileName::Workspace, false), (ProfileName::Devbox, false), (ProfileName::ReadOnly, true), (ProfileName::Strict, true), ] { let resolved = name.resolve_profile(&workspace, &config).unwrap(); assert_eq!(resolved.restrict_network, expected, "{name}"); } } fn network_inheritance_config() -> SandboxConfig { SandboxConfig { profiles: HashMap::from([ ( "strict-inherited".to_string(), ProfileConfig { extends: Some("strict".to_string()), restrict_network: None, read_only: vec![], read_write: vec![], deny: vec![], }, ), ( "read-only-inherited".to_string(), ProfileConfig { extends: Some("read-only".to_string()), restrict_network: None, read_only: vec![], read_write: vec![], deny: vec![], }, ), ( "strict-unrestricted".to_string(), ProfileConfig { extends: Some("strict".to_string()), restrict_network: Some(false), read_only: vec![], read_write: vec![], deny: vec![], }, ), ( "workspace-restricted".to_string(), ProfileConfig { extends: Some("workspace".to_string()), restrict_network: Some(true), read_only: vec![], read_write: vec![], deny: vec![], }, ), ]), } } #[test] fn custom_network_restriction_inherits_and_overrides_base() { let workspace = std::env::current_dir().unwrap(); let config = network_inheritance_config(); for (name, expected) in [ ("strict-inherited", true), ("read-only-inherited", true), ("strict-unrestricted", false), ("workspace-restricted", true), ] { let profile_name = ProfileName::Custom(name.to_string()); let resolved = profile_name.resolve_profile(&workspace, &config).unwrap(); assert_eq!(resolved.restrict_network, expected, "{name}"); } } #[test] #[cfg(all(feature = "enforce", unix))] fn strict_allowlist_includes_run_and_var_when_present() { // Regression: /run (resolv realpath) + /var (NSS/SSSD) when present. let workspace = std::env::temp_dir(); let profile = ProfileName::Strict .resolve_profile(&workspace, &SandboxConfig::default()) .expect("strict resolves"); assert!(!profile.default_read); if PathBuf::from("/run").exists() { assert!( profile.read_only.iter().any(|p| p == Path::new("/run")), "strict read_only must include exact /run for systemd-resolved DNS; got {:?}", profile.read_only ); } if PathBuf::from("/var").exists() { assert!( profile.read_only.iter().any(|p| p == Path::new("/var")), "strict read_only must include exact /var for NSS/SSSD; got {:?}", profile.read_only ); } } #[test] #[cfg(all(feature = "enforce", unix))] fn base_profile_capability_set_builds() { // A base profile with no `deny` builds a CapabilitySet without erroring. let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig::default(); let result = ProfileName::Workspace.to_capability_set_with_config(&workspace, &config); assert!(result.is_ok(), "Failed: {:?}", result.err()); } #[test] #[cfg(all(feature = "enforce", unix))] fn custom_profile_from_config() { let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig { profiles: HashMap::from([( "project".to_string(), ProfileConfig { extends: Some("workspace".to_string()), restrict_network: Some(true), read_only: vec!["/data".to_string()], read_write: vec![], deny: vec!["/data/private".to_string()], }, )]), }; let profile = ProfileName::Custom("project".to_string()); let result = profile.to_capability_set_with_config(&workspace, &config); assert!(result.is_ok(), "Failed: {:?}", result.err()); } #[test] #[cfg(all(feature = "enforce", unix))] fn custom_extends_devbox_has_no_data_in_deny() { // Regression: devbox excludes /data via a local list, not profile.deny, so // a custom profile extending devbox must not inherit /data into the kernel // deny set (which would wrongly read-deny /data and force fail-closed). let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig { profiles: HashMap::from([( "mydev".to_string(), ProfileConfig { extends: Some("devbox".to_string()), restrict_network: None, read_only: vec![], read_write: vec![], deny: vec![], }, )]), }; let profile = ProfileName::Custom("mydev".to_string()); let resolved = profile.resolve_profile(&workspace, &config).unwrap(); assert!( !resolved.deny.contains(&PathBuf::from("/data")), "custom profile extending devbox must not inherit /data into deny: {:?}", resolved.deny ); } #[test] #[cfg(all(feature = "enforce", unix))] fn custom_profile_not_found() { let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig::default(); let profile = ProfileName::Custom("nonexistent".to_string()); let result = profile.to_capability_set_with_config(&workspace, &config); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("not found"), "Unexpected error: {err}"); } #[test] fn mismatched_profile_names_reports_only_changed_custom_profiles() { let profile = |restrict_network| ProfileConfig { extends: Some("workspace".to_string()), restrict_network: Some(restrict_network), read_only: vec![], read_write: vec![], deny: vec![], }; let global = SandboxConfig { profiles: HashMap::from([ ("dev".to_string(), profile(false)), ("same".to_string(), profile(false)), ]), }; let project = SandboxConfig { profiles: HashMap::from([ ("dev".to_string(), profile(true)), ("same".to_string(), profile(false)), ("project-only".to_string(), profile(true)), ("devbox".to_string(), profile(true)), ]), }; assert_eq!(mismatched_profile_names(&global, &project), vec!["dev"]); } #[test] fn parse_toml_config() { let toml_str = r#" [profiles.devbox] extends = "workspace" restrict_network = true read_only = ["/data"] deny = ["/data/private"] [profiles.ci] extends = "strict" read_write = ["/tmp/ci-artifacts"] "#; let config: SandboxConfig = toml::from_str(toml_str).unwrap(); assert_eq!(config.profiles.len(), 2); assert!(config.profiles.contains_key("devbox")); assert!(config.profiles.contains_key("ci")); assert_eq!(config.profiles["devbox"].read_only, vec!["/data"]); assert_eq!(config.profiles["devbox"].deny, vec!["/data/private"]); } #[test] fn project_cannot_redefine_global_profile() { // Global "secure" with a real deny list must win over a project hollow-out. let mut config = SandboxConfig { profiles: HashMap::from([( "secure".to_string(), ProfileConfig { extends: Some("workspace".to_string()), restrict_network: Some(true), read_only: vec![], read_write: vec![], deny: vec!["/home/user/.ssh".to_string()], }, )]), }; let project = SandboxConfig { profiles: HashMap::from([ ( "secure".to_string(), ProfileConfig { extends: Some("workspace".to_string()), restrict_network: Some(false), read_only: vec![], read_write: vec!["/".to_string()], deny: vec![], }, ), ( "project-only".to_string(), ProfileConfig { extends: Some("workspace".to_string()), restrict_network: None, read_only: vec![], read_write: vec![], deny: vec!["./secrets".to_string()], }, ), ]), }; merge_project_profiles(&mut config, project); assert_eq!( config.profiles["secure"].deny, vec!["/home/user/.ssh".to_string()], "global deny must be preserved" ); assert_eq!(config.profiles["secure"].restrict_network, Some(true)); assert!( config.profiles["secure"].read_write.is_empty(), "project must not widen global read_write" ); assert!( config.profiles.contains_key("project-only"), "new project-only profile names are still allowed" ); } #[test] #[cfg(all(feature = "enforce", unix))] fn extends_off_returns_err_not_panic() { let workspace = std::env::current_dir().unwrap(); let config = SandboxConfig { profiles: HashMap::from([( "broken".to_string(), ProfileConfig { extends: Some("off".to_string()), restrict_network: None, read_only: vec![], read_write: vec![], deny: vec![], }, )]), }; let err = ProfileName::Custom("broken".to_string()) .resolve_profile(&workspace, &config) .expect_err("extends=off must Err"); let msg = err.to_string(); assert!( msg.contains("off") || msg.contains("none"), "unexpected error: {msg}" ); } #[test] #[cfg(all(feature = "enforce", unix))] fn resolve_off_returns_err_not_panic() { let workspace = std::env::current_dir().unwrap(); let err = ProfileName::Off .resolve_profile(&workspace, &SandboxConfig::default()) .expect_err("Off.resolve must Err"); assert!(err.to_string().contains("off"), "unexpected error: {err}"); } }