Files
agent-sandbox/src/config.rs
T
2026-04-25 15:10:42 +02:00

552 lines
17 KiB
Rust

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<FileConfig>) -> Result<SandboxConfig, SandboxError> {
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<SandboxMode> {
match (opts.blacklist, opts.whitelist) {
(_, Some(true)) => Some(SandboxMode::Whitelist),
(Some(true), _) => Some(SandboxMode::Blacklist),
_ => None,
}
}
fn merge_flag(cli: Option<bool>, profile: Option<bool>, globals: Option<bool>) -> bool {
cli.or(profile).or(globals).unwrap_or(false)
}
fn merge_flag_with_default(
cli: Option<bool>,
profile: Option<bool>,
globals: Option<bool>,
default: bool,
) -> bool {
cli.or(profile).or(globals).unwrap_or(default)
}
fn merge_flag_pair(enable: bool, disable: bool) -> Option<bool> {
if enable {
Some(true)
} else if disable {
Some(false)
} else {
None
}
}
fn resolve_chdir(
cli: Option<PathBuf>,
profile: Option<PathBuf>,
globals: Option<PathBuf>,
) -> Result<PathBuf, SandboxError> {
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<String>,
profile: &[String],
globals: &[String],
) -> Result<Vec<BindSpec>, 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<BindSpec, SandboxError> {
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<BindSpec, SandboxError> {
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<PathBuf, SandboxError> {
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<String>) -> Result<Vec<String>, 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<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
globals.iter().chain(profile).cloned().chain(cli).collect()
}
fn parse_env_entries(raw: &[String]) -> Result<Vec<EnvEntry>, 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<EnvEntry>) -> Vec<EnvEntry> {
let mut seen: HashSet<String> = HashSet::new();
let mut result: Vec<EnvEntry> = 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<String>,
mut passthrough_args: Vec<OsString>,
profile: &Options,
globals: &Options,
) -> Result<(OsString, Vec<OsString>), 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<PathBuf> {
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<String, Options>,
#[serde(rename = "default-profile", default)]
pub default_profile: Option<String>,
#[serde(rename = "extra-config", default)]
pub extra_config: Option<PathBuf>,
// Collects unrecognized keys; deny_unknown_fields is incompatible with flatten.
#[serde(flatten)]
_unknown: HashMap<String, toml::Value>,
}
impl FileConfig {
pub fn load(path: &Path) -> Result<Self, SandboxError> {
Self::load_file(path)?.load_extra()
}
fn load_extra(self) -> Result<Self, SandboxError> {
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<Self, SandboxError> {
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<Self, SandboxError> {
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<Options, SandboxError> {
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<bool>,
pub whitelist: Option<bool>,
pub hardened: Option<bool>,
pub unshare_net: Option<bool>,
pub seccomp: Option<bool>,
pub env_filter: Option<bool>,
pub entrypoint: Option<CommandValue>,
pub command: Option<CommandValue>,
pub dry_run: Option<bool>,
pub chdir: Option<PathBuf>,
#[serde(default)]
pub rw: Vec<String>,
#[serde(default)]
pub ro: Vec<String>,
#[serde(default)]
pub mask: Vec<PathBuf>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub unsetenv: Vec<String>,
#[serde(default)]
pub bwrap_args: Vec<String>,
}
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<T>(mut base: Vec<T>, extra: Vec<T>) -> Vec<T> {
base.extend(extra);
base
}
fn pick_mode_flags(base: &Options, extra: &Options) -> (Option<bool>, Option<bool>) {
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<PathBuf, SandboxError> {
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<PathBuf, SandboxError> {
let expanded = expand_tilde(path)?;
require_absolute(&expanded)?;
Ok(expanded)
}
#[derive(Deserialize, Clone)]
#[serde(untagged)]
pub enum CommandValue {
Simple(String),
WithArgs(Vec<String>),
}
impl CommandValue {
pub fn into_vec(self) -> Vec<String> {
match self {
Self::Simple(s) => vec![s],
Self::WithArgs(v) => v,
}
}
fn into_binary_and_args(self) -> (OsString, Vec<OsString>) {
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<PathBuf> {
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<PathBuf, SandboxError> {
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<PathBuf, SandboxError> {
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;