Skip to main content

assay_core/render_safety/
mod.rs

1//! Render-side sink safety (MCP01a slice 1).
2//!
3//! A pipeline, not one universal renderer: `strip control -> redact -> truncate -> sink-specific
4//! encode`. Control is stripped BEFORE redaction so an attacker cannot hide a secret from the
5//! detector by gluing terminal-control bytes into it (`ghp\x1b[m_...` would otherwise break the
6//! word boundary, dodge the rule, then surface once control is stripped). The load-bearing invariant
7//! is **redact-before-truncate** (so a secret can never survive as a truncated prefix); the final
8//! encode is the sink boundary, applied to already-stripped, redacted, bounded text. Capture-side
9//! redaction (ADR-034) is a separate, earlier layer; this protects what reaches a rendered sink.
10//!
11//! Scoped value rule: raw credential values must not appear in public/report sinks. This module does
12//! not manage secret lifecycle, rotation or vaulting; detection is pattern-based and may miss a novel
13//! format (see `rules`). It is the producer half of the MCP01a render-safety conformance.
14
15pub mod conformance;
16pub mod corpus;
17pub mod rules;
18
19use lazy_static::lazy_static;
20use regex::Regex;
21use std::collections::BTreeMap;
22
23/// Default bound for a rendered field, mirroring the Plimsoll sink-safe renderer.
24pub const MAX_RENDER_FIELD: usize = 256;
25const TRUNCATION_MARKER: &str = "(truncated)";
26
27/// A render sink. The pipeline order is shared; only the final encode differs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Sink {
30    Stdout,
31    Json,
32    Sarif,
33    Junit,
34    Markdown,
35    Otel,
36}
37
38impl Sink {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Sink::Stdout => "stdout",
42            Sink::Json => "json",
43            Sink::Sarif => "sarif",
44            Sink::Junit => "junit",
45            Sink::Markdown => "markdown",
46            Sink::Otel => "otel",
47        }
48    }
49
50    /// The name of the sink-specific final encoding. Structured sinks (json/sarif/otel) return the
51    /// value-safe text and let their serializer escape it (`*_serializer` / `attribute_value`); the
52    /// adapter must not pre-escape or a downstream serde serializer would double-encode. String-built
53    /// sinks (junit/markdown) neutralize active markup in-adapter.
54    pub fn encoding(self) -> &'static str {
55        match self {
56            Sink::Stdout => "terminal_safe",
57            Sink::Json | Sink::Sarif => "json_serializer",
58            Sink::Junit => "xml_escape",
59            Sink::Markdown => "markdown_neutralize",
60            Sink::Otel => "attribute_value",
61        }
62    }
63
64    pub const ALL: [Sink; 6] = [
65        Sink::Stdout,
66        Sink::Json,
67        Sink::Sarif,
68        Sink::Junit,
69        Sink::Markdown,
70        Sink::Otel,
71    ];
72}
73
74/// The outcome of redaction: the value-free text plus which rule classes fired.
75#[derive(Debug, Clone, Default)]
76pub struct RedactOutcome {
77    pub text: String,
78    pub fired: BTreeMap<String, u64>,
79    pub secret_hits: u64,
80    pub pii_hits: u64,
81}
82
83/// Redact secret/PII shapes, replacing each match with a value-free `<redacted:RULE>` placeholder.
84/// Idempotent over its own placeholders (they carry no secret shape).
85pub fn redact(input: &str) -> RedactOutcome {
86    let mut text = input.to_string();
87    let mut fired: BTreeMap<String, u64> = BTreeMap::new();
88    let mut secret_hits = 0u64;
89    let mut pii_hits = 0u64;
90    for rule in rules::RULES.iter() {
91        let count = rule.re.find_iter(&text).count() as u64;
92        if count == 0 {
93            continue;
94        }
95        *fired.entry(rule.name.to_string()).or_insert(0) += count;
96        match rule.class {
97            "secret" => secret_hits += count,
98            "pii" => pii_hits += count,
99            _ => {}
100        }
101        let placeholder = format!("<redacted:{}>", rule.name);
102        text = rule
103            .re
104            .replace_all(&text, placeholder.as_str())
105            .into_owned();
106    }
107    RedactOutcome {
108        text,
109        fired,
110        secret_hits,
111        pii_hits,
112    }
113}
114
115lazy_static! {
116    // ESC-introduced sequences: CSI (`ESC [ ... final`) and OSC (`ESC ] ... BEL|ESC\`).
117    static ref ANSI_RE: Regex =
118        Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap();
119    // Any remaining lone ESC.
120    static ref LONE_ESC: Regex = Regex::new(r"\x1b").unwrap();
121    // C0/C1 control (keep \t \n \r), DEL, and Unicode bidi formatting overrides.
122    static ref CONTROL_RE: Regex =
123        Regex::new(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u{202a}-\u{202e}\u{2066}-\u{2069}]").unwrap();
124}
125
126/// Strip terminal-control: ANSI/OSC sequences, BEL, other C0/C1 controls (keeping tab/newline/CR),
127/// and Unicode bidi overrides. Stripped control becomes U+FFFD so the removal is visible.
128pub fn strip_control(input: &str) -> String {
129    let no_ansi = ANSI_RE.replace_all(input, "");
130    let no_esc = LONE_ESC.replace_all(&no_ansi, "\u{fffd}");
131    CONTROL_RE.replace_all(&no_esc, "\u{fffd}").into_owned()
132}
133
134/// True if any terminal-control or Unicode bidi-formatting character remains (tab/newline/CR are
135/// allowed). The conformance leak predicate for control-class probes: stronger than matching a single
136/// corpus needle, it rejects ANY residual control in rendered output.
137pub fn has_residual_control(s: &str) -> bool {
138    s.chars().any(|c| {
139        c == '\u{7f}'
140            || ('\u{00}'..='\u{08}').contains(&c)
141            || c == '\u{0b}'
142            || c == '\u{0c}'
143            || ('\u{0e}'..='\u{1f}').contains(&c)
144            || ('\u{202a}'..='\u{202e}').contains(&c)
145            || ('\u{2066}'..='\u{2069}').contains(&c)
146    })
147}
148
149fn bound(text: &str, max_len: usize) -> String {
150    if text.chars().count() <= max_len {
151        return text.to_string();
152    }
153    let truncated: String = text.chars().take(max_len).collect();
154    format!("{truncated}{TRUNCATION_MARKER}")
155}
156
157fn xml_escape(s: &str) -> String {
158    s.replace('&', "&amp;")
159        .replace('<', "&lt;")
160        .replace('>', "&gt;")
161        .replace('"', "&quot;")
162        .replace('\'', "&apos;")
163}
164
165fn markdown_neutralize(s: &str) -> String {
166    s.replace('`', "\\`")
167        .replace("](", "\\]\\(")
168        .replace("![", "\\!\\[")
169        .replace('<', "&lt;")
170        .replace('>', "&gt;")
171        .replace("javascript:", "javascript\\:")
172}
173
174/// The sink-specific final encode, applied to already-redacted, control-stripped, bounded text.
175///
176/// Structured sinks (stdout/otel/json/sarif) return the value-safe text unchanged: the value is
177/// placed into a JSON/attribute structure whose serializer (serde) applies escaping, so pre-escaping
178/// here would double-encode. String-built sinks (junit/markdown) neutralize active markup themselves
179/// because their output is often concatenated, not serializer-escaped.
180fn encode(sink: Sink, text: &str) -> String {
181    match sink {
182        Sink::Stdout | Sink::Otel | Sink::Json | Sink::Sarif => text.to_string(),
183        Sink::Junit => xml_escape(text),
184        Sink::Markdown => markdown_neutralize(text),
185    }
186}
187
188/// Render `input` safely for `sink`: strip control -> redact -> truncate -> sink-encode.
189/// Control-strip precedes redaction (anti-evasion); redact-before-truncate is the leak invariant;
190/// encode is the sink boundary on already-safe text. Returns the redaction outcome for accounting.
191pub fn render_safe_with_outcome(
192    sink: Sink,
193    input: &str,
194    max_len: usize,
195) -> (String, RedactOutcome) {
196    let stripped = strip_control(input);
197    let redacted = redact(&stripped);
198    let bounded = bound(&redacted.text, max_len);
199    (encode(sink, &bounded), redacted)
200}
201
202/// Render `input` safely for `sink` (see [`render_safe_with_outcome`]).
203pub fn render_safe(sink: Sink, input: &str, max_len: usize) -> String {
204    render_safe_with_outcome(sink, input, max_len).0
205}
206
207/// Object keys whose string values carry untrusted model / agent / tool / user content and must be
208/// rendered sink-safe wherever they appear in a `details` tree or result row. Everything not on this
209/// list (assay-owned ids, schema names, reason codes, status enums, artifact digests, timestamps,
210/// counts, policy ids, fingerprints) stays raw. The list IS the safety boundary: a key matches by
211/// name at any depth, and once inside an untrusted-keyed subtree every string leaf is untrusted (so a
212/// structured `expected`/`actual`/`assertions[].message` is covered without enumerating its shape).
213pub const UNTRUSTED_FIELDS: &[&str] = &[
214    "prompt",
215    "response",
216    "output",
217    "error",
218    "rationale",
219    "message",
220    "expected",
221    "actual",
222    "diff",
223    "tool_output",
224    "stdout",
225    "stderr",
226];
227
228fn is_untrusted_key(key: &str) -> bool {
229    UNTRUSTED_FIELDS.contains(&key)
230}
231
232/// Recursively render the untrusted string leaves of a `details` / result JSON value safe for `sink`,
233/// leaving assay-owned keys structurally untouched (byte-stable, not merely no-op'd). A string leaf is
234/// untrusted when its own key — or any ancestor key — is in [`UNTRUSTED_FIELDS`]; numbers, bools and
235/// null pass through unchanged. This is the recursive allowlist-by-key-name companion to
236/// [`render_safe`], for whole-blob sinks (the `run.json` serializer) where each field cannot be wired
237/// by hand. `max_len` bounds each rendered leaf; pass [`usize::MAX`] for a record sink that must keep
238/// full (redacted) content rather than a truncated preview.
239pub fn render_details_safe(
240    sink: Sink,
241    value: &serde_json::Value,
242    max_len: usize,
243) -> serde_json::Value {
244    render_details_inner(sink, value, max_len, false)
245}
246
247fn render_details_inner(
248    sink: Sink,
249    value: &serde_json::Value,
250    max_len: usize,
251    in_untrusted: bool,
252) -> serde_json::Value {
253    use serde_json::Value;
254    match value {
255        Value::String(s) if in_untrusted => Value::String(render_safe(sink, s, max_len)),
256        Value::Array(items) => Value::Array(
257            items
258                .iter()
259                .map(|v| render_details_inner(sink, v, max_len, in_untrusted))
260                .collect(),
261        ),
262        Value::Object(map) => {
263            let mut out = serde_json::Map::with_capacity(map.len());
264            for (k, v) in map {
265                let child_untrusted = in_untrusted || is_untrusted_key(k);
266                out.insert(
267                    k.clone(),
268                    render_details_inner(sink, v, max_len, child_untrusted),
269                );
270            }
271            Value::Object(out)
272        }
273        other => other.clone(),
274    }
275}
276
277/// DELIBERATELY WRONG order (truncate raw input FIRST, then redact): used only by the differential
278/// test to prove that this order leaks a truncated secret prefix. Never call this in product code.
279#[doc(hidden)]
280pub fn render_truncate_first_unsafe(sink: Sink, input: &str, max_len: usize) -> String {
281    let bounded = bound(input, max_len);
282    let stripped = strip_control(&bounded);
283    let redacted = redact(&stripped);
284    encode(sink, &redacted.text)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn has_control(s: &str) -> bool {
292        s.contains('\u{1b}') || s.contains('\u{07}') || has_residual_control(s)
293    }
294
295    #[test]
296    fn redacts_secret_shapes_value_free() {
297        let token = format!("ghp_{}", "A".repeat(36));
298        let out = redact(&format!("here is {token} ok"));
299        assert!(out.text.contains("<redacted:github-token>"));
300        assert!(!out.text.contains(&token));
301        assert_eq!(out.secret_hits, 1);
302    }
303
304    #[test]
305    fn strips_terminal_control() {
306        let s = "\u{1b}[31mRED\u{1b}[0m\u{07}\u{202e}rev";
307        let out = strip_control(s);
308        assert!(!has_control(&out));
309        assert!(out.contains("RED"));
310    }
311
312    #[test]
313    fn render_safe_never_leaks_across_sinks() {
314        let secret = format!("ghp_{}", "B".repeat(36));
315        let input = format!("\u{1b}[31m{secret}\u{1b}[0m alice@example.com");
316        for sink in Sink::ALL {
317            let out = render_safe(sink, &input, MAX_RENDER_FIELD);
318            assert!(!out.contains(&secret), "{} leaked secret", sink.as_str());
319            assert!(
320                !out.contains("alice@example.com"),
321                "{} leaked pii",
322                sink.as_str()
323            );
324            assert!(!has_control(&out), "{} leaked control", sink.as_str());
325        }
326    }
327
328    #[test]
329    fn redact_before_truncate_does_not_leak_but_wrong_order_does() {
330        // A secret placed near the truncation boundary: truncate-first cuts it so the shape no longer
331        // matches and a raw `ghp_` fragment leaks; redact-first replaces it whole before bounding.
332        let secret = format!("ghp_{}", "C".repeat(36));
333        let input = format!("{} {secret}", "x".repeat(239));
334        let safe = render_safe(Sink::Stdout, &input, MAX_RENDER_FIELD);
335        assert!(
336            !safe.contains("ghp_"),
337            "redact-before-truncate must not leak"
338        );
339        let unsafe_out = render_truncate_first_unsafe(Sink::Stdout, &input, MAX_RENDER_FIELD);
340        assert!(
341            unsafe_out.contains("ghp_"),
342            "truncate-first is expected to leak"
343        );
344    }
345
346    #[test]
347    fn benign_near_matches_survive() {
348        let benign =
349            "uuid 123e4567-e89b-12d3-a456-426614174000 sha256:deadbeef path /usr/bin/assay";
350        let out = redact(benign);
351        assert!(
352            !out.text.contains("<redacted:"),
353            "benign text over-redacted: {}",
354            out.text
355        );
356    }
357
358    #[test]
359    fn sink_encodings_are_distinct_where_expected() {
360        assert_eq!(Sink::Junit.encoding(), "xml_escape");
361        assert_eq!(Sink::Markdown.encoding(), "markdown_neutralize");
362        // Structured sinks defer escaping to their serializer (no in-adapter pre-escape).
363        assert_eq!(Sink::Sarif.encoding(), "json_serializer");
364    }
365
366    #[test]
367    fn details_walker_redacts_untrusted_keeps_owned_byte_stable() {
368        let secret = format!("ghp_{}", "D".repeat(36));
369        let email = "alice@example.com";
370        let details = serde_json::json!({
371            "prompt": format!("ask {secret}"),
372            "assertions": [{ "message": format!("got {email}") }, { "passed": true }],
373            "expected": "uuid 123e4567-e89b-12d3-a456-426614174000",
374            "skip": { "fingerprint": "abc123def456", "reason": "fingerprint_match" },
375            "score_pct": 42,
376        });
377        let safe = render_details_safe(Sink::Json, &details, usize::MAX);
378        let blob = safe.to_string();
379        // Untrusted leaves redacted (prompt at any depth, assertions[].message nested).
380        assert!(!blob.contains(&secret), "prompt secret leaked");
381        assert!(!blob.contains(email), "nested assertion pii leaked");
382        assert!(blob.contains("<redacted:"), "no redaction markers fired");
383        // Benign content UNDER an untrusted key survives (no over-redaction).
384        assert_eq!(
385            safe["expected"],
386            serde_json::json!("uuid 123e4567-e89b-12d3-a456-426614174000")
387        );
388        // Assay-owned keys are structurally untouched (not merely a render_safe no-op).
389        assert_eq!(
390            safe["skip"]["fingerprint"],
391            serde_json::json!("abc123def456")
392        );
393        assert_eq!(
394            safe["skip"]["reason"],
395            serde_json::json!("fingerprint_match")
396        );
397        assert_eq!(safe["score_pct"], serde_json::json!(42));
398        // A non-string leaf under an untrusted-keyed subtree passes through.
399        assert_eq!(safe["assertions"][1]["passed"], serde_json::json!(true));
400    }
401
402    #[test]
403    fn details_walker_record_sink_keeps_full_length_but_strips_secret() {
404        // run.json is a record sink: redact + control-strip, but no truncation marker (usize::MAX).
405        let secret = format!("ghp_{}", "E".repeat(36));
406        let long = format!("{} {secret}", "z".repeat(400));
407        let details = serde_json::json!({ "response": long });
408        let safe = render_details_safe(Sink::Json, &details, usize::MAX);
409        let rendered = safe["response"].as_str().unwrap();
410        assert!(!rendered.contains(&secret), "record sink leaked secret");
411        assert!(rendered.contains("<redacted:github-token>"));
412        assert!(
413            !rendered.contains("(truncated)"),
414            "record sink must not truncate"
415        );
416        assert!(rendered.len() > 400, "record sink preserved full length");
417    }
418
419    #[test]
420    fn structured_sinks_return_unescaped_value_text() {
421        // A benign value with JSON-special chars must come back unescaped, so a downstream serde
422        // serializer does not double-encode it. (Active-markup sinks still neutralize in-adapter.)
423        let v = r#"path "C:\tmp" <ok>"#;
424        assert_eq!(render_safe(Sink::Json, v, MAX_RENDER_FIELD), v);
425        assert!(render_safe(Sink::Junit, v, MAX_RENDER_FIELD).contains("&lt;ok&gt;"));
426    }
427}