hive-c0re: validate cascade agent names in meta_update_cascade_agents' fanout path too

extract validate_agent_names() and use it for both the parsed
agent-<name> inputs and run_meta_lock's pre-computed fanout list, so a
malformed name can't reach the new fast_forward_applied_main / lock_update
filesystem+git+forge-URL operations regardless of which of the two
sources it came from
This commit is contained in:
damocles 2026-09-13 16:41:57 +02:00 committed by mara
commit 95898338fc

View file

@ -545,7 +545,7 @@ async fn run_meta_lock(
// Lock file changed — meta-inputs panel re-renders. // Lock file changed — meta-inputs panel re-renders.
crate::dashboard::emit_meta_inputs_snapshot(coord); crate::dashboard::emit_meta_inputs_snapshot(coord);
let cascade = match fanout { let cascade = match fanout {
Some(list) => list, Some(list) => validate_agent_names(list),
None => meta_update_cascade_agents(inputs).await, None => meta_update_cascade_agents(inputs).await,
}; };
// Pull each cascade agent's own input too — an agent's config-repo main // Pull each cascade agent's own input too — an agent's config-repo main
@ -891,15 +891,20 @@ async fn run_deploy_tail(
/// `inputs` or any input under `hyperhive` → every container; /// `inputs` or any input under `hyperhive` → every container;
/// otherwise just the agents named by `agent-<name>` inputs. /// otherwise just the agents named by `agent-<name>` inputs.
/// Topology-sorted so parents rebuild before their children. /// Topology-sorted so parents rebuild before their children.
///
/// `inputs` is the caller-supplied flake-input-name list
/// (`RequestUpdateMetaInputs`'s `inputs` field, operator-approved but not
/// otherwise validated) — the `agent-<name>` branch parses agent names
/// straight out of it, so each is validated through [`hive_types::Ident`]
/// before it ever reaches a filesystem path or a forge URL built from a
/// cascade agent's name. The `touched_hyperhive` branch's names need no
/// such filter: they come from `lifecycle::list()`, already real
/// container names by construction, not parsed out of caller input.
pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> { pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
let touched_hyperhive = inputs let touched_hyperhive = inputs
.iter() .iter()
.any(|i| i == "hyperhive" || i.starts_with("hyperhive/")); .any(|i| i == "hyperhive" || i.starts_with("hyperhive/"));
let touched_agents: Vec<String> = inputs let touched_agents = parse_agent_input_names(inputs);
.iter()
.filter_map(|i| i.strip_prefix("agent-"))
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned())
.collect();
let mut names = if touched_hyperhive || inputs.is_empty() { let mut names = if touched_hyperhive || inputs.is_empty() {
crate::lifecycle::list() crate::lifecycle::list()
.await .await
@ -917,3 +922,86 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
crate::auto_update::topology_sort(&mut names, &topo); crate::auto_update::topology_sort(&mut names, &topo);
names names
} }
/// Parse `agent-<name>` input strings into validated agent names. Pure and
/// synchronous (no `lifecycle::list()` call) so the validation — the part
/// that actually matters for safety, since these names end up in
/// filesystem paths and forge URLs — is unit-testable without touching
/// live container state. Delegates the actual filtering to
/// [`validate_agent_names`] after stripping the `agent-` prefix.
fn parse_agent_input_names(inputs: &[String]) -> Vec<String> {
let candidates = inputs
.iter()
.filter_map(|i| i.strip_prefix("agent-"))
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned());
validate_agent_names(candidates)
}
/// Drop any name that isn't a well-formed [`hive_types::Ident`] — the same
/// validating parser every other agent-name wire field in this codebase
/// goes through, so a name that reaches
/// [`fast_forward_applied_main`](crate::forge::fast_forward_applied_main) or
/// a `git+http://.../agent-configs/<name>.git` URL built off it has already
/// passed the same bar as one that arrived over a socket. Not just a
/// helper for [`parse_agent_input_names`]: `run_meta_lock`'s `fanout`
/// parameter carries pre-computed agent names too, and while today's only
/// caller of that branch (the boot sweep, `sweep = true`) returns before
/// ever reaching the filesystem/git code below, nothing in the type
/// enforces that pairing — so this same filter also guards the
/// `Some(list)` arm, closing the gap for good rather than relying on it
/// staying incidental.
fn validate_agent_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
names
.into_iter()
.filter(|name| {
let valid = hive_types::Ident::parse(name).is_ok();
if !valid {
tracing::warn!(%name, "meta-update cascade: dropping malformed agent name");
}
valid
})
.collect()
}
#[cfg(test)]
mod cascade_agent_tests {
use super::{parse_agent_input_names, validate_agent_names};
#[test]
fn well_formed_agent_inputs_pass_through() {
let inputs = vec!["agent-iris".to_owned(), "agent-atlas".to_owned()];
assert_eq!(parse_agent_input_names(&inputs), vec!["iris", "atlas"]);
}
#[test]
fn path_traversal_in_an_agent_input_is_dropped() {
let inputs = vec!["agent-../../etc".to_owned(), "agent-iris".to_owned()];
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
}
#[test]
fn non_agent_inputs_are_ignored_not_misparsed() {
let inputs = vec!["hyperhive".to_owned(), "nixpkgs".to_owned()];
assert!(parse_agent_input_names(&inputs).is_empty());
}
#[test]
fn oversized_or_uppercase_names_are_dropped() {
let too_long = format!("agent-{}", "a".repeat(64));
let inputs = vec![too_long, "agent-Iris".to_owned(), "agent-iris".to_owned()];
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
}
#[test]
fn fanout_supplied_names_are_validated_too() {
// `run_meta_lock`'s `Some(list)` arm feeds `fanout` straight into
// this filter — pin that a bogus name in that pre-computed list
// gets dropped exactly like a parsed `agent-<name>` input would.
let names = vec![
"iris".to_owned(),
"../../etc".to_owned(),
"atlas".to_owned(),
];
assert_eq!(validate_agent_names(names), vec!["iris", "atlas"]);
}
}