vetto 0.2.19

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Markdown report.

use super::{clean, stats::SessionStats};

pub fn render(stats: &SessionStats) -> String {
    let mut out = String::with_capacity(2048);
    out.push_str("# vetto session report\n\n");
    out.push_str(&format!(
        "- tier: `{}` · net: `{}` · profile: `{}`\n",
        markdown_inline(&stats.tier),
        markdown_inline(&stats.net_mode),
        markdown_inline(&stats.profile)
    ));
    out.push_str(&format!(
        "- exit code: `{}` · duration: `{}s`\n\n",
        stats.exit_code, stats.duration_secs
    ));

    out.push_str("## Event counts\n\n");
    out.push_str("| event | count |\n|---|---|\n");
    for (kind, count) in &stats.counts {
        out.push_str(&format!("| {} | {count} |\n", markdown_cell(kind)));
    }
    out.push_str(&format!(
        "\nObserved file reads: {} · writes: {} (best-effort /proc polling).\n\n",
        stats.file_reads, stats.file_writes
    ));

    out.push_str("## Blocked attempts\n\n");
    if stats.blocked_attempts.is_empty() {
        out.push_str(
            "None observed. Observation channels are optional (see notices); \
             enforcement is active regardless.\n\n",
        );
    } else {
        out.push_str("| path | process | source | count |\n|---|---|---|---|\n");
        for b in &stats.blocked_attempts {
            out.push_str(&format!(
                "| {} | {} | {} | {} |\n",
                markdown_cell(&b.path),
                markdown_cell(&b.comm),
                markdown_cell(&b.source),
                b.count
            ));
        }
        out.push('\n');
    }

    out.push_str("## Network requests\n\n");
    if stats.net_requests.is_empty() {
        out.push_str("None (network is off by default).\n\n");
    } else {
        out.push_str("| host | port | decision |\n|---|---|---|\n");
        for r in &stats.net_requests {
            let decision = if r.allowed { "allow" } else { "DENIED" };
            out.push_str(&format!(
                "| {} | {} | {} |\n",
                markdown_cell(&r.host),
                r.port,
                decision
            ));
        }
        out.push('\n');
    }

    out.push_str("## Suspicious signals (best-effort)\n\n");
    if stats.suspicious_signals.is_empty() {
        out.push_str("None observed. This classifier is advisory and incomplete.\n\n");
    } else {
        out.push_str("| severity | category | subject | reason | count |\n|---|---|---|---|---|\n");
        for signal in &stats.suspicious_signals {
            out.push_str(&format!(
                "| {} | {} | {} | {} | {} |\n",
                markdown_cell(&signal.severity),
                markdown_cell(&signal.category),
                markdown_cell(&signal.subject),
                markdown_cell(&signal.reason),
                signal.count
            ));
        }
        out.push('\n');
    }

    out.push_str("## Notices\n\n");
    if stats.notices.is_empty() {
        out.push_str("- none\n");
    } else {
        for n in &stats.notices {
            out.push_str(&format!("- {}\n", markdown_cell(n)));
        }
    }

    out.push_str(
        "\n---\nGenerated by vetto. Observations are BEST-EFFORT and never carry \
enforcement authority. Secret sanitizer: BEST-EFFORT.\n",
    );
    out
}

/// Keep attacker-controlled strings in one Markdown cell. Sanitization is
/// best-effort redaction; escaping only protects report structure and does
/// not make the source string trusted.
fn markdown_cell(value: &str) -> String {
    clean(value)
        .replace('\\', "\\\\")
        .replace('|', "\\|")
        .replace('\r', "\\r")
        .replace('\n', "\\n")
}

fn markdown_inline(value: &str) -> String {
    markdown_cell(value).replace(char::from(96), "'")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn user_strings_are_redacted_and_table_structure_is_escaped() {
        let secret = "Bearer abcdefghijklmnop";
        let stats = SessionStats {
            tier: format!("tier-{secret}"),
            net_mode: "off".into(),
            profile: "profile".into(),
            notices: vec!["row | injected\nnext".into()],
            ..SessionStats::default()
        };
        let report = render(&stats);
        assert!(
            !report.contains("abcdefghijklmnop"),
            "secret leaked: {report}"
        );
        assert!(report.contains("row \\| injected\\nnext"));
    }
}