From 95898338fc044a546e402a0e46d6b486c31e8212 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 13 Sep 2026 16:41:57 +0200 Subject: [PATCH] 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- 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 --- hive-c0re/src/job_queue/exec.rs | 100 ++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 3b4c099a..92bd448b 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -545,7 +545,7 @@ async fn run_meta_lock( // Lock file changed — meta-inputs panel re-renders. crate::dashboard::emit_meta_inputs_snapshot(coord); let cascade = match fanout { - Some(list) => list, + Some(list) => validate_agent_names(list), None => meta_update_cascade_agents(inputs).await, }; // 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; /// otherwise just the agents named by `agent-` inputs. /// 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-` 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 { let touched_hyperhive = inputs .iter() .any(|i| i == "hyperhive" || i.starts_with("hyperhive/")); - let touched_agents: Vec = inputs - .iter() - .filter_map(|i| i.strip_prefix("agent-")) - .map(|rest| rest.split('/').next().unwrap_or(rest).to_owned()) - .collect(); + let touched_agents = parse_agent_input_names(inputs); let mut names = if touched_hyperhive || inputs.is_empty() { crate::lifecycle::list() .await @@ -917,3 +922,86 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec { crate::auto_update::topology_sort(&mut names, &topo); names } + +/// Parse `agent-` 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 { + 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/.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) -> Vec { + 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-` input would. + let names = vec![ + "iris".to_owned(), + "../../etc".to_owned(), + "atlas".to_owned(), + ]; + assert_eq!(validate_agent_names(names), vec!["iris", "atlas"]); + } +}