vetto 0.2.9

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Self-contained HTML report: inline CSS only, zero external requests.

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

pub fn render(stats: &SessionStats) -> String {
    let mut rows = String::new();
    for (kind, count) in &stats.counts {
        rows.push_str(&format!(
            "<tr><td>{}</td><td class=\"num\">{count}</td></tr>\n",
            html_escape(&clean(kind))
        ));
    }

    let mut blocked = String::new();
    if stats.blocked_attempts.is_empty() {
        blocked.push_str("<p class=\"muted\">No blocked attempts were observed. \
            Remember: observation channels are optional (see notices) — enforcement is active regardless.</p>\n");
    } else {
        blocked.push_str(
            "<table><tr><th>path</th><th>process</th><th>source</th><th>count</th></tr>\n",
        );
        for b in &stats.blocked_attempts {
            blocked.push_str(&format!(
                "<tr><td class=\"path\">{}</td><td>{}</td><td>{}</td><td class=\"num\">{}</td></tr>\n",
                html_escape(&clean(&b.path)),
                html_escape(&clean(&b.comm)),
                html_escape(&clean(&b.source)),
                b.count
            ));
        }
        blocked.push_str("</table>\n");
    }

    let mut net = String::new();
    if !stats.net_requests.is_empty() {
        net.push_str("<table><tr><th>host</th><th>port</th><th>decision</th></tr>\n");
        for r in &stats.net_requests {
            net.push_str(&format!(
                "<tr><td>{}</td><td class=\"num\">{}</td><td class=\"{}\">{}</td></tr>\n",
                html_escape(&clean(&r.host)),
                r.port,
                if r.allowed { "ok" } else { "deny" },
                if r.allowed { "allow" } else { "DENIED" }
            ));
        }
        net.push_str("</table>\n");
    } else {
        net.push_str("<p class=\"muted\">No network requests (network is off by default).</p>\n");
    }

    let mut notices = String::new();
    for n in &stats.notices {
        notices.push_str(&format!("<li>{}</li>\n", html_escape(&clean(n))));
    }
    if notices.is_empty() {
        notices.push_str("<li class=\"muted\">none</li>\n");
    }

    let mut suspicious = String::new();
    if stats.suspicious_signals.is_empty() {
        suspicious.push_str(
            "<p class=\"muted\">None observed. This classifier is advisory and incomplete.</p>\n",
        );
    } else {
        suspicious.push_str(
            "<table><tr><th>severity</th><th>category</th><th>subject</th><th>reason</th><th>count</th></tr>\n",
        );
        for signal in &stats.suspicious_signals {
            suspicious.push_str(&format!(
                "<tr><td>{}</td><td>{}</td><td class=\"path\">{}</td><td>{}</td><td class=\"num\">{}</td></tr>\n",
                html_escape(&clean(&signal.severity)),
                html_escape(&clean(&signal.category)),
                html_escape(&clean(&signal.subject)),
                html_escape(&clean(&signal.reason)),
                signal.count
            ));
        }
        suspicious.push_str("</table>\n");
    }

    let histogram_svg = super::svg::render_category_histogram_svg(stats);

    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>vetto session report</title>
<style>
  body {{ font-family: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
         background: #101418; color: #cfd8dc; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }}
  h1 {{ color: #4dd0e1; font-size: 1.3rem; }}
  h2 {{ color: #80cbc4; font-size: 1.05rem; margin-top: 2rem; }}
  table {{ border-collapse: collapse; width: 100%; margin: 0.5rem 0; }}
  th, td {{ text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #263238; }}
  th {{ color: #b0bec5; }}
  .num {{ text-align: right; }}
  .path {{ font-family: inherit; word-break: break-all; }}
  .ok {{ color: #a5d6a7; }}
  .deny {{ color: #ef9a9a; font-weight: bold; }}
  .muted {{ color: #78909c; }}
  .meta span {{ margin-right: 1.2rem; }}
  footer {{ margin-top: 2.5rem; border-top: 1px solid #263238; padding-top: 0.8rem;
           color: #78909c; font-size: 0.85rem; }}
</style>
</head>
<body>
<h1>vetto session report</h1>
<p class="meta">
  <span>tier: <b>{tier}</b></span>
  <span>net: <b>{net}</b></span>
  <span>profile: <b>{profile}</b></span>
  <span>exit: <b>{exit}</b></span>
  <span>duration: <b>{dur}s</b></span>
</p>

<h2>Event category distribution</h2>
{histogram}

<h2>Event counts</h2>
<table>
<tr><th>event</th><th>count</th></tr>
{rows}
</table>
<p class="muted">file reads observed: {reads} · file writes observed: {writes} ·
observation is best-effort (/proc polling, ~100 ms granularity)</p>

<h2>Blocked attempts</h2>
{blocked}

<h2>Network requests</h2>
{net_tbl}

<h2>Suspicious signals (best-effort)</h2>
{suspicious}

<h2>Notices</h2>
<ul>
{notices}
</ul>

<footer>
Generated by vetto v{version}. Observations are BEST-EFFORT and never carry
enforcement authority; blocking is enforced by Landlock / namespaces / seccomp.
Secret sanitizer: BEST-EFFORT (false positives and misses are possible).
</footer>
</body>
</html>
"#,
        tier = html_escape(&clean(&stats.tier)),
        net = html_escape(&clean(&stats.net_mode)),
        profile = html_escape(&clean(&stats.profile)),
        exit = stats.exit_code,
        dur = stats.duration_secs,
        histogram = histogram_svg,
        rows = rows,
        reads = stats.file_reads,
        writes = stats.file_writes,
        blocked = blocked,
        net_tbl = net,
        suspicious = suspicious,
        notices = notices,
        version = env!("CARGO_PKG_VERSION"),
    )
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

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

    #[test]
    fn user_strings_are_redacted_and_html_escaped() {
        let secret = "ghp_0123456789abcdefghijklmnopqrstuvwxyz";
        let stats = SessionStats {
            tier: format!("tier-{secret}"),
            net_mode: "off<script>".into(),
            profile: "profile".into(),
            notices: vec![format!("message={secret}")],
            ..SessionStats::default()
        };
        let report = render(&stats);
        assert!(!report.contains(secret), "secret leaked: {report}");
        assert!(report.contains("off&lt;script&gt;"), "HTML was not escaped");
    }
}