//! 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; use crate::paths::{AGENTS_ROOT, SHARED_ROOT}; #[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 _; 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 { let agents_slash = format!("{AGENTS_ROOT}/"); let shared_slash = format!("{SHARED_ROOT}/"); let prefixes: [&str; 4] = ["/agents/", "/shared/", &agents_slash, &shared_slash]; 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()); } }