diff --git a/Cargo.lock b/Cargo.lock index 5328d96..e7d8fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,9 @@ version = "0.1.0" dependencies = [ "clap", "glob", + "serde", "tempfile", + "toml", ] [[package]] @@ -325,6 +327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -360,6 +363,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + [[package]] name = "strsim" version = "0.11.1" @@ -390,6 +402,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -475,6 +526,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 98f6256..d430cff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } glob = "0.3" +serde = { version = "1", features = ["derive"] } +toml = "1" [dev-dependencies] tempfile = "3" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6688475 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,60 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug, Default)] +#[command( + name = "agent-sandbox", + version, + about = "Sandbox agentic coding assistants with bubblewrap" +)] +pub struct Args { + /// Blacklist mode: bind / read-only, overlay sensitive paths (default) + #[arg(long, conflicts_with = "whitelist")] + pub blacklist: bool, + + /// Whitelist mode: only explicitly listed minimal paths visible + #[arg(long)] + pub whitelist: bool, + + /// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run + #[arg(long)] + pub hardened: bool, + + /// Unshare the network namespace + #[arg(long)] + pub no_net: bool, + + /// Bind an extra path read-write (repeatable) + #[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)] + pub extra_rw: Vec, + + /// Bind an extra path read-only (repeatable) + #[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)] + pub extra_ro: Vec, + + /// Print the bwrap command without executing + #[arg(long)] + pub dry_run: bool, + + /// Working directory inside the sandbox (default: current directory) + #[arg(long, value_name = "PATH")] + pub chdir: Option, + + /// Use a named profile from the config file + #[arg(long, conflicts_with = "no_config")] + pub profile: Option, + + /// Path to config file (default: $XDG_CONFIG_HOME/agent-sandbox/config.toml) + #[arg(long = "config", value_name = "PATH")] + pub config_path: Option, + + /// Skip loading the config file entirely + #[arg(long)] + pub no_config: bool, + + /// Command and arguments to run inside the sandbox + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub command_and_args: Vec, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1606fcf --- /dev/null +++ b/src/config.rs @@ -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) -> 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.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 { + match (opts.blacklist, opts.whitelist) { + (_, Some(true)) => Some(SandboxMode::Whitelist), + (Some(true), _) => Some(SandboxMode::Blacklist), + _ => None, + } +} + +fn merge_flag(cli: bool, profile: Option, globals: Option) -> bool { + if cli { + return true; + } + profile.or(globals).unwrap_or(false) +} + +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_paths( + cli: Vec, + profile: &[PathBuf], + globals: &[PathBuf], +) -> Result, 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::, _>>()?; + Ok(config_paths.chain(cli_paths).collect()) +} + +fn resolve_command( + mut positional: Vec, + profile_cmd: Option, + globals_cmd: Option, +) -> (OsString, Vec) { + 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 { + 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, +} + +impl FileConfig { + pub fn load(path: &Path) -> Result { + 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 { + 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, + pub whitelist: Option, + pub hardened: Option, + pub no_net: Option, + pub command: Option, + pub dry_run: Option, + pub chdir: Option, + #[serde(default)] + pub rw: Vec, + #[serde(default)] + pub ro: Vec, +} + +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), +} + +impl CommandValue { + pub fn into_vec(self) -> Vec { + match self { + Self::Simple(s) => vec![s], + Self::WithArgs(v) => v, + } + } +} + +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)] +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 = + 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 = expected.iter().map(PathBuf::from).collect(); + assert_eq!(actual, &expected); + } + + fn assert_command(cmd: &Option, expected: &[&str]) { + let actual = cmd.clone().unwrap().into_vec(); + let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); + assert_eq!(actual, expected); + } +} diff --git a/src/errors.rs b/src/errors.rs index 157e8d2..02f41dd 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -7,12 +7,22 @@ pub enum SandboxError { BwrapNotFound, CommandNotFound(PathBuf), CommandNotExecutable(PathBuf), - RwPathMissing(PathBuf), - RoPathMissing(PathBuf), + PathMissing(PathBuf), ChdirMissing(PathBuf), CurrentDirUnavailable(std::io::Error), GlobPattern(glob::PatternError), Io(std::io::Error), + ConfigRead { + path: PathBuf, + source: std::io::Error, + }, + ConfigParse { + path: PathBuf, + source: toml::de::Error, + }, + ProfileNotFound(String), + ConflictingMode, + ConfigPathNotAbsolute(PathBuf), } impl std::fmt::Display for SandboxError { @@ -34,12 +44,25 @@ impl std::fmt::Display for SandboxError { Self::CommandNotExecutable(p) => { write!(f, "command is not executable: {}", p.display()) } - Self::RwPathMissing(p) => write!(f, "--rw path does not exist: {}", p.display()), - Self::RoPathMissing(p) => write!(f, "--ro path does not exist: {}", p.display()), + Self::PathMissing(p) => write!(f, "path does not exist: {}", p.display()), Self::ChdirMissing(p) => write!(f, "--chdir path does not exist: {}", p.display()), Self::CurrentDirUnavailable(e) => write!(f, "cannot determine current directory: {e}"), Self::GlobPattern(e) => write!(f, "invalid glob pattern: {e}"), Self::Io(e) => write!(f, "I/O error: {e}"), + Self::ConfigRead { path, source } => { + write!(f, "cannot read config file '{}': {source}", path.display()) + } + Self::ConfigParse { path, source } => { + write!(f, "cannot parse config file '{}': {source}", path.display()) + } + Self::ProfileNotFound(name) => write!(f, "profile not found in config: {name}"), + Self::ConflictingMode => write!( + f, + "config section sets both blacklist and whitelist to true" + ), + Self::ConfigPathNotAbsolute(p) => { + write!(f, "config path is not absolute: {}", p.display()) + } } } } @@ -50,6 +73,8 @@ impl std::error::Error for SandboxError { Self::CurrentDirUnavailable(e) => Some(e), Self::GlobPattern(e) => Some(e), Self::Io(e) => Some(e), + Self::ConfigRead { source, .. } => Some(source), + Self::ConfigParse { source, .. } => Some(source), _ => None, } } diff --git a/src/lib.rs b/src/lib.rs index 0d5cba1..4fcfc8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ mod agents; mod blacklist; +pub mod cli; +pub mod config; mod errors; mod preflight; mod sandbox; diff --git a/src/main.rs b/src/main.rs index 741d6c4..37e097f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,146 +1,28 @@ -use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; use std::process; use clap::Parser; -use agent_sandbox::{SandboxConfig, SandboxMode}; - -#[derive(Parser, Debug)] -#[command( - name = "agent-sandbox", - version, - about = "Sandbox agentic coding assistants with bubblewrap" -)] -struct Args { - /// Blacklist mode: bind / read-only, overlay sensitive paths (default) - #[arg(long, conflicts_with = "whitelist")] - blacklist: bool, - - /// Whitelist mode: only explicitly listed minimal paths visible - #[arg(long)] - whitelist: bool, - - /// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run - #[arg(long)] - hardened: bool, - - /// Unshare the network namespace - #[arg(long)] - no_net: bool, - - /// Bind an extra path read-write (repeatable) - #[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)] - extra_rw: Vec, - - /// Bind an extra path read-only (repeatable) - #[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)] - extra_ro: Vec, - - /// Print the bwrap command without executing - #[arg(long)] - dry_run: bool, - - /// Working directory inside the sandbox (default: current directory) - #[arg(long, value_name = "PATH")] - chdir: Option, - - /// Command and arguments to run inside the sandbox - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - command_and_args: Vec, -} +use agent_sandbox::cli::Args; +use agent_sandbox::config::{self, FileConfig}; fn main() { let args = Args::parse(); - let (command, command_args) = resolve_command(args.command_and_args); - let command = assert_binary_exists(&command); - let chdir = assert_chdir(args.chdir); - - let mode = if args.whitelist { - SandboxMode::Whitelist + let file_config = if args.no_config { + None } else { - SandboxMode::Blacklist + config::find_config_path(args.config_path.as_deref()) + .map(|p| FileConfig::load(&p).unwrap_or_else(|e| exit_err(&e))) }; - let config = SandboxConfig { - mode, - hardened: args.hardened, - no_net: args.no_net, - extra_rw: args - .extra_rw - .iter() - .map(|p| canonicalize_or_exit(p)) - .collect(), - extra_ro: args - .extra_ro - .iter() - .map(|p| canonicalize_or_exit(p)) - .collect(), - command, - command_args, - chdir, - dry_run: args.dry_run, - }; + let config = config::build(args, file_config).unwrap_or_else(|e| exit_err(&e)); if let Err(e) = agent_sandbox::run(config) { - eprintln!("error: {e}"); - process::exit(1); + exit_err(&e); } } -fn resolve_command(mut positional: Vec) -> (OsString, Vec) { - if !positional.is_empty() { - let cmd = positional.remove(0); - return (cmd, positional); - } - if let Ok(cmd) = std::env::var("SANDBOX_CMD") { - return (OsString::from(cmd), vec![]); - } - ( - OsString::from("claude"), - vec![OsString::from("--dangerously-skip-permissions")], - ) -} - -fn assert_binary_exists(name: &OsStr) -> PathBuf { - resolve_binary(name).unwrap_or_else(|| { - eprintln!("error: command not found: {}", name.to_string_lossy()); - process::exit(1); - }) -} - -fn assert_chdir(explicit: Option) -> PathBuf { - if let Some(p) = explicit { - return canonicalize_or_exit(&p); - } - match std::env::current_dir() { - Ok(p) => p, - Err(e) => { - eprintln!( - "error: {}", - agent_sandbox::SandboxError::CurrentDirUnavailable(e) - ); - process::exit(1); - } - } -} - -fn canonicalize_or_exit(p: &Path) -> PathBuf { - std::fs::canonicalize(p).unwrap_or_else(|e| { - eprintln!("error: cannot resolve path '{}': {e}", p.display()); - process::exit(1); - }) -} - -fn resolve_binary(name: &OsStr) -> 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()) - }) +fn exit_err(e: &dyn std::fmt::Display) -> ! { + eprintln!("error: {e}"); + process::exit(1); } diff --git a/src/sandbox.rs b/src/sandbox.rs index 952b92f..1337a2f 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -167,7 +167,7 @@ fn ro_bind_under_tmpfs(cmd: &mut Command, base: &str, paths: &[&str]) { fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { if !path.exists() { - return Err(SandboxError::RwPathMissing(path.to_path_buf())); + return Err(SandboxError::PathMissing(path.to_path_buf())); } cmd.arg("--bind").arg(path).arg(path); Ok(()) @@ -175,7 +175,7 @@ fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { if !path.exists() { - return Err(SandboxError::RoPathMissing(path.to_path_buf())); + return Err(SandboxError::PathMissing(path.to_path_buf())); } cmd.arg("--ro-bind").arg(path).arg(path); Ok(()) diff --git a/tests/integration.rs b/tests/integration.rs index dc99566..e5beb30 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -9,6 +9,12 @@ fn sandbox(extra_args: &[&str]) -> Command { cmd } +fn write_config(dir: &TempDir, content: &str) -> String { + let path = dir.path().join("config.toml"); + fs::write(&path, content).expect("failed to write config"); + path.to_str().unwrap().to_string() +} + fn read_sid_from_stat(stat: &str) -> u32 { stat.trim() .split_whitespace() @@ -538,3 +544,36 @@ fn rw_missing_path_errors() { "expected path in error message, got: {stderr}" ); } + +#[test] +fn config_missing_file_errors() { + let output = sandbox(&["--config", "/nonexistent/config.toml"]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("/nonexistent/config.toml"), + "expected config path in error, got: {stderr}" + ); +} + +#[test] +fn config_invalid_toml_errors() { + let dir = TempDir::new().unwrap(); + let cfg = write_config(&dir, "not valid {{{{ toml"); + + let output = sandbox(&["--config", &cfg]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot parse"), + "expected parse error, got: {stderr}" + ); +}