feat(#2302): parse agent names into AgentName newtype at dashboard boundary

This commit is contained in:
damocles 2026-07-19 17:46:04 +02:00 committed by mara
commit cfac917b4d
8 changed files with 162 additions and 103 deletions

View file

@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
use tokio_stream::Stream;
use tokio_stream::wrappers::ReceiverStream;
use super::{AppState, error_response, validate_agent_name};
use super::{AppState, error_response, idents::AgentName};
#[derive(Deserialize)]
pub(super) struct BuildLogsAllQuery {
@ -58,11 +58,18 @@ pub(super) async fn get_build_logs_agent(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
) -> Response {
if let Some(reason) = validate_agent_name(&name) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
let name = match AgentName::parse(&name) {
Ok(n) => n,
Err(reason) => {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
};
let limit = q.limit.unwrap_or(10);
match state.coord.build_logs.list_recent_for_agent(&name, limit) {
match state
.coord
.build_logs
.list_recent_for_agent(name.as_str(), limit)
{
Ok(rows) => axum::Json(rows).into_response(),
Err(e) => error_response(&format!("build-logs {name}: {e:#}")),
}

View file

@ -0,0 +1,103 @@
//! Validated identifier newtypes for dashboard path-params — "parse, don't
//! validate" (#2302).
//!
//! [`AgentName`] can only be constructed through a validating parser, so
//! "this string passed the naming whitelist" becomes a compile-time fact the
//! type carries, instead of a convention every handler re-checks against the
//! raw `String`. This is **format** validation only —
//! whether the name refers to a *live* agent is a separate, stateful runtime
//! concern kept at the lookup sites (see `guard_agent_name`), deliberately not
//! folded into the constructor.
use std::fmt;
/// A validated agent name: 1-63 chars of `[a-z0-9_-]`.
///
/// Constructed only via [`AgentName::parse`]. The invariant matches the
/// historical `validate_agent_name` whitelist (conservative, tracking
/// `nixos-container` basename rules and the agent-name convention across the
/// codebase): rejects empty, over-long, uppercase, slashes, dots, and any
/// non-ASCII (incl. unicode homoglyphs of dash / underscore).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AgentName(String);
impl AgentName {
/// Parse + validate a path-param agent name (format only).
///
/// # Errors
/// Returns `Err(reason)` — a caller-ready 400-body string — when `name`
/// is empty, longer than 63 chars, or contains any byte outside
/// `[a-z0-9_-]`.
pub(crate) fn parse(name: &str) -> Result<Self, &'static str> {
if name.is_empty() {
return Err("agent name must not be empty");
}
if name.len() > 63 {
return Err("agent name must be 63 characters or fewer");
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Err("agent name must contain only [a-z0-9_-]");
}
Ok(Self(name.to_owned()))
}
/// The validated name as a string slice.
#[must_use]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::AgentName;
#[test]
fn agent_name_accepts_canonical_shapes() {
for ok in [
"damocles",
"hm1nd",
"agent-with-dashes",
"snake_case",
"mixed_2-3",
] {
assert!(AgentName::parse(ok).is_ok(), "should accept {ok:?}");
}
let max = "a".repeat(63);
assert!(AgentName::parse(&max).is_ok(), "63 chars is the boundary");
}
#[test]
fn agent_name_rejects_bad_input() {
let too_long = "a".repeat(64);
for bad in [
"",
&too_long,
"../etc/passwd",
"alice/bob",
"Alice",
"alice bob",
"alice.bob",
"alice;DROP TABLE messages",
// Non-ASCII, incl. unicode homoglyphs of ASCII dash.
"damóclès",
"alice\u{2013}bob", // en-dash
] {
assert!(AgentName::parse(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn agent_name_round_trips_as_str() {
assert_eq!(AgentName::parse("damocles").unwrap().as_str(), "damocles");
}
}

View file

@ -18,7 +18,7 @@ use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{error_problem, strip_container_prefix, validate_agent_name};
use super::{error_problem, idents::AgentName, strip_container_prefix};
use crate::lifecycle;
#[derive(Deserialize)]
@ -57,13 +57,16 @@ pub(super) async fn get_journal(
// shellout below — the `lifecycle::list()` existence check would
// catch them anyway, but rejecting at the boundary keeps the
// failure mode crisp.
if let Some(reason) = validate_agent_name(&name) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("bad agent name: {reason}")));
}
let name = match AgentName::parse(&name) {
Ok(n) => n,
Err(reason) => {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("bad agent name: {reason}")));
}
};
// Validate the container name against the list of managed
// containers so we don't shell out with arbitrary input.
let container = strip_container_prefix(&name);
let container = strip_container_prefix(name.as_str());
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
let live = lifecycle::list().await.unwrap_or_default();
if !live.iter().any(|c| c == &prefixed) {

View file

@ -10,7 +10,7 @@ use axum::{
};
use serde::Deserialize;
use super::{AppState, error_response, scan_validated_paths, validate_agent_name};
use super::{AppState, error_response, idents::AgentName, scan_validated_paths};
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
/// Returns messages addressed to `"operator"` that haven't been
@ -113,10 +113,13 @@ pub(super) async fn post_mark_all_read(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
if let Some(reason) = validate_agent_name(&name) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
match state.coord.broker.mark_all_read(&name) {
let name = match AgentName::parse(&name) {
Ok(n) => n,
Err(reason) => {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
};
match state.coord.broker.mark_all_read(name.as_str()) {
Ok(n) => {
tracing::info!(%name, marked = n, "operator marked all messages read");
axum::Json(serde_json::json!({ "marked": n })).into_response()

View file

@ -19,6 +19,8 @@ use crate::lifecycle;
mod approvals;
mod build_logs;
mod extra_forges;
mod idents;
pub(crate) use idents::AgentName;
mod infra_containers;
mod journal;
mod lifecycle_ops;
@ -292,33 +294,10 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024)
}
/// Validate that a path-param agent name conforms to the hyperhive
/// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty,
/// uppercase, slashes, dots, and any non-ASCII (incl. unicode
/// homoglyphs of dash/underscore). Returns `None` on accept, `Some(reason)`
/// on reject — caller wraps the reason in a 400 response. Conservative
/// whitelist matching `nixos-container` basename rules and the existing
/// agent-name convention across the codebase.
pub(crate) fn validate_agent_name(name: &str) -> Option<&'static str> {
if name.is_empty() {
return Some("agent name must not be empty");
}
if name.len() > 63 {
return Some("agent name must be 63 characters or fewer");
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Some("agent name must contain only [a-z0-9_-]");
}
None
}
/// Two-axis path-param guard for write routes. Combines:
///
/// 1. **format validation** (`validate_agent_name`) — rejects path
/// traversal / unicode homoglyphs / empty + too-long names with
/// 1. **format validation** ([`idents::AgentName::parse`]) — rejects
/// path traversal / unicode homoglyphs / empty + too-long names with
/// HTTP 400.
/// 2. **existence check** — looks up `name` in the coordinator's
/// container snapshot; unknown name → HTTP 404 with a clear
@ -332,9 +311,9 @@ pub(crate) fn validate_agent_name(name: &str) -> Option<&'static str> {
/// handler taking a name path-param. Read-only GET handlers and
/// handlers that legitimately operate on tombstoned agents (e.g.
/// `mark-all-read` on broker rows for a destroyed agent) call
/// `validate_agent_name` directly and skip the existence check.
/// [`idents::AgentName::parse`] directly and skip the existence check.
async fn guard_agent_name(state: &AppState, name: &str) -> Option<Response> {
if let Some(reason) = validate_agent_name(name) {
if let Err(reason) = idents::AgentName::parse(name) {
return Some(
(StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(),
);
@ -396,45 +375,4 @@ mod tests {
let fv = serde_json::to_value(&five).expect("problem details serialise");
assert_eq!(fv["status"], 500);
}
#[test]
fn validate_agent_name_accepts_canonical_shapes() {
assert!(validate_agent_name("damocles").is_none());
assert!(validate_agent_name("hm1nd").is_none());
assert!(validate_agent_name("agent-with-dashes").is_none());
assert!(validate_agent_name("snake_case").is_none());
assert!(validate_agent_name("mixed_2-3").is_none());
let max = "a".repeat(63);
assert!(
validate_agent_name(&max).is_none(),
"63-char name should pass"
);
}
// The two-axis guard (`guard_agent_name`) wires `validate_agent_name`
// + an async coordinator lookup. The lookup needs a populated
// `Coordinator`, which needs sqlite + tokio runtime; rather than
// build that scaffolding for an integration-flavoured test we cover
// the format axis here (the existence axis is enforced by the
// shared `containers_snapshot` API, tested in `coordinator.rs`'s
// own suite). 9 cases below cover the boundary-length case and
// other expected rejects to make the contract explicit.
#[test]
fn validate_agent_name_rejects_bad_input() {
assert!(validate_agent_name("").is_some());
let too_long = "a".repeat(64);
assert!(validate_agent_name(&too_long).is_some());
// Path-traversal attempts.
assert!(validate_agent_name("../etc/passwd").is_some());
assert!(validate_agent_name("alice/bob").is_some());
// Uppercase rejected — canonical lowercase convention.
assert!(validate_agent_name("Alice").is_some());
// No spaces, dots, special chars.
assert!(validate_agent_name("alice bob").is_some());
assert!(validate_agent_name("alice.bob").is_some());
assert!(validate_agent_name("alice;DROP TABLE messages").is_some());
// Non-ASCII (incl. unicode homoglyphs of ASCII dash).
assert!(validate_agent_name("damóclès").is_some());
assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash
}
}

View file

@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use problem_details::ProblemDetails;
use super::{AppState, guard_agent_name, strip_container_prefix, validate_agent_name};
use super::{AppState, guard_agent_name, idents::AgentName, strip_container_prefix};
#[derive(Serialize)]
pub(super) struct ToolGroupsSnapshot {
@ -371,24 +371,26 @@ pub(super) async fn get_stale_permissions(
///
/// Bypasses `guard_agent_name`'s live-roster check intentionally —
/// the whole point is to remove entries for non-roster agents. Only
/// the format check (`validate_agent_name`) is applied. No rebuild is
/// the format check ([`AgentName::parse`]) is applied. No rebuild is
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots
/// update the P3RM1SS10NS tab live.
pub(super) async fn delete_agent_permissions(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
let logical = strip_container_prefix(&name);
if let Some(reason) = validate_agent_name(&logical) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
let logical = match AgentName::parse(&strip_container_prefix(&name)) {
Ok(n) => n,
Err(reason) => {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
};
// Run both removals regardless so we clean up as much as possible
// even on partial I/O errors. Collect errors to surface below.
let tg_err = crate::tool_groups::remove_agent(&logical).err();
let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err();
if let Some(ref e) = tg_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
}
let cap_err = crate::capabilities::remove_agent(&logical).err();
let cap_err = crate::capabilities::remove_agent(logical.as_str()).err();
if let Some(ref e) = cap_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
}

View file

@ -17,7 +17,7 @@ use crate::container_view::{ContainerView, claude_has_session};
use crate::coordinator::Coordinator;
use crate::lifecycle;
use super::{AppState, error_response, validate_agent_name};
use super::{AppState, error_response, idents::AgentName};
#[derive(Serialize, Clone, Debug)]
pub struct TombstoneView {
@ -116,16 +116,19 @@ pub(super) async fn post_purge_tombstone(
// `containers_snapshot()` is deliberately NOT used here:
// tombstoned agents are gone from the snapshot by design; that's
// the whole point of this endpoint.
if let Some(reason) = validate_agent_name(&name) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
let name = match AgentName::parse(&name) {
Ok(n) => n,
Err(reason) => {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
};
// Sanity: refuse to purge if a live container still exists with this
// name. The dashboard already filters tombstones to non-live names,
// but the operator could send a stale POST.
let live = lifecycle::list().await.unwrap_or_default();
if live
.iter()
.any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name)
.any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c.as_str() == name.as_str())
{
return error_response(&format!(
"refusing to purge {name}: container still exists — use DESTR0Y first"
@ -133,8 +136,8 @@ pub(super) async fn post_purge_tombstone(
}
let mut errors = Vec::new();
for dir in [
crate::paths::agent_state_dir(&name),
crate::paths::applied_dir(&name),
crate::paths::agent_state_dir(name.as_str()),
crate::paths::applied_dir(name.as_str()),
] {
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
@ -145,7 +148,7 @@ pub(super) async fn post_purge_tombstone(
let _ = state
.coord
.approvals
.fail_pending_for_agent(&name, "agent state purged");
.fail_pending_for_agent(name.as_str(), "agent state purged");
if errors.is_empty() {
tracing::info!(%name, "tombstone purged");
// Fire the post-purge tombstones snapshot so dashboards

View file

@ -425,7 +425,7 @@ async fn handle_get_agent_meta(
// the OS level. Validate it before any path is built. The `None` default
// (`target == agent`) is the caller's own authenticated name, already
// valid — but validating unconditionally is simplest and harmless.
if let Some(reason) = crate::dashboard::validate_agent_name(target) {
if let Err(reason) = crate::dashboard::AgentName::parse(target) {
return hive_agent_sock::Response::Err {
message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"),
};
@ -445,7 +445,7 @@ async fn handle_get_agent_meta(
// `@user:server` / `homeserver`) — the access token lives separately
// in the agent's `matrix-token` and is never part of this response.
// Peer visibility is intentional: it lets an agent verify/contact
// another on a public matrix instance. The `validate_agent_name` gate
// another on a public matrix instance. The `AgentName::parse` gate
// above is what closes the real vector here (path traversal via `../`
// in an agent-supplied name).
matrix_accounts: read_agent_matrix_identities(target),
@ -748,7 +748,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
/// `submit_init_config`, which builds filesystem paths from it, so validate
/// before that.
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
if let Some(reason) = crate::dashboard::validate_agent_name(target) {
if let Err(reason) = crate::dashboard::AgentName::parse(target) {
return Some(Response::Err {
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
});