Files
agent-sandbox/src/config.rs

829 lines
24 KiB
Rust

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<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.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<SandboxMode> {
match (opts.blacklist, opts.whitelist) {
(_, Some(true)) => Some(SandboxMode::Whitelist),
(Some(true), _) => Some(SandboxMode::Blacklist),
_ => None,
}
}
fn merge_flag(cli: bool, profile: Option<bool>, globals: Option<bool>) -> bool {
if cli {
return true;
}
profile.or(globals).unwrap_or(false)
}
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_paths(
cli: Vec<PathBuf>,
profile: &[PathBuf],
globals: &[PathBuf],
) -> Result<Vec<PathBuf>, 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::<Result<Vec<_>, _>>()?;
Ok(config_paths.chain(cli_paths).collect())
}
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 resolve_command(
mut passthrough_args: Vec<OsString>,
profile: &Options,
globals: &Options,
) -> Result<(OsString, Vec<OsString>), 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<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>,
// 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> {
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<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 {
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 no_net: Option<bool>,
pub entrypoint: Option<CommandValue>,
pub command: Option<CommandValue>,
pub dry_run: Option<bool>,
pub chdir: Option<PathBuf>,
#[serde(default)]
pub rw: Vec<PathBuf>,
#[serde(default)]
pub ro: Vec<PathBuf>,
#[serde(default)]
pub mask: Vec<PathBuf>,
#[serde(default)]
pub bwrap_args: Vec<String>,
}
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<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)]
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<FileConfig> =
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::<Vec<_>>()
);
}
#[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::<Vec<_>>()
);
}
#[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::<Vec<_>>()
);
}
#[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<PathBuf> = expected.iter().map(PathBuf::from).collect();
assert_eq!(actual, &expected);
}
fn assert_command(cmd: &Option<CommandValue>, expected: &[&str]) {
let actual = cmd.clone().unwrap().into_vec();
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
assert_eq!(actual, expected);
}
}