Skip to main content

act_runtime/audit/
render.rs

1//! Human rendering of audit records. Pure: takes data, returns a `String`.
2//!
3//! Kept free of `tracing` and of I/O so every output shape is unit-testable.
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7
8use crate::audit::record::{CapDecisionRecord, CredentialIssueRecord, Decision4};
9
10const PREFIX: &str = "audit: ";
11
12/// The envelope-span fields the layer captures at span open and completes at
13/// span close.
14#[derive(Debug, Clone)]
15pub struct SpanFields {
16    pub component_ref: String,
17    pub digest: String,
18    pub tool: String,
19    pub args_sha256: String,
20    /// Full arguments, present only when `--audit-args` was set for this run.
21    /// `render_rollup` shows this instead of the digest when present — the
22    /// digest is still captured above, just not the thing printed.
23    pub args_json: Option<String>,
24    pub session_id: Option<String>,
25    pub transport: String,
26    /// Defaults to `"incomplete"`, never empty: a span that closes without
27    /// `finish_tool_call` ever recording an outcome (dropped early, never
28    /// entered) must not render as if the call had actually completed.
29    pub outcome: String,
30    pub duration_ms: u64,
31    /// `std:request-id`, or a host-generated id. Rendered truncated and
32    /// escaped — it is the only way an operator can join one audit line
33    /// back to a client log line.
34    pub request_id: String,
35}
36
37impl Default for SpanFields {
38    fn default() -> Self {
39        Self {
40            component_ref: String::new(),
41            digest: String::new(),
42            tool: String::new(),
43            args_sha256: String::new(),
44            args_json: None,
45            session_id: None,
46            transport: String::new(),
47            outcome: "incomplete".to_string(),
48            duration_ms: 0,
49            request_id: String::new(),
50        }
51    }
52}
53
54/// Accumulated allows for one tool call, grouped by `(cap_id, action, rule)`.
55#[derive(Debug, Clone)]
56pub struct Rollup {
57    counts: BTreeMap<(String, String, String), u64>,
58    cap: usize,
59    overflow: u64,
60}
61
62impl Rollup {
63    pub fn new(cap: usize) -> Self {
64        Self {
65            counts: BTreeMap::new(),
66            cap,
67            overflow: 0,
68        }
69    }
70
71    /// Fold one permitted operation in. Past `cap` distinct groups, further
72    /// *new* groups collapse into an overflow counter — existing groups keep
73    /// counting, so the common case stays exact.
74    pub fn add(&mut self, cap_id: &str, action: &str, rule: Option<&str>) {
75        let key = (
76            cap_id.to_string(),
77            action.to_string(),
78            rule.unwrap_or("").to_string(),
79        );
80        if let Some(n) = self.counts.get_mut(&key) {
81            *n += 1;
82            return;
83        }
84        if self.counts.len() >= self.cap {
85            self.overflow += 1;
86            return;
87        }
88        self.counts.insert(key, 1);
89    }
90
91    // Exercised by `rollup_collapses_past_the_cap` below, which asserts on
92    // both to pin the cap/overflow behaviour — not dead, just test-only.
93    #[allow(dead_code)]
94    pub fn groups(&self) -> usize {
95        self.counts.len()
96    }
97
98    #[allow(dead_code)]
99    pub fn overflow(&self) -> u64 {
100        self.overflow
101    }
102}
103
104/// Truncate to at most `n` bytes without splitting a UTF-8 character.
105fn take_bytes(s: &str, n: usize) -> &str {
106    let mut e = s.len().min(n);
107    while e > 0 && !s.is_char_boundary(e) {
108        e -= 1;
109    }
110    &s[..e]
111}
112
113/// Characters that must be escaped before a guest-controlled value reaches
114/// an audit line. `char::is_control()` only covers Unicode category Cc
115/// (U+0000-001F, U+007F-009F) — it misses the Cf format/bidi-control
116/// characters (U+200E/200F, U+202A-202E, U+2066-2069) and the line/paragraph
117/// separators (U+2028/U+2029). A right-to-left override (U+202E) in
118/// particular lets a component make a terminal *display* a different string
119/// than the one it actually supplied — e.g. in the rule string `render_rollup`
120/// substitutes from the component's own `act.toml` declaration under `ask`/
121/// `open` grants, which is entirely author-chosen and need not correspond to
122/// anything real.
123pub(crate) fn needs_escape(c: char) -> bool {
124    c.is_control()
125        || matches!(
126            c,
127            '\u{200e}'
128                | '\u{200f}'
129                | '\u{202a}'..='\u{202e}'
130                | '\u{2066}'..='\u{2069}'
131                | '\u{2028}'
132                | '\u{2029}'
133        )
134}
135
136/// Escape control and bidi-override characters to prevent audit-line
137/// forgery. Components can inject newlines, ANSI sequences, and Unicode
138/// directional overrides into guest-controlled fields. This sanitizes them
139/// uniformly at the rendering point: \n, \r, \t as their literal forms; other
140/// escaped chars as \u{...}. Returns the original string if no escaping needed.
141///
142/// Not only audit lines: `runtime::consent` and `runtime::elicit` run every
143/// consent prompt through this too. A prompt is the stronger case — an audit
144/// line is read after the fact, whereas a forged prompt line is answered by a
145/// human who believes they are approving something else. It lives here rather
146/// than in the consent module because the audit trail was the first caller
147/// and the escaping rules must not fork between the two surfaces.
148pub(crate) fn escape_audit_field(s: &str) -> Cow<'_, str> {
149    if !s.chars().any(needs_escape) {
150        return Cow::Borrowed(s);
151    }
152    let mut out = String::new();
153    for c in s.chars() {
154        if needs_escape(c) {
155            match c {
156                '\n' => out.push_str("\\n"),
157                '\r' => out.push_str("\\r"),
158                '\t' => out.push_str("\\t"),
159                _ => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
160            }
161        } else {
162            out.push(c);
163        }
164    }
165    Cow::Owned(out)
166}
167
168fn short_digest(digest: &str) -> String {
169    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
170    format!("sha256:{}", take_bytes(hex, 6))
171}
172
173fn humanise_ms(ms: u64) -> String {
174    if ms < 1000 {
175        format!("{ms}ms")
176    } else {
177        format!("{:.1}s", ms as f64 / 1000.0)
178    }
179}
180
181/// The instantiation header: what is running and under what modes.
182pub fn render_header(component_ref: &str, digest: &str, modes: &[(String, String)]) -> String {
183    let component_ref_escaped = escape_audit_field(component_ref);
184    let modes: Vec<String> = modes
185        .iter()
186        .map(|(id, mode)| {
187            let id_escaped = escape_audit_field(id);
188            let mode_escaped = escape_audit_field(mode);
189            format!("{id_escaped}={mode_escaped}")
190        })
191        .collect();
192    format!(
193        "{PREFIX}{} {} \u{2502} {}",
194        component_ref_escaped,
195        short_digest(digest),
196        modes.join(" ")
197    )
198}
199
200/// A second line, printed right after the header, naming capability classes
201/// the component declared in `act.toml` that resolved to `deny` anyway (no
202/// grant covered them, or an operator explicitly denied them). Restricted to
203/// `declared == true` classes by the caller — every class a component never
204/// asked for also resolves to deny, and warning on those would bury the one
205/// signal an operator actually needs to see.
206pub fn render_declared_ungranted_warning(ids: &[String]) -> String {
207    let escaped: Vec<String> = ids
208        .iter()
209        .map(|id| escape_audit_field(id).to_string())
210        .collect();
211    format!(
212        "{PREFIX}\u{26a0} declared but not granted: {}",
213        escaped.join(", ")
214    )
215}
216
217/// A sibling warning for a declared class configured as `ask` when this run
218/// has no interactive prompt channel at all (headless / ACT-HTTP). The
219/// header still shows the configured mode (`ask`) unchanged — that really is
220/// the policy — but every access to a class like this resolves through
221/// `DenyPrompter` before a human is ever asked, so the operator needs the
222/// outcome spelled out, not just the mode.
223pub fn render_declared_ask_blocked_warning(ids: &[String]) -> String {
224    let escaped: Vec<String> = ids
225        .iter()
226        .map(|id| escape_audit_field(id).to_string())
227        .collect();
228    format!(
229        "{PREFIX}\u{26a0} declared ask, no prompt channel — every access will be denied: {}",
230        escaped.join(", ")
231    )
232}
233
234/// One credential handed to a component. Printed the moment it resolves and
235/// never folded into a rollup: an operator scanning a run for "what got out"
236/// must find one line per issue, not a count.
237///
238/// `key` is guest-authored (design §5.5 — the descriptor is untrusted input),
239/// and so is the stored `kind`, so both go through `escape_audit_field`; a
240/// newline in either would otherwise forge a second audit line.
241pub fn render_credential_issue(r: &CredentialIssueRecord) -> String {
242    format!(
243        "{PREFIX}\u{1f511} credential  {}  kind={}  {}  session={}",
244        escape_audit_field(&r.key),
245        escape_audit_field(&r.kind),
246        escape_audit_field(&r.component_ref),
247        escape_audit_field(&r.session_id),
248    )
249}
250
251/// A denial or an ask — printed the moment it resolves, never batched. Also
252/// reused (from the layer) for an allow that has nowhere to fold, e.g. one
253/// fired at instantiation time, before any tool-call span exists.
254///
255/// M4: `--deny db:drop`, an allowlist miss, and a declaration miss (or an
256/// undeclared class) can all resolve to the identical `Decision::Deny` with
257/// the identical default `reason` ("outside ceiling") — `statik` overrides
258/// only ever carries that one generic string unless a call site opts into
259/// `statik_with_reason`. What actually distinguishes §4's steps is `r.mode`
260/// (which grant mode was in force) and `r.rule` (the specific constraint or
261/// declaration text a provider's `classify_explained` attributed) — both
262/// already captured on every record, but previously never rendered here.
263/// §8.4 requires that distinction to live in the audit trail; this is where
264/// an operator actually reads a denial, so it has to appear on this line.
265pub fn render_exception(r: &CapDecisionRecord) -> String {
266    let marker = match r.decision {
267        Decision4::Deny => "\u{2717}",
268        Decision4::Allow => "\u{2713}",
269        Decision4::AskAllow | Decision4::AskDeny => "?",
270    };
271    let action_escaped = escape_audit_field(&r.action);
272    let key_escaped = escape_audit_field(&r.key);
273    let subject = if r.action.is_empty() {
274        key_escaped.to_string()
275    } else {
276        format!("{action_escaped} {key_escaped}")
277    };
278    let cap_id_escaped = escape_audit_field(&r.cap_id);
279    let reason = r
280        .reason
281        .as_deref()
282        .map(|s| {
283            let escaped = escape_audit_field(s);
284            format!("   {escaped}")
285        })
286        .unwrap_or_default();
287    let mode_escaped = escape_audit_field(&r.mode);
288    // Same "under <rule>" phrasing `render_rollup` already uses for a
289    // matched allow rule, so an operator reading either line learns the
290    // convention once.
291    let rule_clause = r
292        .rule
293        .as_deref()
294        .map(|s| format!(" under {}", escape_audit_field(s)))
295        .unwrap_or_default();
296    format!(
297        "{PREFIX}{marker} {}  {}  {}{}  mode:{}{}",
298        r.decision, cap_id_escaped, subject, reason, mode_escaped, rule_clause
299    )
300}
301
302/// The per-call summary, flushed when the envelope span closes.
303pub fn render_rollup(span: &SpanFields, roll: &Rollup) -> String {
304    let tool_escaped = escape_audit_field(&span.tool);
305    // Truncate the caller-supplied request id before escaping, same order as
306    // the session id below: `take_bytes` yields whole characters, whereas
307    // escaping first could cut an escape sequence in half.
308    let req_escaped = escape_audit_field(take_bytes(&span.request_id, 6));
309    // `--audit-args` swaps this token from a digest prefix to the full,
310    // escaped argument values — the digest is still captured on the span
311    // (for OTLP / correlation), just not what this line shows once the full
312    // values are available to show instead.
313    let args_display: Cow<'_, str> = match &span.args_json {
314        Some(json) => escape_audit_field(json),
315        None => Cow::Borrowed(take_bytes(&span.args_sha256, 6)),
316    };
317    let mut line = format!(
318        "{PREFIX}\u{25cf} {}  {} {}  args:{}  req:{}",
319        tool_escaped,
320        span.outcome,
321        humanise_ms(span.duration_ms),
322        args_display,
323        req_escaped,
324    );
325    if let Some(sid) = &span.session_id {
326        let sid_trunc = take_bytes(sid, 8);
327        let sid_escaped = escape_audit_field(sid_trunc);
328        line.push_str(&format!("  session:{sid_escaped}"));
329    }
330
331    // Group by capability so one clause covers all actions on that class.
332    let mut by_cap: BTreeMap<&str, Vec<(&str, &str, u64)>> = BTreeMap::new();
333    for ((cap_id, action, rule), n) in &roll.counts {
334        by_cap
335            .entry(cap_id.as_str())
336            .or_default()
337            .push((action.as_str(), rule.as_str(), *n));
338    }
339    for (cap_id, entries) in by_cap {
340        let short = cap_id.strip_prefix("wasi:").unwrap_or(cap_id);
341        let short_escaped = escape_audit_field(short);
342        let ops: Vec<String> = entries
343            .iter()
344            .map(|(action, _, n)| {
345                let action_escaped = escape_audit_field(action);
346                if action.is_empty() {
347                    format!("{n}")
348                } else {
349                    format!("{n} {action_escaped}")
350                }
351            })
352            .collect();
353        let mut rules: Vec<&str> = entries
354            .iter()
355            .map(|(_, rule, _)| *rule)
356            .filter(|r| !r.is_empty())
357            .collect();
358        rules.sort_unstable();
359        rules.dedup();
360        let scope = if rules.is_empty() {
361            String::new()
362        } else {
363            let rules_escaped: Vec<String> = rules
364                .iter()
365                .map(|r| escape_audit_field(r).to_string())
366                .collect();
367            format!(" under {}", rules_escaped.join(", "))
368        };
369        line.push_str(&format!("  {short_escaped}: {}{scope}", ops.join(" ")));
370    }
371    if roll.overflow > 0 {
372        line.push_str(&format!("  and {} more", roll.overflow));
373    }
374    line
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::audit::record::*;
381
382    fn span_fields() -> SpanFields {
383        SpanFields {
384            component_ref: "python-eval@0.16.0".into(),
385            digest: "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c".into(),
386            tool: "run_python".into(),
387            args_sha256: "9e21c4aa00000000".into(),
388            args_json: None,
389            session_id: None,
390            transport: "cli".into(),
391            outcome: "ok".into(),
392            duration_ms: 1400,
393            request_id: "req-9f8e7d6c5b4a".into(),
394        }
395    }
396
397    /// Golden lines for every renderer in this module.
398    ///
399    /// The `contains`-style tests below each pin one *intention* — "the line
400    /// must name the reason", "the rule must be attributed" — and are the
401    /// right shape for that: they say why the field is there and they keep
402    /// saying it when the surrounding format moves. What none of them can
403    /// see is the line as a whole, which is what an operator reads and what
404    /// a log pipeline parses: field order, separators, spacing, which fields
405    /// appear at all. A renderer could grow a field, lose one, or reorder
406    /// them and every assertion below would still pass.
407    ///
408    /// So the whole line is pinned here instead, once per renderer and once
409    /// per variant that changes its shape. Review a diff in these the way
410    /// you would review a change to the trail's format — because that is
411    /// what it is.
412    mod golden {
413        use super::*;
414
415        fn cap_decision() -> CapDecisionRecord {
416            CapDecisionRecord {
417                cap_id: "wasi:http".into(),
418                key: "api.telemetry.example.com:443".into(),
419                action: "GET".into(),
420                decision: Decision4::Deny,
421                mode: "allowlist".into(),
422                actor: Actor::Static,
423                reason: Some("outside ceiling".into()),
424                rule: None,
425                never_rollup: false,
426            }
427        }
428
429        #[test]
430        fn header() {
431            insta::assert_snapshot!(render_header(
432                "python-eval@0.16.0",
433                "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
434                &[
435                    ("wasi:filesystem".to_string(), "allowlist".to_string()),
436                    ("wasi:http".to_string(), "ask".to_string()),
437                ],
438            ));
439        }
440
441        #[test]
442        fn declared_ungranted_warning() {
443            insta::assert_snapshot!(render_declared_ungranted_warning(&[
444                "wasi:http".to_string(),
445                "wasi:sockets".to_string(),
446            ]));
447        }
448
449        #[test]
450        fn declared_ask_blocked_warning() {
451            insta::assert_snapshot!(render_declared_ask_blocked_warning(&[
452                "wasi:filesystem".to_string()
453            ]));
454        }
455
456        #[test]
457        fn credential_issue() {
458            insta::assert_snapshot!(render_credential_issue(&CredentialIssueRecord {
459                component_ref: "notion@1.2.0".into(),
460                session_id: "sess-4f2a1b".into(),
461                key: "acme:token".into(),
462                kind: "std:oauth2".into(),
463            }));
464        }
465
466        /// A static deny — the shape an operator sees most often.
467        #[test]
468        fn exception_static_deny() {
469            insta::assert_snapshot!(render_exception(&cap_decision()));
470        }
471
472        /// The same decision reached by asking a human. `actor` and the
473        /// decision word both change; the rest of the line must not.
474        #[test]
475        fn exception_ask_denied_by_user() {
476            let mut r = cap_decision();
477            r.decision = Decision4::AskDeny;
478            r.mode = "ask".into();
479            r.actor = Actor::User;
480            r.reason = Some("denied by user".into());
481            insta::assert_snapshot!(render_exception(&r));
482        }
483
484        /// A deny that *did* have a rule to attribute: the `rule` field is
485        /// what distinguishes "your allowlist does not cover this" from
486        /// "nothing ever declared it".
487        #[test]
488        fn exception_with_an_attributed_rule() {
489            let mut r = cap_decision();
490            r.rule = Some("*.example.com".into());
491            r.reason = Some("not granted".into());
492            insta::assert_snapshot!(render_exception(&r));
493        }
494
495        #[test]
496        fn rollup_with_grouped_allows() {
497            let mut roll = Rollup::new(64);
498            for _ in 0..12 {
499                roll.add("wasi:filesystem", "read", Some("/data/**"));
500            }
501            for _ in 0..2 {
502                roll.add("wasi:filesystem", "write", Some("/data/**"));
503            }
504            roll.add("wasi:http", "GET", Some("pypi.org"));
505            insta::assert_snapshot!(render_rollup(&span_fields(), &roll));
506        }
507
508        /// A call that touched no capability at all still reports itself.
509        #[test]
510        fn rollup_with_no_allows() {
511            insta::assert_snapshot!(render_rollup(&span_fields(), &Rollup::new(64)));
512        }
513
514        /// `--audit-args`: the full argument value replaces the digest. The
515        /// one line where a credential could surface, so its exact shape is
516        /// worth pinning.
517        #[test]
518        fn rollup_with_full_args() {
519            let mut sf = span_fields();
520            sf.args_json = Some(r#"{"name":"pandas","version":"2.2.0"}"#.to_string());
521            insta::assert_snapshot!(render_rollup(&sf, &Rollup::new(64)));
522        }
523
524        /// A session id appears, truncated, and the group cap collapses the
525        /// tail into `and N more`.
526        #[test]
527        fn rollup_with_session_and_overflow() {
528            let mut sf = span_fields();
529            sf.session_id = Some("sess-0123456789abcdef".to_string());
530            let mut roll = Rollup::new(2);
531            roll.add("wasi:filesystem", "read", Some("/a/**"));
532            roll.add("wasi:filesystem", "read", Some("/b/**"));
533            roll.add("wasi:filesystem", "read", Some("/c/**"));
534            roll.add("wasi:http", "GET", Some("pypi.org"));
535            insta::assert_snapshot!(render_rollup(&sf, &roll));
536        }
537
538        /// Untrusted text — a guest-chosen tool name, a rule from a grant —
539        /// is escaped, so nothing a component controls can inject a second
540        /// audit line. The escaping is asserted by the tests below; what is
541        /// pinned here is what the escaped line actually looks like.
542        #[test]
543        fn rollup_escapes_untrusted_text() {
544            let mut sf = span_fields();
545            sf.tool = "run\npython".to_string();
546            let mut roll = Rollup::new(64);
547            roll.add("wasi:filesystem", "read", Some("/data\n audit: forged"));
548            insta::assert_snapshot!(render_rollup(&sf, &roll));
549        }
550    }
551
552    #[test]
553    fn exception_line_names_decision_capability_and_reason() {
554        let r = CapDecisionRecord {
555            cap_id: "wasi:http".into(),
556            key: "api.telemetry.example.com:443".into(),
557            action: "GET".into(),
558            decision: Decision4::Deny,
559            mode: "ask".into(),
560            actor: Actor::Static,
561            reason: Some("outside ceiling".into()),
562            rule: None,
563            never_rollup: false,
564        };
565        let line = render_exception(&r);
566        assert!(line.starts_with("audit: "), "got {line}");
567        assert!(line.contains("deny"));
568        assert!(line.contains("wasi:http"));
569        assert!(line.contains("GET api.telemetry.example.com:443"));
570        assert!(line.contains("outside ceiling"));
571    }
572
573    #[test]
574    fn exception_line_carries_mode_and_rule_so_deny_causes_are_distinguishable() {
575        // M4: `statik`'s default reason is the identical "outside ceiling"
576        // string for a deny-constraint match, an allowlist miss, and a
577        // declaration miss — the record still carries a more specific `rule`
578        // (or none, for an allowlist miss) plus the grant `mode`, but before
579        // this fix `render_exception` never printed either, so the three
580        // causes were textually indistinguishable on the one line an
581        // operator actually reads.
582        let base = CapDecisionRecord {
583            cap_id: "db:drop".into(),
584            key: "production".into(),
585            action: "request".into(),
586            decision: Decision4::Deny,
587            mode: "open".into(),
588            actor: Actor::Static,
589            reason: Some("outside ceiling".into()),
590            rule: None,
591            never_rollup: false,
592        };
593
594        // A `deny` constraint match: `rule` carries the constraint JSON.
595        let deny_constraint = CapDecisionRecord {
596            rule: Some(r#"{"key":"production"}"#.into()),
597            ..base.clone()
598        };
599        let line = render_exception(&deny_constraint);
600        assert!(line.contains("mode:open"), "got {line}");
601        assert!(
602            line.contains(r#"under {"key":"production"}"#),
603            "the matched deny constraint must appear, got {line}"
604        );
605
606        // A declaration miss: `rule` carries the fixed declaration text.
607        let declaration_miss = CapDecisionRecord {
608            mode: "ask".into(),
609            rule: Some("outside the declared ceiling".into()),
610            ..base.clone()
611        };
612        let line = render_exception(&declaration_miss);
613        assert!(line.contains("mode:ask"), "got {line}");
614        assert!(
615            line.contains("under outside the declared ceiling"),
616            "got {line}"
617        );
618
619        // An allowlist miss: no rule attributed at all, only the mode — and
620        // this must render visibly differently from the two cases above,
621        // not just happen to omit a clause nobody checks for.
622        let allowlist_miss = CapDecisionRecord {
623            mode: "allowlist".into(),
624            rule: None,
625            ..base
626        };
627        let line = render_exception(&allowlist_miss);
628        assert!(line.contains("mode:allowlist"), "got {line}");
629        assert!(
630            !line.contains("under "),
631            "no rule was attributed, so no `under` clause should appear, got {line}"
632        );
633        assert_ne!(
634            line,
635            render_exception(&deny_constraint),
636            "an allowlist miss must not render identically to a deny-constraint match"
637        );
638        assert_ne!(
639            line,
640            render_exception(&declaration_miss),
641            "an allowlist miss must not render identically to a declaration miss"
642        );
643    }
644
645    #[test]
646    fn ask_denied_by_user_is_attributed_to_the_user() {
647        let r = CapDecisionRecord {
648            cap_id: "wasi:filesystem".into(),
649            key: "/home/alex/.ssh/id_ed25519".into(),
650            action: "read".into(),
651            decision: Decision4::AskDeny,
652            mode: "ask".into(),
653            actor: Actor::User,
654            reason: Some("denied by user".into()),
655            rule: None,
656            never_rollup: false,
657        };
658        let line = render_exception(&r);
659        assert!(line.contains("ask-deny"));
660        assert!(line.contains("denied by user"));
661    }
662
663    #[test]
664    fn rollup_groups_allows_by_capability_action_and_rule() {
665        let mut roll = Rollup::new(64);
666        for _ in 0..12 {
667            roll.add("wasi:filesystem", "read", Some("/data/**"));
668        }
669        for _ in 0..2 {
670            roll.add("wasi:filesystem", "write", Some("/data/**"));
671        }
672        roll.add("wasi:http", "GET", Some("pypi.org"));
673
674        let line = render_rollup(&span_fields(), &roll);
675        assert!(line.contains("run_python"));
676        assert!(line.contains("ok"));
677        assert!(
678            line.contains("1.4s"),
679            "expected humanised duration, got {line}"
680        );
681        assert!(
682            line.contains("args:9e21c4"),
683            "expected short args digest, got {line}"
684        );
685        assert!(line.contains("12 read"));
686        assert!(line.contains("2 write"));
687        assert!(line.contains("/data/**"));
688        assert!(line.contains("pypi.org"));
689        assert!(
690            line.contains("req:req-9f"),
691            "expected truncated request id, got {line}"
692        );
693    }
694
695    #[test]
696    fn rollup_shows_full_args_instead_of_the_digest_when_present() {
697        let mut sf = span_fields();
698        sf.args_json = Some(r#"{"name":"zzmarkerzz"}"#.to_string());
699        let roll = Rollup::new(64);
700
701        let line = render_rollup(&sf, &roll);
702        assert!(
703            line.contains(r#"args:{"name":"zzmarkerzz"}"#),
704            "expected full args, got {line}"
705        );
706        assert!(
707            !line.contains("args:9e21c4"),
708            "digest prefix must not also appear, got {line}"
709        );
710    }
711
712    #[test]
713    fn rollup_with_no_allows_still_reports_the_call() {
714        let roll = Rollup::new(64);
715        let line = render_rollup(&span_fields(), &roll);
716        assert!(line.contains("run_python"));
717        assert!(!line.contains("under"), "no grants touched, got {line}");
718    }
719
720    #[test]
721    fn rollup_collapses_past_the_cap() {
722        // A pathological component must not grow rollup state without bound.
723        let mut roll = Rollup::new(2);
724        roll.add("wasi:filesystem", "read", Some("/a/**"));
725        roll.add("wasi:filesystem", "read", Some("/b/**"));
726        roll.add("wasi:filesystem", "read", Some("/c/**"));
727        roll.add("wasi:filesystem", "read", Some("/d/**"));
728        assert_eq!(roll.groups(), 2);
729        assert_eq!(roll.overflow(), 2);
730        let line = render_rollup(&span_fields(), &roll);
731        assert!(line.contains("and 2 more"), "got {line}");
732    }
733
734    #[test]
735    fn header_shows_short_digest_and_per_class_modes() {
736        let line = render_header(
737            "python-eval@0.16.0",
738            "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
739            &[
740                ("wasi:filesystem".to_string(), "allowlist".to_string()),
741                ("wasi:http".to_string(), "ask".to_string()),
742            ],
743        );
744        assert!(line.contains("python-eval@0.16.0"));
745        assert!(
746            line.contains("sha256:1f3a9c"),
747            "expected truncated digest, got {line}"
748        );
749        assert!(
750            !line.contains("9f0a1b2c"),
751            "full digest must not be printed"
752        );
753        assert!(line.contains("wasi:filesystem=allowlist"));
754        assert!(line.contains("wasi:http=ask"));
755    }
756
757    #[test]
758    fn rollup_truncates_multibyte_session_id_safely() {
759        // Japanese hiragana: "アアアアア" = 5 chars × 3 bytes = 15 bytes total.
760        // Byte 8 lands mid-character (inside the 3rd "ア"). This must not panic.
761        let mut sf = span_fields();
762        sf.session_id = Some("アアアアア".to_string());
763        let roll = Rollup::new(64);
764
765        let line = render_rollup(&sf, &roll);
766        // Should render safely and contain the session clause
767        assert!(
768            line.contains("session:"),
769            "session clause missing from {line}"
770        );
771        // Should truncate to a safe point (2 chars = 6 bytes for "アア")
772        assert!(
773            line.contains("session:アア"),
774            "expected 2 chars, got {line}"
775        );
776    }
777
778    #[test]
779    fn rollup_with_short_session_id_unchanged() {
780        // Session ID shorter than 8 bytes should not be truncated
781        let mut sf = span_fields();
782        sf.session_id = Some("short".to_string()); // 5 bytes
783        let roll = Rollup::new(64);
784
785        let line = render_rollup(&sf, &roll);
786        assert!(
787            line.contains("session:short"),
788            "full short ID should appear, got {line}"
789        );
790    }
791
792    #[test]
793    fn rollup_truncates_multibyte_at_boundary() {
794        // A session ID where the 8-byte mark happens to be exactly on a char
795        // boundary. Emoji 🎉 is 4 bytes, so "🎉🎉" = 8 bytes at a boundary.
796        let mut sf = span_fields();
797        sf.session_id = Some("🎉🎉🎉".to_string()); // 3 emoji × 4 bytes = 12 bytes
798        let roll = Rollup::new(64);
799
800        let line = render_rollup(&sf, &roll);
801        // At 8 bytes exactly (boundary), we get 2 complete emoji
802        assert!(
803            line.contains("session:🎉🎉"),
804            "expected 2 emoji at boundary, got {line}"
805        );
806        // 3rd emoji (would need 12 bytes) should not appear
807        assert!(
808            !line.contains("🎉🎉🎉"),
809            "should not contain 3 emoji, got {line}"
810        );
811    }
812
813    #[test]
814    fn render_escapes_newline_in_rule_to_prevent_forgery() {
815        // A component declares a filesystem path containing a newline followed
816        // by forged audit text. The escaping must prevent the forgery.
817        let mut roll = Rollup::new(64);
818        roll.add("wasi:filesystem", "read", Some("/data\naudit: forged line"));
819
820        let line = render_rollup(&span_fields(), &roll);
821        // Must be exactly one line (no actual newline character)
822        assert_eq!(line.matches('\n').count(), 0, "got {line}");
823        // Newline must appear escaped as literal \n
824        assert!(line.contains("\\n"), "expected escaped newline, got {line}");
825        // The rule should render with the escape, preventing a forged second line
826        assert!(
827            line.contains("\\naudit: forged line"),
828            "escaped injection should appear, got {line}"
829        );
830    }
831
832    #[test]
833    fn render_escapes_newline_in_tool_name() {
834        let mut sf = span_fields();
835        sf.tool = "run\naudit: forged".to_string();
836        let roll = Rollup::new(64);
837
838        let line = render_rollup(&sf, &roll);
839        assert_eq!(line.matches('\n').count(), 0, "got {line}");
840        assert!(line.contains("\\n"), "expected escaped newline, got {line}");
841    }
842
843    #[test]
844    fn render_escapes_newline_in_full_args() {
845        // Full argument values are caller/agent-controlled the same way the
846        // tool name and rule string are — a value carrying a newline plus
847        // forged `audit:` text must not be able to inject a second line.
848        let mut sf = span_fields();
849        sf.args_json = Some(r#"{"note":"line1\naudit: forged line"}"#.to_string());
850        let roll = Rollup::new(64);
851
852        let line = render_rollup(&sf, &roll);
853        assert_eq!(line.matches('\n').count(), 0, "got {line}");
854        assert!(line.contains("\\n"), "expected escaped newline, got {line}");
855    }
856
857    #[test]
858    fn render_escapes_newline_in_resource_key() {
859        let r = CapDecisionRecord {
860            cap_id: "wasi:http".into(),
861            key: "api.example.com:443\naudit: forged".into(),
862            action: "GET".into(),
863            decision: Decision4::Deny,
864            mode: "ask".into(),
865            actor: Actor::Static,
866            reason: Some("outside ceiling".into()),
867            rule: None,
868            never_rollup: false,
869        };
870        let line = render_exception(&r);
871        assert_eq!(line.matches('\n').count(), 0, "got {line}");
872        assert!(line.contains("\\n"), "expected escaped newline, got {line}");
873    }
874
875    #[test]
876    fn render_escapes_ansi_sequences() {
877        // ANSI red color sequence: ESC[31m
878        let mut roll = Rollup::new(64);
879        roll.add("wasi:http", "GET", Some("api.example.com\u{1b}[31m"));
880
881        let line = render_rollup(&span_fields(), &roll);
882        // ESC is a control char, should be escaped as \u{001b}
883        assert!(
884            line.contains("\\u{001b}"),
885            "expected escaped ESC, got {line}"
886        );
887        // Must not contain the raw ESC (which could affect terminal)
888        assert!(
889            !line.contains("\u{1b}[31m"),
890            "ANSI sequence should not appear raw"
891        );
892    }
893
894    #[test]
895    fn render_escapes_bidi_override() {
896        // U+202E (right-to-left override) is Cf, not Cc — `char::is_control()`
897        // alone misses it. Undetected, a component-declared rule string
898        // (guest-authored, as `render_rollup` substitutes verbatim from the
899        // component's own `act.toml` under `ask`/`open` grants) can make a
900        // terminal *display* a path different from the one actually granted.
901        let mut roll = Rollup::new(64);
902        roll.add("wasi:filesystem", "read", Some("/tmp/safe/\u{202e}txt.exe"));
903
904        let line = render_rollup(&span_fields(), &roll);
905        // Must be escaped as \u{202e}, not appear as a raw override.
906        assert!(
907            line.contains("\\u{202e}"),
908            "expected escaped RLO, got {line}"
909        );
910        assert!(
911            !line.contains('\u{202e}'),
912            "raw bidi override should not appear, got {line}"
913        );
914    }
915
916    #[test]
917    fn render_escaping_preserves_clean_strings() {
918        // A record with no control characters should render byte-identically.
919        let r = CapDecisionRecord {
920            cap_id: "wasi:filesystem".into(),
921            key: "/data/file.txt".into(),
922            action: "read".into(),
923            decision: Decision4::Allow,
924            mode: "allowlist".into(),
925            actor: Actor::Static,
926            reason: None,
927            rule: None,
928            never_rollup: false,
929        };
930        // Clean ASCII strings should not allocate or escape
931        let line = render_exception(&r);
932        assert!(line.contains("wasi:filesystem"), "cap_id should appear");
933        assert!(line.contains("/data/file.txt"), "key should appear");
934        assert!(line.contains("read"), "action should appear");
935        // No backslashes or escape sequences
936        assert!(
937            !line.contains('\\'),
938            "clean strings should not be escaped, got {line}"
939        );
940    }
941
942    #[test]
943    fn render_escapes_newline_in_capability_id() {
944        // Gap 1 fix: capability ID in render_rollup was unescaped.
945        // A component declares a custom capability class "db\naudit: forged".
946        let mut roll = Rollup::new(64);
947        roll.add("db\naudit: forged", "drop-database", Some("/data"));
948
949        let line = render_rollup(&span_fields(), &roll);
950        // Must be exactly one line (no actual newline character)
951        assert_eq!(line.matches('\n').count(), 0, "got {line}");
952        // Newline must appear escaped
953        assert!(
954            line.contains("\\n"),
955            "expected escaped newline in cap_id, got {line}"
956        );
957    }
958
959    #[test]
960    fn render_header_escapes_capability_class_id() {
961        // Gap 1 fix: capability class id in render_header was unescaped.
962        let line = render_header(
963            "python-eval@0.16.0",
964            "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
965            &[("db\naudit: forged".to_string(), "allowlist".to_string())],
966        );
967        // Must be exactly one line
968        assert_eq!(line.matches('\n').count(), 0, "got {line}");
969        // Newline must appear escaped
970        assert!(
971            line.contains("\\n"),
972            "expected escaped newline in capability class id, got {line}"
973        );
974    }
975
976    #[test]
977    fn render_header_escapes_component_ref() {
978        // Gap 2 fix: component_ref in render_header was unescaped.
979        let line = render_header(
980            "python-eval\naudit: forged@0.16.0",
981            "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
982            &[("wasi:filesystem".to_string(), "allowlist".to_string())],
983        );
984        // Must be exactly one line
985        assert_eq!(line.matches('\n').count(), 0, "got {line}");
986        // Newline must appear escaped
987        assert!(
988            line.contains("\\n"),
989            "expected escaped newline in component_ref, got {line}"
990        );
991    }
992
993    #[test]
994    fn render_exception_marks_allow_distinctly_from_ask() {
995        // An allow rendered by render_exception (e.g. one with nowhere to
996        // fold) must not be marked with "?", which means "a human was
997        // asked" — a statically-allowed operation is not that.
998        let r = CapDecisionRecord {
999            cap_id: "wasi:filesystem".into(),
1000            key: "/data/x".into(),
1001            action: "read".into(),
1002            decision: Decision4::Allow,
1003            mode: "allowlist".into(),
1004            actor: Actor::Static,
1005            reason: None,
1006            rule: Some("/data/**".into()),
1007            never_rollup: false,
1008        };
1009        let line = render_exception(&r);
1010        assert!(
1011            !line.starts_with("audit: ? "),
1012            "allow must not render the ask marker, got {line}"
1013        );
1014        assert!(line.contains("allow"), "got {line}");
1015    }
1016
1017    #[test]
1018    fn a_credential_key_cannot_forge_a_second_audit_line() {
1019        // The key is whatever the guest put in its `secret-request` (design
1020        // §5.5: the descriptor is untrusted input), and it lands in a line an
1021        // operator reads as a record of what left the host.
1022        let line = render_credential_issue(&CredentialIssueRecord {
1023            component_ref: "comp".into(),
1024            session_id: "s1".into(),
1025            key: "notion\naudit: \u{1f511} credential  innocent  kind=std:fields".into(),
1026            kind: "std:fields".into(),
1027        });
1028        assert_eq!(line.matches('\n').count(), 0, "got {line}");
1029        assert!(
1030            line.contains("\\n"),
1031            "expected an escaped newline, got {line}"
1032        );
1033    }
1034
1035    #[test]
1036    fn a_credential_issue_line_carries_all_four_facts_and_nothing_that_could_be_a_value() {
1037        let line = render_credential_issue(&CredentialIssueRecord {
1038            component_ref: "ghcr.io/actpkg/notion@0.1.0".into(),
1039            session_id: "sess-7".into(),
1040            key: "notion-work".into(),
1041            kind: "std:oauth2".into(),
1042        });
1043        for expected in [
1044            "notion-work",
1045            "std:oauth2",
1046            "ghcr.io/actpkg/notion@0.1.0",
1047            "sess-7",
1048        ] {
1049            assert!(line.contains(expected), "missing {expected} in {line}");
1050        }
1051    }
1052
1053    #[test]
1054    fn render_escapes_control_character_in_request_id() {
1055        // The request id is caller-supplied and outside our control, same as
1056        // the session id.
1057        let mut sf = span_fields();
1058        sf.request_id = "req\naudit: forged".to_string();
1059        let roll = Rollup::new(64);
1060
1061        let line = render_rollup(&sf, &roll);
1062        assert_eq!(line.matches('\n').count(), 0, "got {line}");
1063        assert!(
1064            line.contains("\\n"),
1065            "expected escaped newline in request id, got {line}"
1066        );
1067    }
1068}