set_status: reject multi-line and over-200-char text (#720)

This commit is contained in:
damocles 2026-05-31 11:50:49 +02:00 committed by Mara
commit 6c2cd078f1
4 changed files with 121 additions and 1 deletions

View file

@ -230,6 +230,12 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}
}
AgentRequest::SetStatus { text } => {
// #720: cap length + reject multi-line so a confused caller
// can't dump a multi-paragraph session report into the
// dashboard chip.
if let Err(message) = crate::limits::check_status_text(text) {
return AgentResponse::Err { message };
}
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
.join("hyperhive-status");
let result = if text.trim().is_empty() {

View file

@ -41,6 +41,56 @@ pub fn check_size(label: &str, body: &str) -> Result<(), String> {
}
}
/// Per-status soft cap. `set_status` renders as a short chip on the
/// dashboard agent card — the front-end truncates long strings to
/// keep the row layout intact, so a multi-paragraph "session report"
/// is wasted bytes that just bloat the rescan emit + container view
/// payload. Cap at 200 chars to fit the chip plus a little
/// descriptive padding without forcing the operator to read a
/// scrolling chunk.
pub const STATUS_MAX_CHARS: usize = 200;
/// Validate a `set_status` payload (#720). Single-line + bounded so
/// callers can't dump multi-paragraph session reports into the
/// dashboard chip. Whitespace trim is done by the caller before the
/// store-to-disk step — we run validation on the trimmed form so
/// surrounding whitespace doesn't push a borderline-legal status
/// past the cap.
///
/// Empty / all-whitespace input is accepted: the call site treats
/// that as "clear the status" and removes the on-disk sentinel. Tests
/// + caller cover both directions.
///
/// Returns a caller-ready error string suitable for surfacing in the
/// `*Response::Err` shape.
pub fn check_status_text(text: &str) -> Result<(), String> {
let trimmed = text.trim();
if trimmed.is_empty() {
// Empty = clear-status sentinel; nothing to validate.
return Ok(());
}
// Newline / carriage-return: status is a single-line chip on the
// dashboard. A multi-line argus session report is the canonical
// failure mode from #720.
if trimmed.contains('\n') || trimmed.contains('\r') {
return Err(
"set_status text must be a single line — write multi-line context to \
a file under your `/agents/<you>/state/` dir and reference that path \
from the chip instead"
.to_owned(),
);
}
let len = trimmed.chars().count();
if len > STATUS_MAX_CHARS {
return Err(format!(
"set_status text too long ({len} chars, max {STATUS_MAX_CHARS}); the \
dashboard chip truncates anything longer, so trim to a short summary \
and write the detail to `/agents/<you>/state/<file>` instead"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@ -63,4 +113,62 @@ mod tests {
let err = check_size("question", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
assert!(err.starts_with("question body too long"));
}
#[test]
fn check_status_accepts_short_single_line() {
assert!(check_status_text("idle").is_ok());
assert!(check_status_text("processing matrix messages").is_ok());
// Boundary: exactly STATUS_MAX_CHARS chars trimmed is still
// accepted; one more rejects.
let max = "a".repeat(STATUS_MAX_CHARS);
assert!(check_status_text(&max).is_ok());
}
#[test]
fn check_status_accepts_empty_and_whitespace() {
// Empty + whitespace-only are the "clear status" sentinel and
// bypass the rest of the checks.
assert!(check_status_text("").is_ok());
assert!(check_status_text(" ").is_ok());
assert!(check_status_text("\n\t ").is_ok());
}
#[test]
fn check_status_rejects_multi_line() {
let err = check_status_text("line one\nline two").unwrap_err();
assert!(err.contains("single line"), "err = {err}");
// Carriage return alone also rejects (windows linebreak / CR-only).
assert!(check_status_text("a\rb").is_err());
}
#[test]
fn check_status_rejects_oversize() {
let too_long = "a".repeat(STATUS_MAX_CHARS + 1);
let err = check_status_text(&too_long).unwrap_err();
assert!(err.contains("too long"), "err = {err}");
assert!(err.contains(&format!("max {STATUS_MAX_CHARS}")));
}
#[test]
fn check_status_counts_chars_not_bytes() {
// Multi-byte chars (emoji, accented letters) count once each
// per char — chars().count() not byte len. STATUS_MAX_CHARS
// worth of 4-byte chars is still legal.
let emoji = "💜".repeat(STATUS_MAX_CHARS);
assert!(
check_status_text(&emoji).is_ok(),
"{STATUS_MAX_CHARS} emoji should fit"
);
let too_many = "💜".repeat(STATUS_MAX_CHARS + 1);
assert!(check_status_text(&too_many).is_err());
}
#[test]
fn check_status_validates_post_trim() {
// Leading/trailing whitespace is trimmed before the length
// check — a borderline-legal payload with spaces around it
// still passes.
let padded = format!(" {} ", "a".repeat(STATUS_MAX_CHARS));
assert!(check_status_text(&padded).is_ok());
}
}

View file

@ -502,6 +502,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
}
}
ManagerRequest::SetStatus { text } => {
// #720: cap length + reject multi-line so a confused caller
// can't dump a multi-paragraph session report into the
// dashboard chip.
if let Err(message) = crate::limits::check_status_text(text) {
return ManagerResponse::Err { message };
}
let path = Coordinator::agent_notes_dir(MANAGER_AGENT).join("hyperhive-status");
let result = if text.trim().is_empty() {
std::fs::remove_file(&path).or_else(|e| {