use std::collections::HashMap; use std::ffi::OsString; use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::cli::Args; use crate::{SandboxConfig, SandboxError, SandboxMode}; pub fn build(args: Args, file_config: Option) -> Result { let (mut globals, mut profile) = match file_config { Some(c) => { let profile = c.resolve_profile(args.profile.as_deref())?; (c.options, profile) } None => (Options::default(), Options::default()), }; validate_mode(&globals)?; validate_mode(&profile)?; globals.validate_paths()?; profile.validate_paths()?; let (command, command_args) = resolve_command(args.command_and_args, &profile, &globals)?; let command = resolve_binary(&command) .ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?; Ok(SandboxConfig { mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals), hardened: merge_flag(args.hardened, profile.hardened, globals.hardened), no_net: merge_flag(args.no_net, profile.no_net, globals.no_net), dry_run: merge_flag(args.dry_run, profile.dry_run, globals.dry_run), chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?, extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?, extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?, mask: merge_vecs(args.mask, &profile.mask, &globals.mask), bwrap_args: split_bwrap_args(merge_vecs( args.bwrap_args, &profile.bwrap_args, &globals.bwrap_args, ))?, command, command_args, }) } fn merge_mode( cli_blacklist: bool, cli_whitelist: bool, profile: &Options, globals: &Options, ) -> SandboxMode { if cli_whitelist { return SandboxMode::Whitelist; } if cli_blacklist { return SandboxMode::Blacklist; } resolve_mode(profile) .or_else(|| resolve_mode(globals)) .unwrap_or(SandboxMode::Blacklist) } fn resolve_mode(opts: &Options) -> Option { match (opts.blacklist, opts.whitelist) { (_, Some(true)) => Some(SandboxMode::Whitelist), (Some(true), _) => Some(SandboxMode::Blacklist), _ => None, } } fn merge_flag(cli: bool, profile: Option, globals: Option) -> bool { if cli { return true; } profile.or(globals).unwrap_or(false) } fn resolve_chdir( cli: Option, profile: Option, globals: Option, ) -> Result { match cli.or(profile).or(globals) { Some(p) => std::fs::canonicalize(&p).map_err(|_| SandboxError::ChdirMissing(p)), None => std::env::current_dir().map_err(SandboxError::CurrentDirUnavailable), } } fn merge_paths( cli: Vec, profile: &[PathBuf], globals: &[PathBuf], ) -> Result, SandboxError> { let config_paths = globals.iter().chain(profile).cloned(); let cli_paths = cli .into_iter() .map(|p| std::fs::canonicalize(&p).map_err(|_| SandboxError::PathMissing(p))) .collect::, _>>()?; Ok(config_paths.chain(cli_paths).collect()) } fn split_bwrap_args(raw: Vec) -> Result, SandboxError> { let mut out = Vec::new(); for value in &raw { let words = shlex::split(value).ok_or_else(|| SandboxError::InvalidBwrapArg(value.clone()))?; out.extend(words); } Ok(out) } fn merge_vecs(cli: Vec, profile: &[T], globals: &[T]) -> Vec { globals.iter().chain(profile).cloned().chain(cli).collect() } fn resolve_command( mut passthrough_args: Vec, profile: &Options, globals: &Options, ) -> Result<(OsString, Vec), SandboxError> { let entrypoint = profile.entrypoint.clone().or(globals.entrypoint.clone()); let command = profile.command.clone().or(globals.command.clone()); if let Some(ep) = entrypoint { let (cmd, mut args) = ep.into_binary_and_args(); if !passthrough_args.is_empty() { args.extend(passthrough_args); } else if let Some(default_cmd) = command { args.extend(default_cmd.into_vec().into_iter().map(OsString::from)); } return Ok((cmd, args)); } if !passthrough_args.is_empty() { return Ok((passthrough_args.remove(0), passthrough_args)); } if let Some(config_cmd) = command { return Ok(config_cmd.into_binary_and_args()); } Err(SandboxError::NoCommand) } fn resolve_binary(name: &OsString) -> Option { let path = PathBuf::from(name); if path.is_absolute() || path.components().count() > 1 { return path.is_file().then_some(path); } std::env::var_os("PATH").and_then(|path_var| { std::env::split_paths(&path_var) .map(|dir| dir.join(name)) .find(|p| p.is_file()) }) } #[derive(Deserialize, Default)] pub struct FileConfig { #[serde(flatten)] pub options: Options, #[serde(default)] pub profile: HashMap, // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. #[serde(flatten)] _unknown: HashMap, } impl FileConfig { pub fn load(path: &Path) -> Result { let contents = std::fs::read_to_string(path).map_err(|e| SandboxError::ConfigRead { path: path.to_path_buf(), source: e, })?; Self::parse(&contents).map_err(|e| match e { SandboxError::ConfigParse { source, .. } => SandboxError::ConfigParse { path: path.to_path_buf(), source, }, other => other, }) } fn parse(contents: &str) -> Result { let config: Self = toml::from_str(contents).map_err(|e| SandboxError::ConfigParse { path: PathBuf::new(), source: e, })?; if let Some(key) = config._unknown.keys().next() { return Err(SandboxError::UnknownConfigKey(key.clone())); } Ok(config) } fn resolve_profile(&self, name: Option<&str>) -> Result { match name { Some(n) => self .profile .get(n) .cloned() .ok_or_else(|| SandboxError::ProfileNotFound(n.to_string())), None => Ok(Options::default()), } } } #[derive(Deserialize, Default, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Options { pub blacklist: Option, pub whitelist: Option, pub hardened: Option, pub no_net: Option, pub entrypoint: Option, pub command: Option, pub dry_run: Option, pub chdir: Option, #[serde(default)] pub rw: Vec, #[serde(default)] pub ro: Vec, #[serde(default)] pub mask: Vec, #[serde(default)] pub bwrap_args: Vec, } impl Options { fn validate_paths(&mut self) -> Result<(), SandboxError> { for p in &mut self.rw { *p = expand_and_canonicalize(p)?; } for p in &mut self.ro { *p = expand_and_canonicalize(p)?; } if let Some(ref chdir) = self.chdir { self.chdir = Some(expand_and_canonicalize(chdir)?); } for p in &mut self.mask { *p = expand_and_require_absolute(p)?; } Ok(()) } } fn expand_and_require_absolute(path: &Path) -> Result { let expanded = expand_tilde(path)?; require_absolute(&expanded)?; Ok(expanded) } #[derive(Deserialize, Clone)] #[serde(untagged)] pub enum CommandValue { Simple(String), WithArgs(Vec), } impl CommandValue { pub fn into_vec(self) -> Vec { match self { Self::Simple(s) => vec![s], Self::WithArgs(v) => v, } } fn into_binary_and_args(self) -> (OsString, Vec) { let mut parts = self.into_vec(); let binary = OsString::from(parts.remove(0)); let args = parts.into_iter().map(OsString::from).collect(); (binary, args) } } pub fn find_config_path(explicit: Option<&Path>) -> Option { if let Some(p) = explicit { return Some(p.to_path_buf()); } let config_dir = std::env::var("XDG_CONFIG_HOME") .ok() .filter(|s| !s.is_empty()) .map(PathBuf::from) .or_else(|| { std::env::var("HOME") .ok() .filter(|s| !s.is_empty()) .map(|h| PathBuf::from(h).join(".config")) })?; let path = config_dir.join("agent-sandbox/config.toml"); path.exists().then_some(path) } fn validate_mode(opts: &Options) -> Result<(), SandboxError> { if opts.blacklist == Some(true) && opts.whitelist == Some(true) { return Err(SandboxError::ConflictingMode); } Ok(()) } fn expand_tilde(path: &Path) -> Result { let s = path.to_string_lossy(); if !s.starts_with('~') { return Ok(path.to_path_buf()); } if s.len() > 1 && !s.starts_with("~/") { return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf())); } let home = std::env::var("HOME") .ok() .filter(|h| !h.is_empty()) .ok_or(SandboxError::HomeNotSet)?; Ok(PathBuf::from(home).join(s.strip_prefix("~/").unwrap_or(""))) } fn require_absolute(path: &Path) -> Result<(), SandboxError> { if !path.is_absolute() { return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf())); } Ok(()) } fn expand_and_canonicalize(path: &Path) -> Result { let expanded = expand_tilde(path)?; require_absolute(&expanded)?; std::fs::canonicalize(&expanded).map_err(|e| SandboxError::ConfigRead { path: expanded, source: e, }) } #[cfg(test)] mod tests { use std::sync::LazyLock; use super::*; const FULL_CONFIG_TOML: &str = r#" hardened = true no-net = true rw = ["/tmp/a", "/tmp/b"] command = "zsh" [profile.claude] rw = ["/home/user/.config/claude"] ro = ["/etc/claude", "/etc/shared"] entrypoint = ["claude", "--dangerously-skip-permissions"] command = ["bash", "-c", "echo hi"] [profile.codex] whitelist = true dry-run = true chdir = "/home/user/project" rw = ["/home/user/.codex"] "#; static CONFIG: LazyLock = LazyLock::new(|| toml::from_str(FULL_CONFIG_TOML).unwrap()); #[test] fn globals_scalars() { assert_eq!(CONFIG.options.hardened, Some(true)); assert_eq!(CONFIG.options.no_net, Some(true)); } #[test] fn globals_multi_element_vecs() { assert_paths(&CONFIG.options.rw, &["/tmp/a", "/tmp/b"]); } #[test] fn globals_simple_command() { assert_command(&CONFIG.options.command, &["zsh"]); } #[test] fn unset_vecs_default_empty() { assert!(CONFIG.options.ro.is_empty()); } #[test] fn unset_scalars_are_none() { assert_eq!(CONFIG.profile["claude"].hardened, None); } #[test] fn profile_multi_element_vecs() { assert_paths( &CONFIG.profile["claude"].ro, &["/etc/claude", "/etc/shared"], ); } #[test] fn profile_entrypoint_with_args() { assert_command( &CONFIG.profile["claude"].entrypoint, &["claude", "--dangerously-skip-permissions"], ); } #[test] fn profile_command_with_args() { assert_command( &CONFIG.profile["claude"].command, &["bash", "-c", "echo hi"], ); } #[test] fn profile_kebab_case_scalars() { let codex = &CONFIG.profile["codex"]; assert_eq!(codex.whitelist, Some(true)); assert_eq!(codex.dry_run, Some(true)); assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project"))); } fn args_with_command() -> Args { Args { command_and_args: vec!["/usr/bin/true".into()], ..Args::default() } } #[test] fn build_hardened_from_globals() { let file_config = FileConfig { options: Options { hardened: Some(true), ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert!(config.hardened); } #[test] fn build_profile_overrides_globals() { let file_config = FileConfig { options: Options { hardened: Some(true), ..Options::default() }, profile: HashMap::from([( "relaxed".into(), Options { hardened: Some(false), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("relaxed".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.hardened); } #[test] fn build_cli_flag_overrides_profile() { let file_config = FileConfig { profile: HashMap::from([( "nonet".into(), Options { no_net: Some(false), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("nonet".into()), no_net: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(config.no_net); } #[test] fn build_cli_mode_overrides_profile() { let file_config = FileConfig { profile: HashMap::from([( "wl".into(), Options { whitelist: Some(true), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("wl".into()), blacklist: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(matches!(config.mode, SandboxMode::Blacklist)); } #[test] fn build_rw_paths_accumulate() { let file_config = FileConfig { options: Options { rw: vec![PathBuf::from("/tmp")], ..Options::default() }, profile: HashMap::from([( "extra".into(), Options { rw: vec![PathBuf::from("/usr")], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), extra_rw: vec![PathBuf::from("/var")], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.extra_rw.len(), 3); } #[test] fn build_command_from_profile() { let file_config = FileConfig { profile: HashMap::from([( "test".into(), Options { command: Some(CommandValue::WithArgs(vec![ "/usr/bin/true".into(), "--flag".into(), ])), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("test".into()), ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); assert_eq!(config.command_args, vec![OsString::from("--flag")]); } #[test] fn build_cli_command_overrides_config() { let file_config = FileConfig { options: Options { command: Some(CommandValue::Simple("/usr/bin/false".into())), ..Options::default() }, ..FileConfig::default() }; let args = Args { command_and_args: vec!["/usr/bin/true".into()], ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); } #[test] fn build_no_file_config() { let config = build(args_with_command(), None).unwrap(); assert!(matches!(config.mode, SandboxMode::Blacklist)); assert!(!config.hardened); } #[test] fn build_missing_profile_errors() { let args = Args { profile: Some("nope".into()), ..args_with_command() }; assert!(matches!( build(args, Some(FileConfig::default())), Err(SandboxError::ProfileNotFound(_)) )); } #[test] fn build_conflicting_mode_errors() { let file_config = FileConfig { options: Options { blacklist: Some(true), whitelist: Some(true), ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConflictingMode) )); } #[test] fn build_tilde_expansion_in_config_paths() { let home = std::env::var("HOME").unwrap(); let file_config = FileConfig { options: Options { rw: vec![PathBuf::from("~/")], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.extra_rw, vec![PathBuf::from(&home)]); } #[test] fn build_relative_config_path_rejected() { let file_config = FileConfig { options: Options { rw: vec![PathBuf::from("relative/path")], ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConfigPathNotAbsolute(_)) )); } #[test] fn build_chdir_from_config() { let file_config = FileConfig { options: Options { chdir: Some(PathBuf::from("/tmp")), ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.chdir, std::fs::canonicalize("/tmp").unwrap()); } #[test] fn build_mask_accumulates() { let file_config = FileConfig { options: Options { mask: vec![PathBuf::from("/tmp/a")], ..Options::default() }, profile: HashMap::from([( "extra".into(), Options { mask: vec![PathBuf::from("/tmp/b")], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), mask: vec![PathBuf::from("/tmp/c")], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.mask.len(), 3); } #[test] fn unknown_option_rejected() { let toml = r#" hardened = true bogus = "nope" "#; assert!(matches!( FileConfig::parse(toml), Err(SandboxError::UnknownConfigKey(_)) )); } #[test] fn unknown_profile_option_rejected() { let toml = r#" [profile.test] hardened = true frobnicate = 42 "#; assert!(matches!( FileConfig::parse(toml), Err(SandboxError::ConfigParse { .. }) )); } #[test] fn entrypoint_with_passthrough_args() { let (cmd, args) = resolve_command( vec!["--verbose".into(), "--model".into(), "opus".into()], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ "claude".into(), "--dangerously-skip-permissions".into(), ])), command: Some(CommandValue::Simple("--default-flag".into())), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec![ "--dangerously-skip-permissions", "--verbose", "--model", "opus" ] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn entrypoint_without_passthrough_uses_command_defaults() { let (cmd, args) = resolve_command( vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), command: Some(CommandValue::WithArgs(vec![ "--dangerously-skip-permissions".into(), "--verbose".into(), ])), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec!["--dangerously-skip-permissions", "--verbose"] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn entrypoint_without_passthrough_or_command() { let (cmd, args) = resolve_command( vec![], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ "claude".into(), "--dangerously-skip-permissions".into(), ])), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!(args, vec![OsString::from("--dangerously-skip-permissions")]); } #[test] fn profile_entrypoint_overrides_global() { let (cmd, _) = resolve_command( vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), ..Options::default() }, &Options { entrypoint: Some(CommandValue::Simple("codex".into())), ..Options::default() }, ) .unwrap(); assert_eq!(cmd, "claude"); } #[test] fn global_entrypoint_with_profile_command() { let (cmd, args) = resolve_command( vec![], &Options { command: Some(CommandValue::WithArgs(vec![ "--dangerously-skip-permissions".into(), "--verbose".into(), ])), ..Options::default() }, &Options { entrypoint: Some(CommandValue::Simple("claude".into())), ..Options::default() }, ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec!["--dangerously-skip-permissions", "--verbose"] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn no_command_errors() { let result = resolve_command(vec![], &Options::default(), &Options::default()); assert!(matches!(result, Err(SandboxError::NoCommand))); } fn assert_paths(actual: &[PathBuf], expected: &[&str]) { let expected: Vec = expected.iter().map(PathBuf::from).collect(); assert_eq!(actual, &expected); } fn assert_command(cmd: &Option, expected: &[&str]) { let actual = cmd.clone().unwrap().into_vec(); let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(actual, expected); } }