use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::cli::Args; use crate::{BindSpec, EnvEntry, 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.entrypoint, args.command_and_args, &profile, &globals)?; let command = resolve_binary(&command) .ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?; let env = dedupe_env_last_wins(parse_env_entries(&merge_vecs( args.env, &profile.env, &globals.env, ))?); let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv); reject_env_key_conflicts(&env, &unsetenv)?; Ok(SandboxConfig { mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals), hardened: merge_flag( merge_flag_pair(args.hardened, args.no_hardened), profile.hardened, globals.hardened, ), unshare_net: merge_flag( merge_flag_pair(args.unshare_net, args.share_net), profile.unshare_net, globals.unshare_net, ), seccomp: merge_flag_with_default( merge_flag_pair(args.seccomp, args.no_seccomp), profile.seccomp, globals.seccomp, true, ), env_filter: merge_flag_with_default( merge_flag_pair(args.env_filter, args.no_env_filter), profile.env_filter, globals.env_filter, true, ), dry_run: merge_flag( merge_flag_pair(args.dry_run, args.no_dry_run), profile.dry_run, globals.dry_run, ), chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?, extra_rw: merge_bind_specs(args.extra_rw, &profile.rw, &globals.rw)?, extra_ro: merge_bind_specs(args.extra_ro, &profile.ro, &globals.ro)?, mask: merge_vecs(args.mask, &profile.mask, &globals.mask), env, unsetenv, 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: Option, profile: Option, globals: Option) -> bool { cli.or(profile).or(globals).unwrap_or(false) } fn merge_flag_with_default( cli: Option, profile: Option, globals: Option, default: bool, ) -> bool { cli.or(profile).or(globals).unwrap_or(default) } fn merge_flag_pair(enable: bool, disable: bool) -> Option { if enable { Some(true) } else if disable { Some(false) } else { None } } 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_bind_specs( cli: Vec, profile: &[String], globals: &[String], ) -> Result, SandboxError> { let config_specs = globals .iter() .chain(profile) .map(|raw| resolve_config_bind_spec(raw)); let cli_specs = cli.iter().map(|raw| resolve_cli_bind_spec(raw)); config_specs.chain(cli_specs).collect() } fn resolve_cli_bind_spec(raw: &str) -> Result { let (src, dst) = split_bind_spec(raw)?; let source = prepare_cli_bind_path(Path::new(src))?; let target = match dst { Some(d) => { let p = PathBuf::from(d); require_absolute(&p)?; p } None => source.clone(), }; Ok(BindSpec { source, target }) } fn resolve_config_bind_spec(raw: &str) -> Result { let (src, dst) = split_bind_spec(raw)?; let source = expand_and_require_existing(Path::new(src))?; let target = match dst { Some(d) => expand_and_require_absolute(Path::new(d))?, None => source.clone(), }; Ok(BindSpec { source, target }) } fn split_bind_spec(raw: &str) -> Result<(&str, Option<&str>), SandboxError> { match raw.split_once(':') { Some((src, dst)) if !src.is_empty() && !dst.is_empty() => Ok((src, Some(dst))), Some(_) => Err(SandboxError::InvalidBindSpec(raw.to_string())), None if raw.is_empty() => Err(SandboxError::InvalidBindSpec(raw.to_string())), None => Ok((raw, None)), } } fn prepare_cli_bind_path(path: &Path) -> Result { let absolute = std::path::absolute(path).map_err(|_| SandboxError::PathMissing(path.to_path_buf()))?; if !absolute.exists() { return Err(SandboxError::PathMissing(absolute)); } Ok(absolute) } 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 parse_env_entries(raw: &[String]) -> Result, SandboxError> { raw.iter() .map(|s| match s.split_once('=') { None if s.is_empty() => Err(SandboxError::InvalidEnvEntry(s.clone())), None => Ok(EnvEntry::Keep(s.clone())), Some(("", _)) => Err(SandboxError::InvalidEnvEntry(s.clone())), Some((key, value)) => Ok(EnvEntry::Set(key.to_string(), value.to_string())), }) .collect() } fn dedupe_env_last_wins(entries: Vec) -> Vec { let mut seen: HashSet = HashSet::new(); let mut result: Vec = Vec::new(); for entry in entries.into_iter().rev() { if seen.insert(entry.key().to_string()) { result.push(entry); } } result.reverse(); result } fn reject_env_key_conflicts(env: &[EnvEntry], unsetenv: &[String]) -> Result<(), SandboxError> { let env_keys: HashSet<&str> = env.iter().map(EnvEntry::key).collect(); let unsetenv_keys: HashSet<&str> = unsetenv.iter().map(String::as_str).collect(); match env_keys.intersection(&unsetenv_keys).next() { Some(&key) => Err(SandboxError::ConflictingEnvKey(key.to_string())), None => Ok(()), } } fn resolve_command( cli_entrypoint: Option, mut passthrough_args: Vec, profile: &Options, globals: &Options, ) -> Result<(OsString, Vec), SandboxError> { let entrypoint = cli_entrypoint .map(CommandValue::Simple) .or_else(|| profile.entrypoint.clone()) .or_else(|| 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, #[serde(rename = "default-profile", default)] pub default_profile: Option, #[serde(rename = "extra-config", default)] pub extra_config: Option, // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. #[serde(flatten)] _unknown: HashMap, } impl FileConfig { pub fn load(path: &Path) -> Result { Self::load_file(path)?.load_extra() } fn load_extra(self) -> Result { let Some(ref extra_path) = self.extra_config else { return Ok(self); }; let resolved = expand_and_require_absolute(extra_path)?; let extra = match Self::load_file(&resolved) { Ok(c) => c, Err(SandboxError::ConfigRead { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { return Ok(self); } Err(e) => return Err(e), }; if extra.extra_config.is_some() { return Err(SandboxError::NestedExtraConfig(resolved)); } Ok(self.merge_with(extra)) } fn load_file(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 merge_with(self, extra: FileConfig) -> FileConfig { let mut profile = self.profile; for (profile_name, profile_options) in extra.profile { let merged = match profile.remove(&profile_name) { Some(existing) => existing.merge_with(profile_options), None => profile_options, }; profile.insert(profile_name, merged); } FileConfig { options: self.options.merge_with(extra.options), profile, default_profile: extra.default_profile.or(self.default_profile), extra_config: None, _unknown: HashMap::new(), } } 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.or(self.default_profile.as_deref()) { 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 unshare_net: Option, pub seccomp: Option, pub env_filter: 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 env: Vec, #[serde(default)] pub unsetenv: Vec, #[serde(default)] pub bwrap_args: Vec, } impl Options { fn merge_with(self, extra: Options) -> Options { let (blacklist, whitelist) = pick_mode_flags(&self, &extra); Options { blacklist, whitelist, hardened: extra.hardened.or(self.hardened), unshare_net: extra.unshare_net.or(self.unshare_net), seccomp: extra.seccomp.or(self.seccomp), env_filter: extra.env_filter.or(self.env_filter), entrypoint: extra.entrypoint.or(self.entrypoint), command: extra.command.or(self.command), dry_run: extra.dry_run.or(self.dry_run), chdir: extra.chdir.or(self.chdir), rw: append(self.rw, extra.rw), ro: append(self.ro, extra.ro), mask: append(self.mask, extra.mask), env: append(self.env, extra.env), unsetenv: append(self.unsetenv, extra.unsetenv), bwrap_args: append(self.bwrap_args, extra.bwrap_args), } } fn validate_paths(&mut self) -> Result<(), SandboxError> { 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 append(mut base: Vec, extra: Vec) -> Vec { base.extend(extra); base } fn pick_mode_flags(base: &Options, extra: &Options) -> (Option, Option) { if extra.blacklist.is_some() || extra.whitelist.is_some() { (extra.blacklist, extra.whitelist) } else { (base.blacklist, base.whitelist) } } fn expand_and_require_existing(path: &Path) -> Result { let expanded = expand_and_require_absolute(path)?; if !expanded.exists() { return Err(SandboxError::PathMissing(expanded)); } Ok(expanded) } 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)] #[path = "../tests/unit/config.rs"] mod tests;