diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 7fa508d4..d5e20fc3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -37,6 +37,7 @@ mod permissions; mod questions; mod reminders; mod schedules; +mod state_files; mod topology; mod webhook; @@ -45,6 +46,10 @@ mod webhook; // re-exported at the module root to preserve the `crate::dashboard::approval_diff` // path across the submodule split. pub(crate) use approvals::approval_diff; +// Run at broker-message ingest by the coordinator + the operator-msg path +// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths` +// path across the split. +pub use state_files::scan_validated_paths; #[derive(Clone)] struct AppState { @@ -94,7 +99,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/journal/{name}", get(journal::get_journal)) .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(approvals::get_approval_diff)) - .route("/api/state-file", get(get_state_file)) + .route("/api/state-file", get(state_files::get_state_file)) .route("/api/reminders", get(reminders::api_reminders)) .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) @@ -1081,139 +1086,9 @@ struct RequestSpawnForm { name: String, } -#[derive(Deserialize)] -struct StateFileQuery { - path: String, -} - -/// Resolve a caller-supplied path against the allow-listed roots -/// (`agents//state/` and `shared/`). Applies defense-in-depth -/// symlink + traversal checks before serving. Security model and -/// all five layers: `docs/security.md::State-file endpoint`. -fn resolve_state_path( - raw: &str, -) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> { - use std::os::unix::fs::PermissionsExt as _; - const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; - const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; - let raw = raw.trim(); - let (mapped, root): (std::path::PathBuf, &str) = - if let Some(rest) = raw.strip_prefix("/agents/") { - ( - std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), - AGENTS_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix("/shared/") { - ( - std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), - SHARED_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) { - ( - std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), - AGENTS_ROOT, - ) - } else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) { - ( - std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), - SHARED_ROOT, - ) - } else { - return Err(format!("path not in allow-list: {raw}")); - }; - reject_symlinks_below(std::path::Path::new(root), &mapped)?; - let canonical = - std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?; - if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) { - return Err(format!( - "resolved path escapes allow-list: {}", - canonical.display() - )); - } - if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { - let mut components = rel.components(); - let _agent = components.next(); - let dir = components.next().and_then(|c| c.as_os_str().to_str()); - if dir != Some("state") { - return Err(format!( - "only per-agent state/ is readable here ({} dir not allowed)", - dir.unwrap_or("(root)") - )); - } - } - let meta = - std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?; - if meta.is_file() { - let mode = meta.permissions().mode(); - if mode & 0o004 == 0 { - return Err(format!( - "{} not world-readable (mode 0{:o}); refusing to proxy non-public file", - canonical.display(), - mode & 0o777, - )); - } - } - Ok((canonical, meta)) -} - -/// Walk every path component under `root` and refuse if any of -/// them is a symlink. The roots themselves (`AGENTS_ROOT`, -/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only -/// the parts the agent / operator can plant matter. Components -/// that don't exist yet are skipped — `canonicalize` reports -/// non-existence separately, and missing-component checks would -/// just race the filesystem. -fn reject_symlinks_below( - root: &std::path::Path, - mapped: &std::path::Path, -) -> std::result::Result<(), String> { - let Ok(rel) = mapped.strip_prefix(root) else { - return Ok(()); - }; - let mut cumulative = root.to_path_buf(); - for component in rel.components() { - match component { - std::path::Component::Normal(name) => { - cumulative.push(name); - match std::fs::symlink_metadata(&cumulative) { - Ok(m) if m.file_type().is_symlink() => { - return Err(format!( - "symlink at {} not allowed (canonicalize would resolve it past the \ - allow-list check; refuse outright)", - cumulative.display() - )); - } - Ok(_) | Err(_) => {} - } - } - std::path::Component::ParentDir => { - return Err(format!( - "path contains `..` traversal below {}; refuse outright", - root.display() - )); - } - _ => {} - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::symlink; - - /// Make a unique tmp subdir for the calling test. Caller is responsible - /// for cleanup (we leak on panic, fine for ephemeral CI runs). - fn tmproot(tag: &str) -> std::path::PathBuf { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_nanos()); - let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}")); - std::fs::create_dir_all(&p).unwrap(); - p - } - #[test] fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() { // Reproduce the shape where meta has @@ -1318,61 +1193,6 @@ mod tests { assert!(validate_agent_name("damóclès").is_some()); assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash } - - #[test] - fn reject_symlinks_below_accepts_plain_dirs_and_files() { - let root = tmproot("symlink-ok"); - std::fs::create_dir_all(root.join("alice/state")).unwrap(); - std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap(); - assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok()); - } - - #[test] - fn reject_symlinks_below_rejects_leaf_symlink() { - let root = tmproot("symlink-leaf"); - std::fs::create_dir_all(root.join("alice/state")).unwrap(); - // Plant a symlink that points anywhere; resolve_state_path's - // canonicalize would happily resolve it past the allow-list - // check, so we have to refuse at the un-canonical layer. - symlink("/etc/shadow", root.join("alice/state/peek")).unwrap(); - let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err(); - assert!(err.contains("symlink at"), "msg = {err}"); - assert!(err.contains("peek"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_rejects_directory_symlink_in_middle() { - let root = tmproot("symlink-mid"); - std::fs::create_dir_all(root.join("real/state")).unwrap(); - std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap(); - // alice's "state" dir is actually a symlink to real/state — a - // sub-agent shouldn't be able to plant this and proxy real's - // private files via the dashboard. - std::fs::create_dir_all(root.join("alice")).unwrap(); - symlink(root.join("real/state"), root.join("alice/state")).unwrap(); - let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err(); - assert!(err.contains("symlink at"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_rejects_parent_dir_traversal() { - let root = tmproot("symlink-dotdot"); - // `..` doesn't survive canonicalize anyway, but we want a - // friendlier error than "path escapes allow-list" — refusing - // upfront also avoids walking ancestors with `symlink_metadata`. - let p = root.join("alice/state/../escape"); - let err = reject_symlinks_below(&root, &p).unwrap_err(); - assert!(err.contains("`..`"), "msg = {err}"); - } - - #[test] - fn reject_symlinks_below_passes_through_when_path_not_under_root() { - // resolve_state_path's earlier allow-list check would reject - // this; reject_symlinks_below stays a no-op so the caller - // surfaces the better-fit error. - let root = std::path::Path::new("/var/lib/hyperhive/agents"); - assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok()); - } } /// Snapshot the current tombstone list and emit a @@ -1401,108 +1221,6 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { }); } -/// Scan `body` for path-shaped tokens and return those that pass the -/// allow-list + `is_file` check via `resolve_state_path`. Called at -/// broker-message ingest so the dashboard event already carries the -/// verified set; security rules stay in sync with the read endpoint. -pub fn scan_validated_paths(body: &str) -> Vec { - const PREFIXES: [&str; 4] = [ - "/agents/", - "/shared/", - "/var/lib/hyperhive/agents/", - "/var/lib/hyperhive/shared/", - ]; - let mut out = Vec::::new(); - for raw in body.split(|c: char| c.is_whitespace()) { - // Trim trailing natural-language punctuation that wouldn't - // be part of any real path. Inline rather than via a regex - // dep — the set is small and the call is hot. - let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']); - if token.is_empty() { - continue; - } - if !PREFIXES.iter().any(|p| token.starts_with(p)) { - continue; - } - // Cheap dedupe — typical message has 0-3 refs. - if out.iter().any(|s| s == token) { - continue; - } - if let Ok((_canonical, meta)) = resolve_state_path(token) - && meta.is_file() - { - out.push(token.to_owned()); - } - } - out -} - -async fn get_state_file(axum::extract::Query(q): axum::extract::Query) -> Response { - const MAX_BYTES: usize = 1 << 20; // 1 MiB - let (canonical, meta) = match resolve_state_path(&q.path) { - Ok(pair) => pair, - Err(e) => return error_response(&format!("state-file: {e}")), - }; - if !meta.is_file() { - return error_response(&format!( - "state-file: {} is not a regular file", - canonical.display() - )); - } - let size = meta.len(); - let bytes = match std::fs::read(&canonical) { - Ok(b) => b, - Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), - }; - // Raster images: serve the raw bytes with their real content-type - // so the dashboard can render them in an . Not truncated — - // a clipped binary is corrupt, so over-cap images are rejected - // instead. (SVG stays on the text path: it's text, and the client - // renders it via a data: URI.) - if let Some(ct) = image_content_type(&canonical) { - if bytes.len() > MAX_BYTES { - return error_response(&format!( - "state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap", - canonical.display() - )); - } - return ([("content-type", ct)], bytes).into_response(); - } - let truncated = bytes.len() > MAX_BYTES; - let body_bytes = if truncated { - &bytes[..MAX_BYTES] - } else { - &bytes[..] - }; - let mut body = String::from_utf8_lossy(body_bytes).into_owned(); - if truncated { - use std::fmt::Write as _; - let _ = write!( - body, - "\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n" - ); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() -} - -/// Content-type for a raster image the dashboard can preview in an -/// ``, keyed off the file extension. `None` for non-image, SVG, -/// and text files (SVG is served on the text path and rendered -/// client-side via a `data:` URI). -fn image_content_type(path: &Path) -> Option<&'static str> { - let ext = path.extension()?.to_str()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "bmp" => "image/bmp", - "ico" => "image/x-icon", - "avif" => "image/avif", - _ => return None, - }) -} - /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox /// (#1469). Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing diff --git a/hive-c0re/src/dashboard/state_files.rs b/hive-c0re/src/dashboard/state_files.rs new file mode 100644 index 00000000..002abde7 --- /dev/null +++ b/hive-c0re/src/dashboard/state_files.rs @@ -0,0 +1,308 @@ +//! State-file proxy + path-validation for the dashboard. +//! +//! `GET /api/state-file?path=…` serves an allow-listed file (per-agent +//! `state/` or `shared/`) with defense-in-depth symlink + traversal checks +//! (see `docs/security.md::State-file endpoint`); raster images are served +//! with their real content-type, everything else as truncated text. +//! `scan_validated_paths` runs the same allow-list at broker-message ingest +//! so dashboard events carry a pre-verified file-ref set. + +use std::path::Path; + +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use super::error_response; + +#[derive(Deserialize)] +pub(super) struct StateFileQuery { + path: String, +} + +/// Resolve a caller-supplied path against the allow-listed roots +/// (`agents//state/` and `shared/`). Applies defense-in-depth +/// symlink + traversal checks before serving. Security model and +/// all five layers: `docs/security.md::State-file endpoint`. +fn resolve_state_path( + raw: &str, +) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> { + use std::os::unix::fs::PermissionsExt as _; + const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; + const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; + let raw = raw.trim(); + let (mapped, root): (std::path::PathBuf, &str) = + if let Some(rest) = raw.strip_prefix("/agents/") { + ( + std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), + AGENTS_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix("/shared/") { + ( + std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), + SHARED_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) { + ( + std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")), + AGENTS_ROOT, + ) + } else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) { + ( + std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")), + SHARED_ROOT, + ) + } else { + return Err(format!("path not in allow-list: {raw}")); + }; + reject_symlinks_below(std::path::Path::new(root), &mapped)?; + let canonical = + std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?; + if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) { + return Err(format!( + "resolved path escapes allow-list: {}", + canonical.display() + )); + } + if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { + let mut components = rel.components(); + let _agent = components.next(); + let dir = components.next().and_then(|c| c.as_os_str().to_str()); + if dir != Some("state") { + return Err(format!( + "only per-agent state/ is readable here ({} dir not allowed)", + dir.unwrap_or("(root)") + )); + } + } + let meta = + std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?; + if meta.is_file() { + let mode = meta.permissions().mode(); + if mode & 0o004 == 0 { + return Err(format!( + "{} not world-readable (mode 0{:o}); refusing to proxy non-public file", + canonical.display(), + mode & 0o777, + )); + } + } + Ok((canonical, meta)) +} + +/// Walk every path component under `root` and refuse if any of +/// them is a symlink. The roots themselves (`AGENTS_ROOT`, +/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only +/// the parts the agent / operator can plant matter. Components +/// that don't exist yet are skipped — `canonicalize` reports +/// non-existence separately, and missing-component checks would +/// just race the filesystem. +fn reject_symlinks_below( + root: &std::path::Path, + mapped: &std::path::Path, +) -> std::result::Result<(), String> { + let Ok(rel) = mapped.strip_prefix(root) else { + return Ok(()); + }; + let mut cumulative = root.to_path_buf(); + for component in rel.components() { + match component { + std::path::Component::Normal(name) => { + cumulative.push(name); + match std::fs::symlink_metadata(&cumulative) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!( + "symlink at {} not allowed (canonicalize would resolve it past the \ + allow-list check; refuse outright)", + cumulative.display() + )); + } + Ok(_) | Err(_) => {} + } + } + std::path::Component::ParentDir => { + return Err(format!( + "path contains `..` traversal below {}; refuse outright", + root.display() + )); + } + _ => {} + } + } + Ok(()) +} + +/// Scan `body` for path-shaped tokens and return those that pass the +/// allow-list + `is_file` check via `resolve_state_path`. Called at +/// broker-message ingest so the dashboard event already carries the +/// verified set; security rules stay in sync with the read endpoint. +pub fn scan_validated_paths(body: &str) -> Vec { + const PREFIXES: [&str; 4] = [ + "/agents/", + "/shared/", + "/var/lib/hyperhive/agents/", + "/var/lib/hyperhive/shared/", + ]; + let mut out = Vec::::new(); + for raw in body.split(|c: char| c.is_whitespace()) { + // Trim trailing natural-language punctuation that wouldn't + // be part of any real path. Inline rather than via a regex + // dep — the set is small and the call is hot. + let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']); + if token.is_empty() { + continue; + } + if !PREFIXES.iter().any(|p| token.starts_with(p)) { + continue; + } + // Cheap dedupe — typical message has 0-3 refs. + if out.iter().any(|s| s == token) { + continue; + } + if let Ok((_canonical, meta)) = resolve_state_path(token) + && meta.is_file() + { + out.push(token.to_owned()); + } + } + out +} + +pub(super) async fn get_state_file( + axum::extract::Query(q): axum::extract::Query, +) -> Response { + const MAX_BYTES: usize = 1 << 20; // 1 MiB + let (canonical, meta) = match resolve_state_path(&q.path) { + Ok(pair) => pair, + Err(e) => return error_response(&format!("state-file: {e}")), + }; + if !meta.is_file() { + return error_response(&format!( + "state-file: {} is not a regular file", + canonical.display() + )); + } + let size = meta.len(); + let bytes = match std::fs::read(&canonical) { + Ok(b) => b, + Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())), + }; + // Raster images: serve the raw bytes with their real content-type + // so the dashboard can render them in an . Not truncated — + // a clipped binary is corrupt, so over-cap images are rejected + // instead. (SVG stays on the text path: it's text, and the client + // renders it via a data: URI.) + if let Some(ct) = image_content_type(&canonical) { + if bytes.len() > MAX_BYTES { + return error_response(&format!( + "state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap", + canonical.display() + )); + } + return ([("content-type", ct)], bytes).into_response(); + } + let truncated = bytes.len() > MAX_BYTES; + let body_bytes = if truncated { + &bytes[..MAX_BYTES] + } else { + &bytes[..] + }; + let mut body = String::from_utf8_lossy(body_bytes).into_owned(); + if truncated { + use std::fmt::Write as _; + let _ = write!( + body, + "\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n" + ); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() +} + +/// Content-type for a raster image the dashboard can preview in an +/// ``, keyed off the file extension. `None` for non-image, SVG, +/// and text files (SVG is served on the text path and rendered +/// client-side via a `data:` URI). +fn image_content_type(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + /// Make a unique tmp subdir for the calling test. Caller is responsible + /// for cleanup (we leak on panic, fine for ephemeral CI runs). + fn tmproot(tag: &str) -> std::path::PathBuf { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn reject_symlinks_below_accepts_plain_dirs_and_files() { + let root = tmproot("symlink-ok"); + std::fs::create_dir_all(root.join("alice/state")).unwrap(); + std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap(); + assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok()); + } + + #[test] + fn reject_symlinks_below_rejects_leaf_symlink() { + let root = tmproot("symlink-leaf"); + std::fs::create_dir_all(root.join("alice/state")).unwrap(); + // Plant a symlink that points anywhere; resolve_state_path's + // canonicalize would happily resolve it past the allow-list + // check, so we have to refuse at the un-canonical layer. + symlink("/etc/shadow", root.join("alice/state/peek")).unwrap(); + let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err(); + assert!(err.contains("symlink at"), "msg = {err}"); + assert!(err.contains("peek"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_rejects_directory_symlink_in_middle() { + let root = tmproot("symlink-mid"); + std::fs::create_dir_all(root.join("real/state")).unwrap(); + std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap(); + // alice's "state" dir is actually a symlink to real/state — a + // sub-agent shouldn't be able to plant this and proxy real's + // private files via the dashboard. + std::fs::create_dir_all(root.join("alice")).unwrap(); + symlink(root.join("real/state"), root.join("alice/state")).unwrap(); + let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err(); + assert!(err.contains("symlink at"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_rejects_parent_dir_traversal() { + let root = tmproot("symlink-dotdot"); + // `..` doesn't survive canonicalize anyway, but we want a + // friendlier error than "path escapes allow-list" — refusing + // upfront also avoids walking ancestors with `symlink_metadata`. + let p = root.join("alice/state/../escape"); + let err = reject_symlinks_below(&root, &p).unwrap_err(); + assert!(err.contains("`..`"), "msg = {err}"); + } + + #[test] + fn reject_symlinks_below_passes_through_when_path_not_under_root() { + // resolve_state_path's earlier allow-list check would reject + // this; reject_symlinks_below stays a no-op so the caller + // surfaces the better-fit error. + let root = std::path::Path::new("/var/lib/hyperhive/agents"); + assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok()); + } +}