use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::cli::Args; use crate::session_key::SessionKey; 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)?; (c.options, profile) } None => ( Options::default(), ResolvedProfile { name: join_profile_names(&args.profile), options: Options::default(), }, ), }; validate_mode(&globals)?; validate_mode(&profile.options)?; globals.validate_paths()?; profile.options.validate_paths()?; let mode = merge_mode(args.blacklist, args.whitelist, &profile.options, &globals); let chdir = resolve_chdir( args.chdir.clone(), profile.options.chdir.clone(), globals.chdir.clone(), )?; let persistent_tmp_key = resolve_persistent_tmp_key(&args, &profile, &globals, &mode, &chdir); let (command, command_args) = resolve_command( args.entrypoint, args.command_and_args, &profile.options, &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.options.env, &globals.env, ))?); let unsetenv = merge_vecs(args.unsetenv, &profile.options.unsetenv, &globals.unsetenv); reject_env_key_conflicts(&env, &unsetenv)?; Ok(SandboxConfig { mode, hardened: merge_flag( merge_flag_pair(args.hardened, args.no_hardened), profile.options.hardened, globals.hardened, ), unshare_net: merge_flag( merge_flag_pair(args.unshare_net, args.share_net), profile.options.unshare_net, globals.unshare_net, ), seccomp: merge_flag_with_default( merge_flag_pair(args.seccomp, args.no_seccomp), profile.options.seccomp, globals.seccomp, true, ), env_filter: merge_flag_with_default( merge_flag_pair(args.env_filter, args.no_env_filter), profile.options.env_filter, globals.env_filter, true, ), dry_run: merge_flag( merge_flag_pair(args.dry_run, args.no_dry_run), profile.options.dry_run, globals.dry_run, ), chdir, extra_rw: merge_bind_specs(args.extra_rw, &profile.options.rw, &globals.rw)?, extra_ro: merge_bind_specs(args.extra_ro, &profile.options.ro, &globals.ro)?, mask: merge_vecs(args.mask, &profile.options.mask, &globals.mask), env, unsetenv, bwrap_args: split_bwrap_args(merge_vecs( args.bwrap_args, &profile.options.bwrap_args, &globals.bwrap_args, ))?, command, command_args, persistent_tmp_key, }) } fn resolve_persistent_tmp_key( args: &Args, profile: &ResolvedProfile, globals: &Options, mode: &SandboxMode, chdir: &Path, ) -> Option { if matches!(mode, SandboxMode::Blacklist) { return None; } let enabled = merge_flag( merge_flag_pair(args.persistent_tmp, args.no_persistent_tmp), profile.options.persistent_tmp, globals.persistent_tmp, ); if !enabled { return None; } let label = args .persistent_key .clone() .or_else(|| profile.options.persistent_key.clone()) .or_else(|| globals.persistent_key.clone()); Some( label.unwrap_or_else(|| SessionKey::default().derive(profile.name.as_deref(), mode, chdir)), ) } 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::Whitelist) } 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 profiles: HashMap, #[serde(rename = "extra-config", default)] pub extra_config: Option, // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. #[serde(flatten)] _unknown: HashMap, } #[derive(Default)] pub struct ResolvedProfile { pub name: Option, pub options: Options, } 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 profiles = self.profiles; for (profile_name, profile_options) in extra.profiles { let merged = match profiles.remove(&profile_name) { Some(existing) => existing.merge_with(profile_options), None => profile_options, }; profiles.insert(profile_name, merged); } FileConfig { options: self.options.merge_with(extra.options), profiles, 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, selected: &[String]) -> Result { let names = if selected.is_empty() { self.options .profile .clone() .map(|p| p.0) .unwrap_or_default() } else { selected.to_vec() }; if names.is_empty() { return Ok(ResolvedProfile::default()); } let merged = self .linearize_profiles(&names)? .into_iter() .map(|name| self.profiles[&name].clone()) .fold(Options::default(), |base, next| base.merge_with(next)); Ok(ResolvedProfile { name: join_profile_names(&names), options: merged, }) } fn linearize_profiles(&self, names: &[String]) -> Result, SandboxError> { let mut walk = ProfileWalk::new(&self.profiles); for name in names { walk.visit(name)?; } Ok(walk.ordered) } } struct ProfileWalk<'a> { profiles: &'a HashMap, path: Vec, ordered: Vec, } impl<'a> ProfileWalk<'a> { fn new(profiles: &'a HashMap) -> Self { Self { profiles, path: Vec::new(), ordered: Vec::new(), } } fn visit(&mut self, name: &str) -> Result<(), SandboxError> { if self.path.iter().any(|seen| seen == name) { self.path.push(name.to_string()); return Err(SandboxError::ProfileCycle(self.path.clone())); } if self.ordered.iter().any(|done| done == name) { return Ok(()); } let options = self .profiles .get(name) .ok_or_else(|| SandboxError::ProfileNotFound(name.to_string()))?; self.path.push(name.to_string()); for parent in options.profile.iter().flat_map(|p| &p.0) { self.visit(parent)?; } self.path.pop(); self.ordered.push(name.to_string()); Ok(()) } } fn join_profile_names(names: &[String]) -> Option { (!names.is_empty()).then(|| names.join(",")) } #[derive(Deserialize, Default, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Options { pub profile: Option, 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 persistent_tmp: Option, pub persistent_key: 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 { profile: extra.profile.or(self.profile), 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), persistent_tmp: extra.persistent_tmp.or(self.persistent_tmp), persistent_key: extra.persistent_key.or(self.persistent_key), 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, Debug, PartialEq)] #[serde(from = "OneOrMany")] pub struct ProfileNames(pub Vec); #[derive(Deserialize)] #[serde(untagged)] enum OneOrMany { One(String), Many(Vec), } impl From for ProfileNames { fn from(value: OneOrMany) -> Self { match value { OneOrMany::One(name) => Self(vec![name]), OneOrMany::Many(names) => Self(names), } } } impl From<&str> for ProfileNames { fn from(name: &str) -> Self { Self(vec![name.to_string()]) } } #[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;