Implement config file parsing and precedence with CLI
This commit is contained in:
544
src/config.rs
Normal file
544
src/config.rs
Normal file
@@ -0,0 +1,544 @@
|
||||
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.command.clone(),
|
||||
globals.command.clone(),
|
||||
);
|
||||
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)?,
|
||||
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 resolve_command(
|
||||
mut positional: Vec<OsString>,
|
||||
profile_cmd: Option<CommandValue>,
|
||||
globals_cmd: Option<CommandValue>,
|
||||
) -> (OsString, Vec<OsString>) {
|
||||
if !positional.is_empty() {
|
||||
let cmd = positional.remove(0);
|
||||
return (cmd, positional);
|
||||
}
|
||||
if let Some(config_cmd) = profile_cmd.or(globals_cmd) {
|
||||
let parts = config_cmd.into_vec();
|
||||
let cmd = OsString::from(&parts[0]);
|
||||
let args = parts[1..].iter().map(OsString::from).collect();
|
||||
return (cmd, args);
|
||||
}
|
||||
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
|
||||
return (OsString::from(cmd), vec![]);
|
||||
}
|
||||
(
|
||||
OsString::from("claude"),
|
||||
vec![OsString::from("--dangerously-skip-permissions")],
|
||||
)
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
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,
|
||||
})?;
|
||||
toml::from_str(&contents).map_err(|e| SandboxError::ConfigParse {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
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")]
|
||||
pub struct Options {
|
||||
pub blacklist: Option<bool>,
|
||||
pub whitelist: Option<bool>,
|
||||
pub hardened: Option<bool>,
|
||||
pub no_net: Option<bool>,
|
||||
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>,
|
||||
}
|
||||
|
||||
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)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"]
|
||||
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_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()
|
||||
},
|
||||
)]),
|
||||
};
|
||||
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()
|
||||
},
|
||||
)]),
|
||||
};
|
||||
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)
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user