Implement profile inheritance
This commit is contained in:
+277
-38
@@ -11,13 +11,13 @@ const FULL_CONFIG_TOML: &str = r#"
|
||||
rw = ["/tmp/a", "/tmp/b"]
|
||||
command = "zsh"
|
||||
|
||||
[profile.claude]
|
||||
[profiles.claude]
|
||||
rw = ["/home/user/.config/claude"]
|
||||
ro = ["/etc/claude", "/etc/shared"]
|
||||
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
||||
command = ["bash", "-c", "echo hi"]
|
||||
|
||||
[profile.codex]
|
||||
[profiles.codex]
|
||||
whitelist = true
|
||||
dry-run = true
|
||||
chdir = "/home/user/project"
|
||||
@@ -50,13 +50,13 @@ fn unset_vecs_default_empty() {
|
||||
|
||||
#[test]
|
||||
fn unset_scalars_are_none() {
|
||||
assert_eq!(CONFIG.profile["claude"].hardened, None);
|
||||
assert_eq!(CONFIG.profiles["claude"].hardened, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_multi_element_vecs() {
|
||||
assert_paths(
|
||||
&CONFIG.profile["claude"].ro,
|
||||
&CONFIG.profiles["claude"].ro,
|
||||
&["/etc/claude", "/etc/shared"],
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ fn profile_multi_element_vecs() {
|
||||
#[test]
|
||||
fn profile_entrypoint_with_args() {
|
||||
assert_command(
|
||||
&CONFIG.profile["claude"].entrypoint,
|
||||
&CONFIG.profiles["claude"].entrypoint,
|
||||
&["claude", "--dangerously-skip-permissions"],
|
||||
);
|
||||
}
|
||||
@@ -72,14 +72,14 @@ fn profile_entrypoint_with_args() {
|
||||
#[test]
|
||||
fn profile_command_with_args() {
|
||||
assert_command(
|
||||
&CONFIG.profile["claude"].command,
|
||||
&CONFIG.profiles["claude"].command,
|
||||
&["bash", "-c", "echo hi"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_kebab_case_scalars() {
|
||||
let codex = &CONFIG.profile["codex"];
|
||||
let codex = &CONFIG.profiles["codex"];
|
||||
assert_eq!(codex.whitelist, Some(true));
|
||||
assert_eq!(codex.dry_run, Some(true));
|
||||
assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project")));
|
||||
@@ -112,7 +112,7 @@ fn build_profile_overrides_globals() {
|
||||
hardened: Some(true),
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"relaxed".into(),
|
||||
Options {
|
||||
hardened: Some(false),
|
||||
@@ -132,7 +132,7 @@ fn build_profile_overrides_globals() {
|
||||
#[test]
|
||||
fn build_cli_flag_overrides_profile() {
|
||||
let file_config = FileConfig {
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"nonet".into(),
|
||||
Options {
|
||||
unshare_net: Some(false),
|
||||
@@ -257,7 +257,7 @@ fn build_cli_no_dry_run_overrides_profile() {
|
||||
#[test]
|
||||
fn build_cli_mode_overrides_profile() {
|
||||
let file_config = FileConfig {
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"wl".into(),
|
||||
Options {
|
||||
whitelist: Some(true),
|
||||
@@ -282,7 +282,7 @@ fn build_rw_paths_accumulate() {
|
||||
rw: vec!["/tmp".into()],
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"extra".into(),
|
||||
Options {
|
||||
rw: vec!["/usr".into()],
|
||||
@@ -303,7 +303,7 @@ fn build_rw_paths_accumulate() {
|
||||
#[test]
|
||||
fn build_command_from_profile() {
|
||||
let file_config = FileConfig {
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"test".into(),
|
||||
Options {
|
||||
command: Some(CommandValue::WithArgs(vec![
|
||||
@@ -327,7 +327,7 @@ fn build_command_from_profile() {
|
||||
#[test]
|
||||
fn build_cli_entrypoint_overrides_profile() {
|
||||
let file_config = FileConfig {
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"test".into(),
|
||||
Options {
|
||||
entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())),
|
||||
@@ -410,10 +410,13 @@ fn build_no_file_config() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_default_profile_used_when_cli_absent() {
|
||||
fn build_top_level_profile_used_when_cli_absent() {
|
||||
let file_config = FileConfig {
|
||||
default_profile: Some("auto".into()),
|
||||
profile: HashMap::from([(
|
||||
options: Options {
|
||||
profile: Some("auto".into()),
|
||||
..Options::default()
|
||||
},
|
||||
profiles: HashMap::from([(
|
||||
"auto".into(),
|
||||
Options {
|
||||
hardened: Some(true),
|
||||
@@ -427,10 +430,13 @@ fn build_default_profile_used_when_cli_absent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cli_profile_overrides_default_profile() {
|
||||
fn build_cli_profile_overrides_top_level_profile() {
|
||||
let file_config = FileConfig {
|
||||
default_profile: Some("auto".into()),
|
||||
profile: HashMap::from([
|
||||
options: Options {
|
||||
profile: Some("auto".into()),
|
||||
..Options::default()
|
||||
},
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"auto".into(),
|
||||
Options {
|
||||
@@ -457,9 +463,12 @@ fn build_cli_profile_overrides_default_profile() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_missing_default_profile_errors() {
|
||||
fn build_missing_top_level_profile_errors() {
|
||||
let file_config = FileConfig {
|
||||
default_profile: Some("nope".into()),
|
||||
options: Options {
|
||||
profile: Some("nope".into()),
|
||||
..Options::default()
|
||||
},
|
||||
..FileConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -480,6 +489,227 @@ fn build_missing_profile_errors() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inheritance_merges_parent_into_child() {
|
||||
let file_config = FileConfig {
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"base".into(),
|
||||
Options {
|
||||
hardened: Some(true),
|
||||
unshare_net: Some(true),
|
||||
env: vec!["FROM_PARENT=1".into()],
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"child".into(),
|
||||
Options {
|
||||
profile: Some("base".into()),
|
||||
hardened: Some(false),
|
||||
seccomp: Some(false),
|
||||
env: vec!["FROM_CHILD=1".into()],
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
..FileConfig::default()
|
||||
};
|
||||
let args = Args {
|
||||
profile: Some("child".into()),
|
||||
..args_with_command()
|
||||
};
|
||||
let config = build(args, Some(file_config)).unwrap();
|
||||
|
||||
assert!(!config.hardened);
|
||||
assert!(config.unshare_net);
|
||||
assert!(!config.seccomp);
|
||||
assert_eq!(
|
||||
config.env,
|
||||
vec![
|
||||
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
||||
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inheritance_walks_multi_level_chain() {
|
||||
let file_config = FileConfig {
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"grand".into(),
|
||||
Options {
|
||||
hardened: Some(true),
|
||||
env: vec!["FROM_GRAND=1".into()],
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"parent".into(),
|
||||
Options {
|
||||
profile: Some("grand".into()),
|
||||
env: vec!["FROM_PARENT=1".into()],
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"child".into(),
|
||||
Options {
|
||||
profile: Some("parent".into()),
|
||||
env: vec!["FROM_CHILD=1".into()],
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
..FileConfig::default()
|
||||
};
|
||||
let args = Args {
|
||||
profile: Some("child".into()),
|
||||
..args_with_command()
|
||||
};
|
||||
let config = build(args, Some(file_config)).unwrap();
|
||||
|
||||
assert!(config.hardened);
|
||||
assert_eq!(
|
||||
config.env,
|
||||
vec![
|
||||
EnvEntry::Set("FROM_GRAND".into(), "1".into()),
|
||||
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
||||
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_profile_walks_chain() {
|
||||
let file_config = FileConfig {
|
||||
options: Options {
|
||||
profile: Some("child".into()),
|
||||
..Options::default()
|
||||
},
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"base".into(),
|
||||
Options {
|
||||
hardened: Some(true),
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"child".into(),
|
||||
Options {
|
||||
profile: Some("base".into()),
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
..FileConfig::default()
|
||||
};
|
||||
let config = build(args_with_command(), Some(file_config)).unwrap();
|
||||
assert!(config.hardened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inheritance_cycle_errors() {
|
||||
let file_config = FileConfig {
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"a".into(),
|
||||
Options {
|
||||
profile: Some("b".into()),
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"b".into(),
|
||||
Options {
|
||||
profile: Some("a".into()),
|
||||
..Options::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
..FileConfig::default()
|
||||
};
|
||||
let args = Args {
|
||||
profile: Some("a".into()),
|
||||
..args_with_command()
|
||||
};
|
||||
assert!(matches!(
|
||||
build(args, Some(file_config)),
|
||||
Err(SandboxError::ProfileCycle(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_profile_errors() {
|
||||
let file_config = FileConfig {
|
||||
profiles: HashMap::from([(
|
||||
"child".into(),
|
||||
Options {
|
||||
profile: Some("ghost".into()),
|
||||
..Options::default()
|
||||
},
|
||||
)]),
|
||||
..FileConfig::default()
|
||||
};
|
||||
let args = Args {
|
||||
profile: Some("child".into()),
|
||||
..args_with_command()
|
||||
};
|
||||
assert!(matches!(
|
||||
build(args, Some(file_config)),
|
||||
Err(SandboxError::ProfileNotFound(name)) if name == "ghost"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inheritance_chain_spans_extra_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let base_path = dir.path().join("base.toml");
|
||||
let extra_path = dir.path().join("extra.toml");
|
||||
|
||||
std::fs::write(
|
||||
&extra_path,
|
||||
r#"
|
||||
[profiles.child]
|
||||
profile = "base"
|
||||
env = ["FROM_CHILD=1"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&base_path,
|
||||
format!(
|
||||
r#"
|
||||
extra-config = "{}"
|
||||
|
||||
[profiles.base]
|
||||
hardened = true
|
||||
env = ["FROM_PARENT=1"]
|
||||
"#,
|
||||
extra_path.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let file_config = FileConfig::load(&base_path).unwrap();
|
||||
let args = Args {
|
||||
profile: Some("child".into()),
|
||||
..args_with_command()
|
||||
};
|
||||
let config = build(args, Some(file_config)).unwrap();
|
||||
|
||||
assert!(config.hardened);
|
||||
assert_eq!(
|
||||
config.env,
|
||||
vec![
|
||||
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
||||
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_conflicting_mode_errors() {
|
||||
let file_config = FileConfig {
|
||||
@@ -700,7 +930,7 @@ fn build_env_accumulates_disjoint_keys() {
|
||||
env: vec!["A=global".into(), "B".into()],
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"p".into(),
|
||||
Options {
|
||||
env: vec!["C=profile".into()],
|
||||
@@ -733,7 +963,7 @@ fn build_env_later_tier_overrides_earlier() {
|
||||
env: vec!["A=global".into()],
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"p".into(),
|
||||
Options {
|
||||
env: vec!["A=profile".into()],
|
||||
@@ -818,7 +1048,7 @@ fn build_unsetenv_accumulates() {
|
||||
unsetenv: vec!["G".into()],
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"p".into(),
|
||||
Options {
|
||||
unsetenv: vec!["P".into()],
|
||||
@@ -843,7 +1073,7 @@ fn build_mask_accumulates() {
|
||||
mask: vec![PathBuf::from("/tmp/a")],
|
||||
..Options::default()
|
||||
},
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"extra".into(),
|
||||
Options {
|
||||
mask: vec![PathBuf::from("/tmp/b")],
|
||||
@@ -876,7 +1106,7 @@ fn unknown_option_rejected() {
|
||||
#[test]
|
||||
fn unknown_profile_option_rejected() {
|
||||
let toml = r#"
|
||||
[profile.test]
|
||||
[profiles.test]
|
||||
hardened = true
|
||||
frobnicate = 42
|
||||
"#;
|
||||
@@ -1081,7 +1311,7 @@ fn merge_options_lists_append_base_then_extra() {
|
||||
#[test]
|
||||
fn merge_file_config_profile_merged_by_name() {
|
||||
let base = FileConfig {
|
||||
profile: HashMap::from([(
|
||||
profiles: HashMap::from([(
|
||||
"claude".into(),
|
||||
Options {
|
||||
rw: vec!["/a".into()],
|
||||
@@ -1092,7 +1322,7 @@ fn merge_file_config_profile_merged_by_name() {
|
||||
..FileConfig::default()
|
||||
};
|
||||
let extra = FileConfig {
|
||||
profile: HashMap::from([
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"claude".into(),
|
||||
Options {
|
||||
@@ -1112,32 +1342,41 @@ fn merge_file_config_profile_merged_by_name() {
|
||||
..FileConfig::default()
|
||||
};
|
||||
let merged = base.merge_with(extra);
|
||||
assert_eq!(merged.profile["claude"].hardened, Some(true));
|
||||
assert_eq!(merged.profile["claude"].rw, vec!["/a", "/b"]);
|
||||
assert_eq!(merged.profile["codex"].unshare_net, Some(true));
|
||||
assert_eq!(merged.profiles["claude"].hardened, Some(true));
|
||||
assert_eq!(merged.profiles["claude"].rw, vec!["/a", "/b"]);
|
||||
assert_eq!(merged.profiles["codex"].unshare_net, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_file_config_default_profile_extra_overrides() {
|
||||
fn merge_file_config_top_level_profile_extra_overrides() {
|
||||
let base = FileConfig {
|
||||
default_profile: Some("claude".into()),
|
||||
options: Options {
|
||||
profile: Some("claude".into()),
|
||||
..Options::default()
|
||||
},
|
||||
..FileConfig::default()
|
||||
};
|
||||
let extra = FileConfig {
|
||||
default_profile: Some("codex".into()),
|
||||
options: Options {
|
||||
profile: Some("codex".into()),
|
||||
..Options::default()
|
||||
},
|
||||
..FileConfig::default()
|
||||
};
|
||||
assert_eq!(base.merge_with(extra).default_profile, Some("codex".into()));
|
||||
assert_eq!(base.merge_with(extra).options.profile, Some("codex".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_file_config_default_profile_extra_unset_inherits() {
|
||||
fn merge_file_config_top_level_profile_extra_unset_inherits() {
|
||||
let base = FileConfig {
|
||||
default_profile: Some("claude".into()),
|
||||
options: Options {
|
||||
profile: Some("claude".into()),
|
||||
..Options::default()
|
||||
},
|
||||
..FileConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
base.merge_with(FileConfig::default()).default_profile,
|
||||
base.merge_with(FileConfig::default()).options.profile,
|
||||
Some("claude".into())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user