Accept a list of profiles on the CLI and in the config

This commit is contained in:
2026-10-01 20:32:38 +02:00
parent 1e7e632a4a
commit fbb78d485f
5 changed files with 343 additions and 79 deletions
+3 -1
View File
@@ -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.<name>]` sections define named presets selectable with `--profile <name>`. 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.<name>]` sections define named presets selectable with `--profile <name>`. 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
+18 -10
View File
@@ -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"]
+9 -3
View File
@@ -82,9 +82,15 @@ pub struct Args {
#[arg(long, value_name = "PATH")]
pub chdir: Option<PathBuf>,
/// Use a named profile from the config file
#[arg(long, conflicts_with = "no_config")]
pub profile: Option<String>,
/// 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<String>,
/// Path to config file (default: $XDG_CONFIG_HOME/agent-sandbox/config.toml)
#[arg(long = "config", value_name = "PATH", conflicts_with = "no_config")]
+90 -39
View File
@@ -11,13 +11,13 @@ 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())?;
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<ResolvedProfile, SandboxError> {
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<ResolvedProfile, SandboxError> {
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<Options, SandboxError> {
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<Vec<String>, 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<Vec<Options>, SandboxError> {
let mut visited: Vec<String> = Vec::new();
let mut chain: Vec<Options> = Vec::new();
let mut current = name.to_string();
struct ProfileWalk<'a> {
profiles: &'a HashMap<String, Options>,
path: Vec<String>,
ordered: Vec<String>,
}
loop {
if visited.iter().any(|seen| seen == &current) {
visited.push(current);
return Err(SandboxError::ProfileCycle(visited));
impl<'a> ProfileWalk<'a> {
fn new(profiles: &'a HashMap<String, Options>) -> 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(&current)
.cloned()
.ok_or_else(|| SandboxError::ProfileNotFound(current.clone()))?;
.get(name)
.ok_or_else(|| SandboxError::ProfileNotFound(name.to_string()))?;
visited.push(current);
let parent = options.profile.clone();
chain.push(options);
self.path.push(name.to_string());
for parent in options.profile.iter().flat_map(|p| &p.0) {
self.visit(parent)?;
}
self.path.pop();
match parent {
Some(p) => current = p,
None => return Ok(chain),
}
self.ordered.push(name.to_string());
Ok(())
}
}
fn join_profile_names(names: &[String]) -> Option<String> {
(!names.is_empty()).then(|| names.join(","))
}
#[derive(Deserialize, Default, Clone)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Options {
pub profile: Option<String>,
pub profile: Option<ProfileNames>,
pub blacklist: Option<bool>,
pub whitelist: Option<bool>,
pub hardened: Option<bool>,
@@ -567,6 +592,32 @@ fn expand_and_require_absolute(path: &Path) -> Result<PathBuf, SandboxError> {
Ok(expanded)
}
#[derive(Deserialize, Clone, Debug, PartialEq)]
#[serde(from = "OneOrMany")]
pub struct ProfileNames(pub Vec<String>);
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl From<OneOrMany> 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 {
+217 -20
View File
@@ -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<String>| {
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<String> = expected.iter().map(|s| s.to_string()).collect();
assert_eq!(actual, &expected);