Compare commits
12 changed files with 420 additions and 13 deletions
|
|
@ -339,7 +339,8 @@ Under `/var/lib/hyperhive/agents/<name>/`:
|
|||
`work/` doesn't walk up into `state/AGENTS.md` and load the agent's
|
||||
own identity. Survives `destroy`/recreate, gone on `--purge`. Counted
|
||||
in the per-agent disk metric (`measure_agent_disk`) together with
|
||||
`state/` and the container rootfs. The dashboard doesn't serve it.
|
||||
`state/` and the container rootfs. The dashboard doesn't serve it:
|
||||
the state-file endpoint reads `state/` only.
|
||||
|
||||
### Cross-agent access to state
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
## Agent trust model
|
||||
|
||||
The sections below document specific mechanisms (nixbld isolation,
|
||||
privilege separation). This section frames the model they
|
||||
The sections below document specific mechanisms (the state-file endpoint,
|
||||
nixbld isolation, privilege separation). This section frames the model they
|
||||
serve: **what hyperhive defends, what it deliberately doesn't, and where the
|
||||
operator is accepting risk.** It's the reference for "is it safe to give an
|
||||
agent capability X?."
|
||||
|
|
@ -20,8 +20,9 @@ default**. Isolating credentials _from the agent itself_ is therefore **not a
|
|||
goal**: an agent can read its own tokens, its own `/home/<name>/.claude`, and
|
||||
run arbitrary commands as root inside its container. (The narrow exception is
|
||||
_cross-tenant_ leakage — for example the unsandboxed-nix-build `0600` token policy
|
||||
below stops a build's nixbld user reading the agent's own forge token. That hardens
|
||||
the boundary; it doesn't sandbox the agent from itself.)
|
||||
below stops a build's nixbld user reading the agent's own forge token, and the
|
||||
state-file endpoint stops one agent proxying another's files. Those harden the
|
||||
boundary; they don't sandbox the agent from itself.)
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -161,10 +162,45 @@ into an agent's config, that agent has exactly that reach, with no hyperhive
|
|||
layer narrowing it. Scope external tokens tightly at the source (the external
|
||||
provider) before handing them over.
|
||||
|
||||
## Agent-supplied names in paths
|
||||
## State-file endpoint security model
|
||||
|
||||
Wherever an agent-supplied name reaches a filesystem path, hyperhive validates
|
||||
it first. The agent socket's `GetAgentMeta` takes `name` as a serde-validated
|
||||
`GET /api/state-file?path=<p>` serves files from agent state dirs and
|
||||
the shared space to authenticated dashboard users (browser, operator).
|
||||
It accepts two allow-listed root prefixes and rejects all other paths
|
||||
before touching the filesystem:
|
||||
|
||||
- `/var/lib/hyperhive/agents/<n>/state/` — per-agent durable notes
|
||||
(canonical host form or the in-container view `/agents/<n>/state/`)
|
||||
- `/var/lib/hyperhive/shared/` — shared docs (`/shared/` in-container)
|
||||
|
||||
`/state/...` without an agent prefix is explicitly _not_ accepted — it's
|
||||
ambiguous from the host's perspective.
|
||||
|
||||
Defense-in-depth layers (in order):
|
||||
|
||||
1. **Allow-list prefix check** — rejects without touching the filesystem
|
||||
if the path doesn't match either root.
|
||||
2. **No symlinks below the matched root** — each path component is
|
||||
checked with `symlink_metadata` before canonicalize. A sub-agent
|
||||
that plants `ln -s /other/secret /agents/me/state/peek` can't proxy
|
||||
another agent's file through this endpoint (canonicalize would
|
||||
happily resolve the symlink to a still-within-allow-list path).
|
||||
3. **Canonicalize as belt-and-braces** — resolves `..`/`.` traversal
|
||||
and rejects if the result escapes the roots.
|
||||
4. **`state/` subdir constraint** — under `AGENTS_ROOT`, the second
|
||||
path component must be `state/`. Applied, proposed git repos and
|
||||
config dirs are off-limits.
|
||||
5. **World-readable check** — file must have `mode & 0o004` set.
|
||||
A `0600` file inside `state/` would otherwise be accessible to any
|
||||
operator with dashboard access.
|
||||
|
||||
`scan_validated_paths` (broker-message ingest, linkifier) uses the same
|
||||
`resolve_state_path` helper so security rules stay in sync — the
|
||||
dashboard renders anchors only for tokens that passed the same checks the
|
||||
read endpoint enforces.
|
||||
|
||||
The same invariant holds wherever an agent-supplied name reaches a filesystem
|
||||
path: the agent socket's `GetAgentMeta` takes `name` as a serde-validated
|
||||
`hive_types::Ident` (or falls back to `Ident::parse` for the "self" case)
|
||||
before building `agent_notes_dir(name)`, so a `..` component can't traverse.
|
||||
|
||||
|
|
|
|||
|
|
@ -555,6 +555,23 @@ below — some endpoints aren't in it yet.
|
|||
stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download
|
||||
raw` links in the build tabs. Works on both finished and in-progress
|
||||
build logs.
|
||||
- `GET /static/marked.js` serves the vendored `marked` bundle used
|
||||
for markdown previews.
|
||||
- `GET /api/state-file?path=<host-or-container-path>` — bounded
|
||||
text read of a file under the per-agent `state/` subtree or
|
||||
the shared `/var/lib/hyperhive/shared/`. Accepts the
|
||||
container-view forms (`/agents/<n>/state/...`, `/shared/...`)
|
||||
and the host form. Canonicalises + verifies the path stays
|
||||
inside the allow-list, refuses anything but a regular file,
|
||||
refuses `/agents/<n>/claude` / `config` subtrees, truncates
|
||||
bodies at 1 MiB. No dashboard page calls it since the flow page
|
||||
stopped existing.
|
||||
|
||||
The broker forwarder still detects path tokens **server-side at
|
||||
broker-message ingest**: it calls `scan_validated_paths(body)` —
|
||||
same allow-list helper the read endpoint uses — and attaches
|
||||
the verified file tokens to the event as `file_refs: Vec<String>`.
|
||||
No dashboard page reads that list either.
|
||||
- `GET /api/reminders` — list pending reminders for the
|
||||
dashboard's queued-reminders panel.
|
||||
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
sha256-CkojHOVdQJGFhqZV8A8dBtR2hb04cRSgd1PbV9LYkzQ=
|
||||
sha256-cX/fNFPKFmN88KQiyau2yYp3xydx5wrv5ZFdMud7UcQ=
|
||||
|
|
|
|||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
|
|
@ -1001,6 +1001,8 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ use crate::lifecycle;
|
|||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
|
||||
(name = "rebuild_queue", description = "rebuild-queue cancel"),
|
||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
)
|
||||
|
|
@ -57,6 +58,7 @@ mod lifecycle_ops;
|
|||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
mod rebuild_queue;
|
||||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
|
||||
|
|
@ -65,6 +67,10 @@ mod tombstones;
|
|||
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
|
||||
pub use meta_inputs::MetaInputView;
|
||||
pub(crate) use meta_inputs::emit_meta_inputs_snapshot;
|
||||
// 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;
|
||||
// Called after destroy/purge/spawn finalisation (`actions.rs`); the view
|
||||
// type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`).
|
||||
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
|
||||
|
|
@ -108,6 +114,7 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
|||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::post_mark_all_read))
|
||||
.routes(routes!(build_logs::get_build_logs_all))
|
||||
|
|
@ -359,6 +366,7 @@ mod router_build_probe {
|
|||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::post_mark_all_read))
|
||||
.routes(routes!(build_logs::get_build_logs_all))
|
||||
|
|
|
|||
322
hive-c0re/src/dashboard/state_files.rs
Normal file
322
hive-c0re/src/dashboard/state_files.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
//! 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/trust-boundary/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 utoipa::IntoParams;
|
||||
|
||||
use super::error_response;
|
||||
use crate::paths::{AGENTS_ROOT, SHARED_ROOT};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct StateFileQuery {
|
||||
/// Absolute path under an agent's `state/` dir or under `shared/`;
|
||||
/// checked against the allow-list (`docs/trust-boundary/security.md::State-file
|
||||
/// endpoint`).
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Resolve a caller-supplied path against the allow-listed roots
|
||||
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
|
||||
/// symlink + traversal checks before serving. Security model and
|
||||
/// all five layers: `docs/trust-boundary/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<String> {
|
||||
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::<String>::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
|
||||
}
|
||||
|
||||
/// Serve an allow-listed file.
|
||||
///
|
||||
/// Raster images get their real content-type; everything else is
|
||||
/// served as (possibly truncated) text.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/state-file",
|
||||
params(StateFileQuery),
|
||||
responses(
|
||||
(status = 200, description = "file contents (text, truncated at 1 MiB) or image bytes"),
|
||||
(status = 500, description = "path outside the allow-list, not a regular file, or read failed"),
|
||||
),
|
||||
tag = "state_files"
|
||||
)]
|
||||
pub(super) async fn get_state_file(
|
||||
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
|
||||
) -> 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 <img>. 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
|
||||
/// `<img>`, 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ use chrono::{DateTime, Utc};
|
|||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum DashboardEvent {
|
||||
/// Broker `Sent` event mirrored onto the dashboard channel.
|
||||
/// `file_refs` carries every path-shaped token in `body` that
|
||||
/// hive-c0re verified is a regular file under the allow-listed
|
||||
/// roots (per-agent `state/` + `shared/`). The forwarder
|
||||
/// pre-validates so the dashboard doesn't need a probe
|
||||
/// endpoint — the client renders anchors only for tokens that
|
||||
/// appear in this list, everything else stays plain text.
|
||||
Sent {
|
||||
seq: u64,
|
||||
/// Broker row id. Allows the dashboard to track reply threads.
|
||||
|
|
@ -24,8 +30,11 @@ pub enum DashboardEvent {
|
|||
at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
in_reply_to: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// Broker `Delivered` event mirrored onto the dashboard channel.
|
||||
/// `file_refs` is the same shape as `Sent`.
|
||||
Delivered {
|
||||
seq: u64,
|
||||
/// Broker row id. Allows the dashboard to track reply threads.
|
||||
|
|
@ -36,6 +45,8 @@ pub enum DashboardEvent {
|
|||
at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
in_reply_to: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// A lifecycle action started for an agent (spawn / start / stop
|
||||
/// / restart / rebuild / destroy). Clients render a spinner next
|
||||
|
|
@ -190,6 +201,7 @@ mod tests {
|
|||
body: String::new(),
|
||||
at: hive_sh4re::wire_time::from_secs(0),
|
||||
in_reply_to: None,
|
||||
file_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::Delivered {
|
||||
seq: 1,
|
||||
|
|
@ -199,6 +211,7 @@ mod tests {
|
|||
body: String::new(),
|
||||
at: hive_sh4re::wire_time::from_secs(0),
|
||||
in_reply_to: None,
|
||||
file_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::TransientSet {
|
||||
seq: 1,
|
||||
|
|
|
|||
|
|
@ -469,6 +469,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
|||
at,
|
||||
in_reply_to,
|
||||
}) => {
|
||||
let file_refs = dashboard::scan_validated_paths(&body);
|
||||
coord.emit_dashboard_event(DashboardEvent::Sent {
|
||||
seq: coord.next_seq(),
|
||||
id,
|
||||
|
|
@ -477,6 +478,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
|||
body,
|
||||
at: hive_sh4re::wire_time::from_secs(at),
|
||||
in_reply_to,
|
||||
file_refs,
|
||||
});
|
||||
}
|
||||
Ok(MessageEvent::Delivered {
|
||||
|
|
@ -487,6 +489,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
|||
at,
|
||||
in_reply_to,
|
||||
}) => {
|
||||
let file_refs = dashboard::scan_validated_paths(&body);
|
||||
coord.emit_dashboard_event(DashboardEvent::Delivered {
|
||||
seq: coord.next_seq(),
|
||||
id,
|
||||
|
|
@ -495,6 +498,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
|||
body,
|
||||
at: hive_sh4re::wire_time::from_secs(at),
|
||||
in_reply_to,
|
||||
file_refs,
|
||||
});
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ pub fn meta_git_index_lock() -> PathBuf {
|
|||
meta_root().join(".git/index.lock")
|
||||
}
|
||||
|
||||
/// `shared/` — the cross-agent `/shared` scratch space. A `&str`, so it
|
||||
/// `shared/` — the cross-agent `/shared` scratch space. A `&str` (the
|
||||
/// dashboard state-file allow-list uses it for prefix checks), so it
|
||||
/// stays a const; [`shared_root`] wraps it.
|
||||
// nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match.
|
||||
pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ use std::process::{Command, Stdio};
|
|||
const TOKEN: &str = "test-token-value";
|
||||
|
||||
/// A unique scratch `HYPERHIVE_STATE_DIR`, seeded with a `forge-token`
|
||||
/// file. Built by hand under the system temp dir, since this crate carries no
|
||||
/// `tempfile` dependency to reach for instead.
|
||||
/// file. Matches the manual-tmpdir pattern `hive-c0re`'s
|
||||
/// `dashboard::state_files` tests use — this crate carries no `tempfile`
|
||||
/// dependency to reach for instead.
|
||||
fn scratch_state_dir(tag: &str) -> PathBuf {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
|
|||
Loading…
Reference in a new issue