car-proto 0.54.0

JSON-RPC protocol types for Common Agent Runtime client-server communication
Documentation
//! Compact plain-text rendering of an approval's `details` — the one renderer
//! every Rust approval surface shares.
//!
//! # Why this exists
//!
//! The macOS dashboard decodes [`HostApprovalRequest::details`] into a typed
//! `ApprovalPreview` and renders labelled fields — To/Cc/Bcc/Subject/Body for a
//! `mail.send`, the source for an AppleScript, and so on. No other surface did.
//! The remote channels interpolated `approval.action` and nothing else, so a
//! person approving from their phone saw a bare method name and two buttons
//! while the same approval on the desk showed the whole message about to be
//! sent; the Windows tray looked for a `details.summary` or `details.reason`
//! key that a gate-raised approval never carries, and fell back to dumping the
//! whole `details` object as one unwrapped line.
//!
//! That is backwards. The overseer who is *away from the machine* is the one
//! least able to reconstruct context, and the surfaces built for them carried
//! the least of it. An approval surface that cannot say what it is approving
//! collects a decision without informing it.
//!
//! It lives in `car-proto`, beside the type it renders, so the daemon's
//! messaging adapters and the (out-of-workspace, near-leaf-only) Windows tray
//! can share ONE implementation rather than drifting apart — which is how the
//! three surfaces came to disagree in the first place.
//!
//! # Deliberately bounded
//!
//! The content here leaves the machine for a third-party transport, so this
//! renders a *summary*, never the payload: every field is capped at
//! [`MAX_FIELD_CHARS`] and the whole block at [`MAX_SUMMARY_CHARS`]. The
//! recipient is not an audience — each channel is disabled by default and
//! carries a single host-paired, allowlisted approver handle
//! (`messaging_config`), i.e. the same person the dashboard would show the full
//! preview to. A bounded summary to that one handle is what the existing trust
//! boundary already implies; sending the full body would widen it.
//!
//! Enough to judge, not enough to be the payload: recipients and subject in
//! full (the fields that decide whether an action is safe), bodies and script
//! sources as an opening excerpt.
//!
//! # Shape
//!
//! Mirrors the dashboard's dispatch (`HostEventsClient.swift`, `ApprovalPreview`)
//! so both surfaces name the same fields from the same payload: `details.method`
//! (falling back to stripping the `ws.method:` prefix off `action`) selects the
//! layout, and `details.params_preview` — already capped daemon-side at 2000
//! chars — carries the values.

use crate::HostApprovalRequest;
use serde_json::Value;

/// Per-field character cap. A subject or recipient list survives intact; a
/// message body or script arrives as an opening excerpt.
pub const MAX_FIELD_CHARS: usize = 240;

/// Whole-summary character cap, applied after assembly so no combination of
/// fields can produce an unbounded message.
pub const MAX_SUMMARY_CHARS: usize = 700;

/// Most `key: value` lines rendered for a method with no dedicated layout.
const MAX_RAW_FIELDS: usize = 6;

/// A compact, bounded plain-text summary of what `approval` would do, or
/// `None` when `details` carries nothing worth showing (in which case the
/// caller's bare `action` line is already the whole story).
///
/// The result is multi-line, one `Label: value` per line, with no trailing
/// newline.
pub fn approval_summary(approval: &HostApprovalRequest) -> Option<String> {
    summarize_details(&approval.action, &approval.details)
}

/// [`approval_summary`] over the two fields it actually reads, for a caller
/// holding the approval as raw JSON off the wire rather than as a typed row.
///
/// The Windows tray parses `host.approvals` leniently — a row with an
/// unexpected shape still renders, with the missing fields empty — so making it
/// deserialize a whole [`HostApprovalRequest`] just to render a summary would
/// trade a readable prompt for a dropped one.
pub fn summarize_details(action: &str, details: &Value) -> Option<String> {
    let details = details.as_object()?;

    // The gate always sets `method`; agent-pushed approvals via
    // `host.create_approval` may not, so fall back to the action prefix the
    // gate uses (`ws.method:<method>`).
    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);
    // Without `params_preview` the top-level `details` object is the payload
    // (the agent-pushed shape), so fall back to it rather than rendering
    // nothing.
    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()
                // `method` is already on the prompt's action line.
                .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))
}

/// Pull `(label, rendered)` for each key present and non-empty, in the order
/// given — so a missing Cc simply does not appear rather than rendering blank.
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()
}

/// Collapse one agent-authored string to a single bounded line: whitespace
/// (newlines included) collapses to single spaces, and the result is capped at
/// [`MAX_FIELD_CHARS`].
///
/// Public because `action` needs exactly this treatment too. It is agent-authored,
/// unvalidated and unbounded (`CreateHostApprovalRequest::action` is a bare
/// `String`), and a caller that interpolates it into a prompt beside the labelled
/// fields this module renders would otherwise let a multi-line `action` forge
/// lines that look like the renderer's own output — or pad the real content off
/// a phone screen. Sanitising `details` and not `action` protects the smaller
/// half of the same surface.
pub fn sanitize_line(value: &str) -> String {
    truncate(
        &value.split_whitespace().collect::<Vec<_>>().join(" "),
        MAX_FIELD_CHARS,
    )
}

/// Render one JSON value as a single bounded line, or `None` when it carries
/// nothing (absent, null, empty string, empty array).
///
/// Newlines collapse to spaces: these channels render a multi-line value as
/// separate-looking content, which would let a message body forge what looks
/// like another labelled field.
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)
}

/// Truncate to `max` CHARACTERS (not bytes — `details` carries user text, and
/// slicing a multi-byte character panics), appending an ellipsis when cut.
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}");
        // An absent field renders nothing rather than an empty label.
        assert!(!s.contains("Cc:"), "{s}");
    }

    /// The method is recoverable from the action prefix alone — agent-pushed
    /// approvals do not always set `details.method`.
    #[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}");
        // `method` duplicates the action line the prompt already carries.
        assert!(!s.contains("method:"), "{s}");
    }

    /// Agent-pushed approvals put the payload at the top level, with no
    /// `params_preview` envelope.
    #[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());
        // Present but carrying nothing renderable.
        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}");
    }

    /// Truncation counts characters. Byte-slicing a multi-byte body panics,
    /// and `details` is user text.
    #[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}");
    }

    /// `action` is agent-authored and unbounded, so a caller placing it beside
    /// the rendered fields needs the same collapse-and-cap.
    #[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}'));
    }

    /// A body containing newlines must not be able to render lines that look
    /// like labelled fields the agent did not actually set.
    #[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}");
    }
}