use crate::HostApprovalRequest;
use serde_json::Value;
pub const MAX_FIELD_CHARS: usize = 240;
pub const MAX_SUMMARY_CHARS: usize = 700;
const MAX_RAW_FIELDS: usize = 6;
pub fn approval_summary(approval: &HostApprovalRequest) -> Option<String> {
summarize_details(&approval.action, &approval.details)
}
pub fn summarize_details(action: &str, details: &Value) -> Option<String> {
let details = details.as_object()?;
let method = details
.get("method")
.and_then(Value::as_str)
.unwrap_or_else(|| action.strip_prefix("ws.method:").unwrap_or(action));
let params = details.get("params_preview").and_then(Value::as_object);
let fields: Vec<(&str, String)> = match method {
"mail.send" => {
let p = params?;
labelled(
p,
&[
("To", "to"),
("Cc", "cc"),
("Bcc", "bcc"),
("Subject", "subject"),
("Body", "body"),
],
)
}
"messages.send" => {
let p = params?;
labelled(p, &[("To", "to"), ("Message", "body")])
}
"automation.run_applescript" => {
let p = params?;
labelled(p, &[("AppleScript", "script")])
}
"automation.shortcuts.run" => {
let p = params?;
labelled(p, &[("Shortcut", "name"), ("Input", "input")])
}
"vision.ocr" => {
let p = params?;
let mut f = labelled(p, &[("Image", "path")]);
if f.is_empty() {
f = labelled(p, &[("Image", "url")]);
}
f
}
_ => {
let p = params.unwrap_or(details);
p.iter()
.filter(|(k, _)| k.as_str() != "method")
.filter_map(|(k, v)| render(v).map(|s| (k.as_str(), s)))
.take(MAX_RAW_FIELDS)
.collect()
}
};
if fields.is_empty() {
return None;
}
let body = fields
.into_iter()
.map(|(label, value)| format!("{label}: {value}"))
.collect::<Vec<_>>()
.join("\n");
Some(truncate(&body, MAX_SUMMARY_CHARS))
}
fn labelled(
params: &serde_json::Map<String, Value>,
keys: &[(&'static str, &str)],
) -> Vec<(&'static str, String)> {
keys.iter()
.filter_map(|(label, key)| params.get(*key).and_then(render).map(|s| (*label, s)))
.collect()
}
pub fn sanitize_line(value: &str) -> String {
truncate(
&value.split_whitespace().collect::<Vec<_>>().join(" "),
MAX_FIELD_CHARS,
)
}
fn render(value: &Value) -> Option<String> {
let raw = match value {
Value::Null => return None,
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(items) => {
let parts: Vec<String> = items.iter().filter_map(render).collect();
if parts.is_empty() {
return None;
}
parts.join(", ")
}
Value::Object(_) => value.to_string(),
};
let collapsed = sanitize_line(&raw);
if collapsed.is_empty() {
return None;
}
Some(collapsed)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostApprovalStatus;
use chrono::Utc;
use serde_json::json;
fn approval(action: &str, details: Value) -> HostApprovalRequest {
HostApprovalRequest {
id: "a1".into(),
agent_id: None,
client_id: None,
action: action.into(),
details,
options: vec!["approve".into(), "deny".into()],
status: HostApprovalStatus::Pending,
created_at: Utc::now(),
resolved_at: None,
resolution: None,
}
}
#[test]
fn mail_send_names_recipient_and_subject() {
let a = approval(
"ws.method:mail.send",
json!({
"method": "mail.send",
"params_preview": {
"to": ["ceo@example.com"],
"subject": "Q3 numbers",
"body": "Attached are the figures we discussed.",
}
}),
);
let s = approval_summary(&a).expect("summary");
assert!(s.contains("To: ceo@example.com"), "{s}");
assert!(s.contains("Subject: Q3 numbers"), "{s}");
assert!(s.contains("Body: Attached are the figures"), "{s}");
assert!(!s.contains("Cc:"), "{s}");
}
#[test]
fn method_falls_back_to_the_action_prefix() {
let a = approval(
"ws.method:messages.send",
json!({ "params_preview": { "to": "+15551234567", "body": "on my way" } }),
);
let s = approval_summary(&a).expect("summary");
assert!(s.contains("To: +15551234567"), "{s}");
assert!(s.contains("Message: on my way"), "{s}");
}
#[test]
fn unknown_method_renders_its_parameters() {
let a = approval(
"ws.method:files.delete",
json!({
"method": "files.delete",
"params_preview": { "path": "/etc/hosts", "recursive": true }
}),
);
let s = approval_summary(&a).expect("summary");
assert!(s.contains("path: /etc/hosts"), "{s}");
assert!(s.contains("recursive: true"), "{s}");
assert!(!s.contains("method:"), "{s}");
}
#[test]
fn top_level_details_are_used_when_there_is_no_params_preview() {
let a = approval("deploy", json!({ "environment": "production" }));
let s = approval_summary(&a).expect("summary");
assert!(s.contains("environment: production"), "{s}");
}
#[test]
fn empty_or_absent_details_yield_no_summary() {
assert!(approval_summary(&approval("noop", json!({}))).is_none());
assert!(approval_summary(&approval("noop", Value::Null)).is_none());
assert!(approval_summary(&approval("noop", json!({ "to": "" }))).is_none());
}
#[test]
fn long_fields_are_capped_and_the_whole_block_is_bounded() {
let a = approval(
"ws.method:mail.send",
json!({
"method": "mail.send",
"params_preview": {
"to": ["a@example.com"],
"subject": "x".repeat(1000),
"body": "y".repeat(5000),
}
}),
);
let s = approval_summary(&a).expect("summary");
assert!(
s.chars().count() <= MAX_SUMMARY_CHARS + 1,
"summary must stay bounded, got {} chars",
s.chars().count()
);
assert!(s.contains('…'), "a cut value must say it was cut: {s}");
}
#[test]
fn multibyte_content_does_not_panic_and_stays_bounded() {
let a = approval(
"ws.method:messages.send",
json!({
"method": "messages.send",
"params_preview": { "to": "+15551234567", "body": "🎉".repeat(500) }
}),
);
let s = approval_summary(&a).expect("summary");
assert!(s.chars().count() <= MAX_SUMMARY_CHARS + 1, "{s}");
}
#[test]
fn sanitize_line_collapses_newlines_and_caps() {
assert_eq!(
sanitize_line("wire transfer\n\u{2022} B0 \u{2014} check the weather"),
"wire transfer \u{2022} B0 \u{2014} check the weather",
"a newline must not survive into a line-oriented prompt"
);
let long = sanitize_line(&"x".repeat(5000));
assert!(
long.chars().count() <= MAX_FIELD_CHARS + 1,
"{}",
long.len()
);
assert!(long.ends_with('\u{2026}'));
}
#[test]
fn newlines_in_a_value_cannot_forge_a_field() {
let a = approval(
"ws.method:mail.send",
json!({
"method": "mail.send",
"params_preview": {
"to": ["a@example.com"],
"body": "hello\nBcc: attacker@example.com",
}
}),
);
let s = approval_summary(&a).expect("summary");
let bcc_lines = s.lines().filter(|l| l.starts_with("Bcc:")).count();
assert_eq!(bcc_lines, 0, "a body must not forge a Bcc line: {s}");
}
}