diff --git a/README.md b/README.md index 938e36f..53c2bac 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ Settings can be stored in a TOML config file at `$XDG_CONFIG_HOME/agent-sandbox/ ln -sf "$(pwd)/config-example.toml" "${XDG_CONFIG_HOME:-$HOME/.config}/agent-sandbox/config.toml" ``` -Top-level keys set defaults; `[profile.]` sections define named presets selectable with `--profile `. CLI flags always take highest precedence, followed by the active profile, then top-level defaults. When a profile is active, list-valued fields (`ro`, `rw`, `mask`, `env`, `unsetenv`, `bwrap-args`) append to the globals; scalar fields (modes, flags, `entrypoint`, `command`, `chdir`) replace them. +Top-level keys set defaults; `[profile.]` sections define named presets selectable with `--profile `. CLI flags always take highest precedence, followed by the active profiles, then top-level defaults. When a profile is active, list-valued fields (`ro`, `rw`, `mask`, `env`, `unsetenv`, `bwrap-args`) append to the globals; scalar fields (modes, flags, `entrypoint`, `command`, `chdir`) replace them. + +Several profiles combine: `--profile claude,gpu` (or `--profile claude --profile gpu`, or `profile = ["claude", "gpu"]` in the config) merges them left to right under the same rules, so a later profile's scalars win. A CLI `--profile` replaces the config's list rather than extending it. A profile's own `profile = ...` names its parents, which merge before it; a profile reached twice merges once. ```toml # Global defaults diff --git a/config-example.toml b/config-example.toml index 02a9647..29c50a9 100644 --- a/config-example.toml +++ b/config-example.toml @@ -1,10 +1,11 @@ # Merge rules (apply to every layering below): scalars replace, vec fields # append, profiles merge by name. # -# Layers, lowest precedence first: globals -> active profile (with its -# ancestors folded in) -> CLI flags. `--profile` selects the active profile; -# otherwise the top-level `profile` below is used. Profiles can themselves set -# `profile = "parent"` to inherit from another profile. +# Layers, lowest precedence first: globals -> active profiles (with their +# ancestors folded in) -> CLI flags. `--profile a,b` selects the active +# profiles, merged left to right; otherwise the top-level `profile` below is +# used. Either form takes one name or a list. Profiles can themselves set +# `profile = "parent"` (or a list) to inherit; a shared ancestor merges once. # # `extra-config` optionally points to a second file layered on top of this # one. Missing extra-config files are silently skipped; nesting is not @@ -24,10 +25,7 @@ whitelist = true ro = [ "~/.config/agents", "~/.config/git", - "/etc/alsa", - "/run/user/1000/pulse", - "/run/user/1000/pipewire-0", - "~/.config/llama-server-api-key", + "~/dev", # "/host/path:/sandbox/path", # SRC:DST -> mount host SRC at a different target ] rw = [ @@ -53,7 +51,7 @@ env = [ # command = ["--model", "opus"] # default trailing args # bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch -profile = "claude" +profile = ["claude", "audio"] [profiles.claude] ro = ["~/.local/share/claude-code"] @@ -61,7 +59,7 @@ rw = ["~/.config/claude"] entrypoint = ["claude", "--dangerously-skip-permissions"] [profiles.codex] -ro = ["~/.local/share/codex-cli"] +ro = ["~/.local/share/codex-cli", "~/.agents"] rw = ["~/.config/codex"] entrypoint = ["codex", "--dangerously-bypass-approvals-and-sandbox"] @@ -69,3 +67,13 @@ entrypoint = ["codex", "--dangerously-bypass-approvals-and-sandbox"] ro = ["~/.local/share/pi-agent"] rw = ["~/.config/pi"] entrypoint = ["pi"] + +[profiles.audio] +ro = ["/etc/alsa", "/run/user/1000/pulse", "/run/user/1000/pipewire-0"] + +[profiles.gpu] +bwrap-args = ["--dev-bind /dev/dri /dev/dri"] + +[profiles.wayland] +ro = ["/run/user/1000/wayland-0"] +env = ["WAYLAND_DISPLAY=wayland-0"] diff --git a/src/cli.rs b/src/cli.rs index 3b5014e..c8553df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -82,9 +82,15 @@ pub struct Args { #[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, + /// Use named profiles from the config file, merged in order (comma-separated, repeatable) + #[arg( + long, + value_name = "NAME[,NAME...]", + value_delimiter = ',', + action = clap::ArgAction::Append, + conflicts_with = "no_config" + )] + pub profile: Vec, /// Path to config file (default: $XDG_CONFIG_HOME/agent-sandbox/config.toml) #[arg(long = "config", value_name = "PATH", conflicts_with = "no_config")] diff --git a/src/config.rs b/src/config.rs index d9f1f25..b7e0840 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,13 +11,13 @@ 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.as_deref())?; + let profile = c.resolve_profile(&args.profile)?; (c.options, profile) } None => ( Options::default(), ResolvedProfile { - name: args.profile.clone(), + name: join_profile_names(&args.profile), options: Options::default(), }, ), @@ -419,64 +419,89 @@ impl FileConfig { Ok(config) } - fn resolve_profile(&self, selected: Option<&str>) -> Result { - let Some(name) = selected - .map(String::from) - .or_else(|| self.options.profile.clone()) - else { - return Ok(ResolvedProfile::default()); + 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() }; - let options = self.resolve_chain(&name)?; + 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: Some(name), - options, + name: join_profile_names(&names), + options: merged, }) } - fn resolve_chain(&self, name: &str) -> Result { - let chain = self.collect_chain(name)?; - - let merged = chain - .into_iter() - .rev() - .fold(Options::default(), |base, child| base.merge_with(child)); - - Ok(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) } +} - fn collect_chain(&self, name: &str) -> Result, SandboxError> { - let mut visited: Vec = Vec::new(); - let mut chain: Vec = Vec::new(); - let mut current = name.to_string(); +struct ProfileWalk<'a> { + profiles: &'a HashMap, + path: Vec, + ordered: Vec, +} - loop { - if visited.iter().any(|seen| seen == ¤t) { - visited.push(current); - return Err(SandboxError::ProfileCycle(visited)); - } - - let options = self - .profiles - .get(¤t) - .cloned() - .ok_or_else(|| SandboxError::ProfileNotFound(current.clone()))?; - - visited.push(current); - let parent = options.profile.clone(); - chain.push(options); - - match parent { - Some(p) => current = p, - None => return Ok(chain), - } +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 profile: Option, pub blacklist: Option, pub whitelist: Option, pub hardened: Option, @@ -567,6 +592,32 @@ fn expand_and_require_absolute(path: &Path) -> Result { 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 { diff --git a/tests/unit/config.rs b/tests/unit/config.rs index 50a4092..5212e00 100644 --- a/tests/unit/config.rs +++ b/tests/unit/config.rs @@ -1,5 +1,6 @@ use std::sync::LazyLock; +use clap::Parser; use tempfile::TempDir; use super::*; @@ -122,7 +123,7 @@ fn build_profile_overrides_globals() { ..FileConfig::default() }; let args = Args { - profile: Some("relaxed".into()), + profile: vec!["relaxed".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -142,7 +143,7 @@ fn build_cli_flag_overrides_profile() { ..FileConfig::default() }; let args = Args { - profile: Some("nonet".into()), + profile: vec!["nonet".into()], unshare_net: true, ..args_with_command() }; @@ -267,7 +268,7 @@ fn build_cli_mode_overrides_profile() { ..FileConfig::default() }; let args = Args { - profile: Some("wl".into()), + profile: vec!["wl".into()], blacklist: true, ..args_with_command() }; @@ -292,7 +293,7 @@ fn build_rw_paths_accumulate() { ..FileConfig::default() }; let args = Args { - profile: Some("extra".into()), + profile: vec!["extra".into()], extra_rw: vec!["/var".into()], ..args_with_command() }; @@ -316,7 +317,7 @@ fn build_command_from_profile() { ..FileConfig::default() }; let args = Args { - profile: Some("test".into()), + profile: vec!["test".into()], ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); @@ -337,7 +338,7 @@ fn build_cli_entrypoint_overrides_profile() { ..FileConfig::default() }; let args = Args { - profile: Some("test".into()), + profile: vec!["test".into()], entrypoint: Some("/usr/bin/true".into()), ..Args::default() }; @@ -455,7 +456,7 @@ fn build_cli_profile_overrides_top_level_profile() { ..FileConfig::default() }; let args = Args { - profile: Some("other".into()), + profile: vec!["other".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -480,7 +481,7 @@ fn build_missing_top_level_profile_errors() { #[test] fn build_missing_profile_errors() { let args = Args { - profile: Some("nope".into()), + profile: vec!["nope".into()], ..args_with_command() }; assert!(matches!( @@ -516,7 +517,7 @@ fn inheritance_merges_parent_into_child() { ..FileConfig::default() }; let args = Args { - profile: Some("child".into()), + profile: vec!["child".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -565,7 +566,7 @@ fn inheritance_walks_multi_level_chain() { ..FileConfig::default() }; let args = Args { - profile: Some("child".into()), + profile: vec!["child".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -632,7 +633,7 @@ fn inheritance_cycle_errors() { ..FileConfig::default() }; let args = Args { - profile: Some("a".into()), + profile: vec!["a".into()], ..args_with_command() }; assert!(matches!( @@ -654,7 +655,7 @@ fn missing_parent_profile_errors() { ..FileConfig::default() }; let args = Args { - profile: Some("child".into()), + profile: vec!["child".into()], ..args_with_command() }; assert!(matches!( @@ -695,7 +696,7 @@ fn inheritance_chain_spans_extra_config() { let file_config = FileConfig::load(&base_path).unwrap(); let args = Args { - profile: Some("child".into()), + profile: vec!["child".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -940,7 +941,7 @@ fn build_env_accumulates_disjoint_keys() { ..FileConfig::default() }; let args = Args { - profile: Some("p".into()), + profile: vec!["p".into()], env: vec!["D".into()], ..args_with_command() }; @@ -973,7 +974,7 @@ fn build_env_later_tier_overrides_earlier() { ..FileConfig::default() }; let args = Args { - profile: Some("p".into()), + profile: vec!["p".into()], env: vec!["A=cli".into()], ..args_with_command() }; @@ -1058,7 +1059,7 @@ fn build_unsetenv_accumulates() { ..FileConfig::default() }; let args = Args { - profile: Some("p".into()), + profile: vec!["p".into()], unsetenv: vec!["C".into()], ..args_with_command() }; @@ -1184,11 +1185,11 @@ fn persistent_tmp_uses_leaf_profile_name() { }; let leaf_args = Args { - profile: Some("leaf".into()), + profile: vec!["leaf".into()], ..args_with_command() }; let parent_args = Args { - profile: Some("leaf".into()), + profile: vec!["leaf".into()], ..args_with_command() }; // Same profile selected: same key. @@ -1211,7 +1212,7 @@ fn persistent_tmp_uses_leaf_profile_name() { .unwrap() .persistent_tmp = Some(true); let parent_direct = Args { - profile: Some("parent".into()), + profile: vec!["parent".into()], ..args_with_command() }; let key3 = build(parent_direct, Some(file_config2)) @@ -1246,7 +1247,7 @@ fn build_mask_accumulates() { ..FileConfig::default() }; let args = Args { - profile: Some("extra".into()), + profile: vec!["extra".into()], mask: vec![PathBuf::from("/tmp/c")], ..args_with_command() }; @@ -1619,6 +1620,202 @@ fn load_extra_relative_path_rejected() { )); } +#[test] +fn cli_profile_splits_on_commas() { + let args = Args::try_parse_from(["agent-sandbox", "--profile", "claude,gpu"]).unwrap(); + assert_eq!(args.profile, vec!["claude", "gpu"]); +} + +#[test] +fn cli_repeated_profiles_concatenate_in_order() { + let args = Args::try_parse_from([ + "agent-sandbox", + "--profile", + "claude", + "--profile=gpu,wayland", + ]) + .unwrap(); + assert_eq!(args.profile, vec!["claude", "gpu", "wayland"]); +} + +#[test] +fn config_profile_accepts_a_list() { + let config: FileConfig = toml::from_str(r#"profile = ["claude", "gpu"]"#).unwrap(); + assert_eq!( + config.options.profile, + Some(ProfileNames(vec!["claude".into(), "gpu".into()])) + ); +} + +#[test] +fn config_profile_accepts_a_single_name() { + let config: FileConfig = toml::from_str(r#"profile = "claude""#).unwrap(); + assert_eq!(config.options.profile, Some("claude".into())); +} + +#[test] +fn build_merges_selected_profiles_in_order() { + let args = Args { + profile: vec!["claude".into(), "gpu".into()], + ..args_with_command() + }; + let config = build(args, Some(claude_and_gpu_profiles())).unwrap(); + + assert!(!config.hardened); + assert!(config.unshare_net); + assert_eq!( + config.env, + vec![ + EnvEntry::Set("FROM_CLAUDE".into(), "1".into()), + EnvEntry::Set("FROM_GPU".into(), "1".into()), + ] + ); +} + +#[test] +fn build_merges_top_level_profile_list() { + let file_config = FileConfig { + options: Options { + profile: Some(ProfileNames(vec!["claude".into(), "gpu".into()])), + ..Options::default() + }, + ..claude_and_gpu_profiles() + }; + let config = build(args_with_command(), Some(file_config)).unwrap(); + + assert!(!config.hardened); + assert!(config.unshare_net); +} + +#[test] +fn build_cli_profile_list_replaces_top_level_list() { + let file_config = FileConfig { + options: Options { + profile: Some(ProfileNames(vec!["claude".into(), "gpu".into()])), + ..Options::default() + }, + ..claude_and_gpu_profiles() + }; + let args = Args { + profile: vec!["gpu".into()], + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + + assert_eq!( + config.env, + vec![EnvEntry::Set("FROM_GPU".into(), "1".into())] + ); +} + +#[test] +fn build_shared_parent_merges_once() { + let file_config = FileConfig { + profiles: HashMap::from([ + ( + "base".into(), + Options { + ro: vec!["/etc".into()], + ..Options::default() + }, + ), + ( + "claude".into(), + Options { + profile: Some("base".into()), + ..Options::default() + }, + ), + ( + "gpu".into(), + Options { + profile: Some("base".into()), + ..Options::default() + }, + ), + ]), + ..FileConfig::default() + }; + let args = Args { + profile: vec!["claude".into(), "gpu".into()], + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + + assert_eq!(config.extra_ro, vec![same_bind(Path::new("/etc"))]); +} + +#[test] +fn build_profile_inherits_from_a_parent_list() { + let mut file_config = claude_and_gpu_profiles(); + file_config.profiles.insert( + "both".into(), + Options { + profile: Some(ProfileNames(vec!["claude".into(), "gpu".into()])), + ..Options::default() + }, + ); + let args = Args { + profile: vec!["both".into()], + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + + assert!(!config.hardened); + assert!(config.unshare_net); +} + +#[test] +fn persistent_tmp_key_differs_per_profile_list() { + let file_config = FileConfig { + options: Options { + persistent_tmp: Some(true), + ..Options::default() + }, + ..claude_and_gpu_profiles() + }; + let key_for = |profiles: Vec| { + let args = Args { + profile: profiles, + ..args_with_command() + }; + build(args, Some(clone_file_config(&file_config))) + .unwrap() + .persistent_tmp_key + .unwrap() + }; + + assert_ne!( + key_for(vec!["claude".into()]), + key_for(vec!["claude".into(), "gpu".into()]) + ); +} + +fn claude_and_gpu_profiles() -> FileConfig { + FileConfig { + profiles: HashMap::from([ + ( + "claude".into(), + Options { + hardened: Some(true), + env: vec!["FROM_CLAUDE=1".into()], + ..Options::default() + }, + ), + ( + "gpu".into(), + Options { + hardened: Some(false), + unshare_net: Some(true), + env: vec!["FROM_GPU=1".into()], + ..Options::default() + }, + ), + ]), + ..FileConfig::default() + } +} + fn assert_paths(actual: &[String], expected: &[&str]) { let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(actual, &expected);