Skip to content

Commit 719975c

Browse files
authored
fix(profile): preserve platform_overrides through extends resolution (nolabs-ai#1380)
* fix(profile): preserve platform_overrides through extends resolution Signed-off-by: James Carnegie <me@kipz.org> * fix(profile): re-validate credentials and set_vars after platform_overrides merge apply_platform_overrides merges custom_credentials, env_credentials, and environment.set_vars from the active platform override into the profile, but finalize_profile never re-ran the validators that parse_profile_bytes runs on the pre-merge values. A malicious/invalid entry declared only inside a platform_overrides block bypassed validation entirely. Signed-off-by: James Carnegie <me@kipz.org> --------- Signed-off-by: James Carnegie <me@kipz.org>
1 parent 1f54f4a commit 719975c

1 file changed

Lines changed: 294 additions & 1 deletion

File tree

  • crates/nono-cli/src/profile

crates/nono-cli/src/profile/mod.rs

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2917,6 +2917,18 @@ pub(crate) fn load_raw_profile_from_path(path: &Path) -> Result<Profile> {
29172917
#[allow(deprecated)]
29182918
pub(crate) fn finalize_profile(mut profile: Profile) -> Result<Profile> {
29192919
profile = apply_platform_overrides(profile)?;
2920+
// apply_platform_overrides merges platform-specific custom_credentials,
2921+
// env_credentials, and environment.set_vars into the profile, so those
2922+
// merged values must be re-validated here — parse_profile_bytes only
2923+
// validated the pre-merge, top-level values.
2924+
validate_profile_custom_credentials(&profile)?;
2925+
validate_env_credential_keys(&profile)?;
2926+
if let Some(env_config) = profile.environment.as_ref()
2927+
&& !env_config.set_vars.is_empty()
2928+
&& let Some(err) = crate::exec_strategy::validate_set_vars(&env_config.set_vars)
2929+
{
2930+
return Err(NonoError::ProfileParse(err));
2931+
}
29202932
merge_implicit_default_groups(&mut profile)?;
29212933
validate_credential_capture_resolved(&profile)?;
29222934
validate_credential_provider_resolved(&profile)?;
@@ -3298,11 +3310,50 @@ fn apply_platform_overrides(mut profile: Profile) -> Result<Profile> {
32983310
Ok(merge_profiles(profile, override_profile))
32993311
}
33003312

3313+
/// Must preserve a base's overrides, not just a leaf's — `merge_profiles`
3314+
/// previously nulled this field unconditionally, so any override on a base
3315+
/// profile was silently lost. Same-OS conflicts deep-merge via
3316+
/// `merge_platform_override_slot` rather than one side replacing the other,
3317+
/// so a child override touching one field can't wipe out unrelated fields
3318+
/// the base's override for that OS set.
3319+
fn merge_platform_overrides(
3320+
base: Option<PlatformOverrides>,
3321+
child: Option<PlatformOverrides>,
3322+
) -> Option<PlatformOverrides> {
3323+
match (base, child) {
3324+
(None, None) => None,
3325+
(Some(b), None) => Some(b),
3326+
(None, Some(c)) => Some(c),
3327+
(Some(b), Some(c)) => Some(PlatformOverrides {
3328+
macos: merge_platform_override_slot(b.macos, c.macos),
3329+
linux: merge_platform_override_slot(b.linux, c.linux),
3330+
windows: merge_platform_override_slot(b.windows, c.windows),
3331+
}),
3332+
}
3333+
}
3334+
3335+
/// Uses `merge_profiles` itself so a same-OS override composes with the same
3336+
/// child-wins/dedup-append/deny-union rules as everything else in a profile.
3337+
fn merge_platform_override_slot(
3338+
base: Option<Box<PlatformOverride>>,
3339+
child: Option<Box<PlatformOverride>>,
3340+
) -> Option<Box<PlatformOverride>> {
3341+
match (base, child) {
3342+
(None, None) => None,
3343+
(Some(b), None) => Some(b),
3344+
(None, Some(c)) => Some(c),
3345+
(Some(b), Some(c)) => Some(Box::new(PlatformOverride(merge_profiles(b.0, c.0)))),
3346+
}
3347+
}
3348+
33013349
#[allow(deprecated)] // reads/writes commands.{allow,deny} (deprecated v0.33.0)
33023350
fn merge_profiles(base: Profile, child: Profile) -> Profile {
33033351
Profile {
33043352
extends: None,
3305-
platform_overrides: None,
3353+
platform_overrides: merge_platform_overrides(
3354+
base.platform_overrides,
3355+
child.platform_overrides,
3356+
),
33063357
meta: child.meta,
33073358
security: SecurityConfig {
33083359
signal_mode: child.security.signal_mode.or(base.security.signal_mode),
@@ -9772,4 +9823,246 @@ mod tests {
97729823
"other platform's paths must not be present"
97739824
);
97749825
}
9826+
9827+
#[test]
9828+
fn platform_overrides_valid_set_vars_merged_and_accepted() {
9829+
// Positive counterpart to `platform_overrides_set_vars_validated_after_merge`:
9830+
// a well-formed set_vars entry declared only inside platform_overrides must
9831+
// survive the merge and finalize successfully, not just get rejected.
9832+
let current_os = crate::platform::current_os_name();
9833+
let json = format!(
9834+
r#"{{
9835+
"meta": {{"name": "test"}},
9836+
"platform_overrides": {{
9837+
"{current_os}": {{ "environment": {{ "set_vars": {{"MY_VAR": "value"}} }} }}
9838+
}}
9839+
}}"#
9840+
);
9841+
let profile: Profile = serde_json::from_str(&json).expect("parse");
9842+
let finalized = finalize_profile(profile).expect("valid set_vars must be accepted");
9843+
assert_eq!(
9844+
finalized
9845+
.environment
9846+
.as_ref()
9847+
.expect("environment must be merged in")
9848+
.set_vars
9849+
.get("MY_VAR")
9850+
.map(String::as_str),
9851+
Some("value"),
9852+
"merged set_vars entry must be present after finalize"
9853+
);
9854+
}
9855+
9856+
#[test]
9857+
fn platform_overrides_set_vars_validated_after_merge() {
9858+
// Regression: finalize_profile used to skip re-validating
9859+
// custom_credentials/env_credentials/set_vars after apply_platform_overrides
9860+
// merged them in, letting an override sneak in a reserved set_vars key.
9861+
let current_os = crate::platform::current_os_name();
9862+
let json = format!(
9863+
r#"{{
9864+
"meta": {{"name": "test"}},
9865+
"platform_overrides": {{
9866+
"{current_os}": {{ "environment": {{ "set_vars": {{"PATH": "/evil"}} }} }}
9867+
}}
9868+
}}"#
9869+
);
9870+
let profile: Profile = serde_json::from_str(&json).expect("parse");
9871+
let err = finalize_profile(profile).expect_err("reserved set_vars key must be rejected");
9872+
assert!(
9873+
err.to_string().contains("PATH"),
9874+
"expected error about reserved PATH key, got: {}",
9875+
err
9876+
);
9877+
}
9878+
9879+
#[test]
9880+
fn platform_overrides_on_base_survive_extends_resolution() {
9881+
// Regression: merge_profiles used to null platform_overrides
9882+
// unconditionally, dropping an override defined on a base before
9883+
// finalize_profile ever got to apply it.
9884+
let current_os = crate::platform::current_os_name();
9885+
let dir = tempdir().expect("tmpdir");
9886+
std::fs::write(
9887+
dir.path().join("shared.json"),
9888+
format!(
9889+
r#"{{
9890+
"meta": {{ "name": "shared" }},
9891+
"filesystem": {{ "read": ["/base/path"] }},
9892+
"platform_overrides": {{
9893+
"{current_os}": {{ "filesystem": {{ "read": ["/platform/path"] }} }}
9894+
}}
9895+
}}"#
9896+
),
9897+
)
9898+
.expect("write");
9899+
let child_path = dir.path().join("child.json");
9900+
std::fs::write(
9901+
&child_path,
9902+
r#"{ "extends": "shared", "meta": { "name": "child" } }"#,
9903+
)
9904+
.expect("write");
9905+
9906+
let merged = match load_from_file(&child_path, &[]) {
9907+
Ok(profile) => profile,
9908+
Err(err) => panic!("resolve: {err}"),
9909+
};
9910+
assert!(
9911+
merged.platform_overrides.is_some(),
9912+
"base profile's platform_overrides must survive extends resolution"
9913+
);
9914+
9915+
let finalized = load_profile_from_path(&child_path).expect("load+finalize");
9916+
assert!(
9917+
finalized.platform_overrides.is_none(),
9918+
"platform_overrides should be consumed after finalization"
9919+
);
9920+
assert!(
9921+
finalized
9922+
.filesystem
9923+
.read
9924+
.contains(&"/base/path".to_string()),
9925+
"base path must be present"
9926+
);
9927+
assert!(
9928+
finalized
9929+
.filesystem
9930+
.read
9931+
.contains(&"/platform/path".to_string()),
9932+
"platform override declared on the base profile must apply through extends"
9933+
);
9934+
}
9935+
9936+
#[test]
9937+
fn platform_overrides_merge_per_os_key_child_wins() {
9938+
let current_os = crate::platform::current_os_name();
9939+
let base = Profile {
9940+
filesystem: FilesystemConfig {
9941+
read: vec!["/base/path".to_string()],
9942+
..Default::default()
9943+
},
9944+
platform_overrides: Some(PlatformOverrides {
9945+
macos: if current_os == "macos" {
9946+
Some(Box::new(PlatformOverride(Profile {
9947+
filesystem: FilesystemConfig {
9948+
read: vec!["/base/override/current".to_string()],
9949+
..Default::default()
9950+
},
9951+
..Default::default()
9952+
})))
9953+
} else {
9954+
None
9955+
},
9956+
linux: if current_os == "linux" {
9957+
Some(Box::new(PlatformOverride(Profile {
9958+
filesystem: FilesystemConfig {
9959+
read: vec!["/base/override/current".to_string()],
9960+
..Default::default()
9961+
},
9962+
..Default::default()
9963+
})))
9964+
} else {
9965+
None
9966+
},
9967+
windows: None,
9968+
}),
9969+
..Default::default()
9970+
};
9971+
let child = Profile {
9972+
platform_overrides: Some(PlatformOverrides {
9973+
macos: None,
9974+
linux: None,
9975+
windows: Some(Box::new(PlatformOverride(Profile {
9976+
filesystem: FilesystemConfig {
9977+
read: vec!["/child/override/windows".to_string()],
9978+
..Default::default()
9979+
},
9980+
..Default::default()
9981+
}))),
9982+
}),
9983+
..Default::default()
9984+
};
9985+
9986+
let merged = merge_profiles(base, child);
9987+
let po = merged
9988+
.platform_overrides
9989+
.expect("merged overrides must survive");
9990+
assert!(
9991+
po.windows.is_some(),
9992+
"child-only override key must be preserved"
9993+
);
9994+
assert!(
9995+
(current_os == "macos" && po.macos.is_some())
9996+
|| (current_os == "linux" && po.linux.is_some()),
9997+
"base-only override key for the current platform must be preserved"
9998+
);
9999+
}
10000+
10001+
#[test]
10002+
fn platform_overrides_same_os_key_deep_merges_not_clobbers() {
10003+
let current_os = crate::platform::current_os_name();
10004+
let base_override = Profile {
10005+
network: NetworkConfig {
10006+
open_port: vec![1234],
10007+
..Default::default()
10008+
},
10009+
filesystem: FilesystemConfig {
10010+
read: vec!["/base/override/path".to_string()],
10011+
..Default::default()
10012+
},
10013+
..Default::default()
10014+
};
10015+
let child_override = Profile {
10016+
open_urls: Some(OpenUrlConfig {
10017+
allow_origins: vec!["https://example.com".to_string()],
10018+
allow_localhost: false,
10019+
}),
10020+
..Default::default()
10021+
};
10022+
let mk_overrides = |o: Profile| -> PlatformOverrides {
10023+
let wrapped = Some(Box::new(PlatformOverride(o)));
10024+
if current_os == "macos" {
10025+
PlatformOverrides {
10026+
macos: wrapped,
10027+
linux: None,
10028+
windows: None,
10029+
}
10030+
} else {
10031+
PlatformOverrides {
10032+
macos: None,
10033+
linux: wrapped,
10034+
windows: None,
10035+
}
10036+
}
10037+
};
10038+
let base = Profile {
10039+
platform_overrides: Some(mk_overrides(base_override)),
10040+
..Default::default()
10041+
};
10042+
let child = Profile {
10043+
platform_overrides: Some(mk_overrides(child_override)),
10044+
..Default::default()
10045+
};
10046+
10047+
let merged = merge_profiles(base, child);
10048+
let finalized = finalize_profile(merged).expect("finalize");
10049+
assert!(
10050+
finalized.network.open_port.contains(&1234),
10051+
"base override's open_port must survive a same-OS deep merge with child's override"
10052+
);
10053+
assert!(
10054+
finalized
10055+
.filesystem
10056+
.read
10057+
.contains(&"/base/override/path".to_string()),
10058+
"base override's filesystem grant must survive a same-OS deep merge with child's override"
10059+
);
10060+
assert!(
10061+
finalized
10062+
.open_urls
10063+
.as_ref()
10064+
.is_some_and(|u| u.allow_origins.contains(&"https://example.com".to_string())),
10065+
"child override's own field must still apply"
10066+
);
10067+
}
977510068
}

0 commit comments

Comments
 (0)