diff --git a/config-example.toml b/config-example.toml index df4a54a..559bc4f 100644 --- a/config-example.toml +++ b/config-example.toml @@ -1,5 +1,6 @@ -# Globals; [profile.] overrides them when --profile is passed. -# CLI flags override both. +# Layered settings: CLI > active profile > globals. `--profile` selects the +# profile, otherwise `default-profile` below is used. Vec fields append across +# layers; scalars replace. whitelist = true # blacklist = true @@ -11,8 +12,6 @@ whitelist = true # chdir = "~/projects/my-repo" ro = [ - "~/.local/share/claude-code", - "~/.local/share/codex-cli", "~/dev/agent-config", "/etc/alsa", "/run/user/1000/pulse", @@ -20,7 +19,6 @@ ro = [ # "/host/path:/sandbox/path", # SRC:DST -> mount host SRC at a different target ] rw = [ - "~/.config/claude", "~/.cargo", "~/.rustup", ] @@ -33,12 +31,17 @@ env = [ ] # unsetenv = ["SOME_LEAKED_VAR"] -entrypoint = ["claude", "--dangerously-skip-permissions"] -# command = ["--model", "opus"] # default trailing args -# bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch +# entrypoint = ["claude", "--dangerously-skip-permissions"] # binary + baked-in args +# command = ["--model", "opus"] # default trailing args +# bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch -# Profiles inherit all globals above and override keys they set. Select one at -# runtime with `--profile `. Vec fields (ro/rw/mask/env/unsetenv) append -# to the globals; scalar fields replace. Profile-less runs use just the globals. -[profile.blacklist] -blacklist = true +default-profile = "claude" + +[profile.claude] +ro = ["~/.local/share/claude-code"] +rw = ["~/.config/claude"] +entrypoint = ["claude", "--dangerously-skip-permissions"] + +[profile.codex] +ro = ["~/.local/share/codex-cli"] +entrypoint = ["codex", "--dangerously-bypass-approvals-and-sandbox"] diff --git a/src/config.rs b/src/config.rs index fa3f598..2f11141 100644 --- a/src/config.rs +++ b/src/config.rs @@ -290,6 +290,8 @@ pub struct FileConfig { pub options: Options, #[serde(default)] pub profile: HashMap, + #[serde(rename = "default-profile", default)] + pub default_profile: Option, // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. #[serde(flatten)] _unknown: HashMap, @@ -322,7 +324,7 @@ impl FileConfig { } fn resolve_profile(&self, name: Option<&str>) -> Result { - match name { + match name.or(self.default_profile.as_deref()) { Some(n) => self .profile .get(n) @@ -879,6 +881,65 @@ mod tests { assert!(!config.hardened); } + #[test] + fn build_default_profile_used_when_cli_absent() { + let file_config = FileConfig { + default_profile: Some("auto".into()), + profile: HashMap::from([( + "auto".into(), + Options { + hardened: Some(true), + ..Options::default() + }, + )]), + ..FileConfig::default() + }; + let config = build(args_with_command(), Some(file_config)).unwrap(); + assert!(config.hardened); + } + + #[test] + fn build_cli_profile_overrides_default_profile() { + let file_config = FileConfig { + default_profile: Some("auto".into()), + profile: HashMap::from([ + ( + "auto".into(), + Options { + hardened: Some(true), + ..Options::default() + }, + ), + ( + "other".into(), + Options { + hardened: Some(false), + ..Options::default() + }, + ), + ]), + ..FileConfig::default() + }; + let args = Args { + profile: Some("other".into()), + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + assert!(!config.hardened); + } + + #[test] + fn build_missing_default_profile_errors() { + let file_config = FileConfig { + default_profile: Some("nope".into()), + ..FileConfig::default() + }; + assert!(matches!( + build(args_with_command(), Some(file_config)), + Err(SandboxError::ProfileNotFound(_)) + )); + } + #[test] fn build_missing_profile_errors() { let args = Args {