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{80}-\u{9f}\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{80}'..='\u{9f}').contains(&c)
145            || ('\u{202a}'..='\u{202e}').contains(&c)
146            || ('\u{2066}'..='\u{2069}').contains(&c)
147    })
148}
149
150fn bound(text: &str, max_len: usize) -> String {
151    if text.chars().count() <= max_len {
152        return text.to_string();
153    }
154    let truncated: String = text.chars().take(max_len).collect();
155    format!("{truncated}{TRUNCATION_MARKER}")
156}
157
158fn xml_escape(s: &str) -> String {
159    s.replace('&', "&amp;")
160        .replace('<', "&lt;")
161        .replace('>', "&gt;")
162        .replace('"', "&quot;")
163        .replace('\'', "&apos;")
164}
165
166fn markdown_neutralize(s: &str) -> String {
167    s.replace('`', "\\`")
168        .replace("](", "\\]\\(")
169        .replace("![", "\\!\\[")
170        .replace('<', "&lt;")
171        .replace('>', "&gt;")
172        .replace("javascript:", "javascript\\:")
173}
174
175/// The sink-specific final encode, applied to already-redacted, control-stripped, bounded text.
176///
177/// Structured sinks (stdout/otel/json/sarif) return the value-safe text unchanged: the value is
178/// placed into a JSON/attribute structure whose serializer (serde) applies escaping, so pre-escaping
179/// here would double-encode. String-built sinks (junit/markdown) neutralize active markup themselves
180/// because their output is often concatenated, not serializer-escaped.
181fn encode(sink: Sink, text: &str) -> String {
182    match sink {
183        Sink::Stdout | Sink::Otel | Sink::Json | Sink::Sarif => text.to_string(),
184        Sink::Junit => xml_escape(text),
185        Sink::Markdown => markdown_neutralize(text),
186    }
187}
188
189/// Render `input` safely for `sink`: strip control -> redact -> truncate -> sink-encode.
190/// Control-strip precedes redaction (anti-evasion); redact-before-truncate is the leak invariant;
191/// encode is the sink boundary on already-safe text. Returns the redaction outcome for accounting.
192pub fn render_safe_with_outcome(
193    sink: Sink,
194    input: &str,
195    max_len: usize,
196) -> (String, RedactOutcome) {
197    let stripped = strip_control(input);
198    let redacted = redact(&stripped);
199    let bounded = bound(&redacted.text, max_len);
200    (encode(sink, &bounded), redacted)
201}
202
203/// Render `input` safely for `sink` (see [`render_safe_with_outcome`]).
204pub fn render_safe(sink: Sink, input: &str, max_len: usize) -> String {
205    render_safe_with_outcome(sink, input, max_len).0
206}
207
208/// Object keys whose string values carry untrusted model / agent / tool / user content and must be
209/// rendered sink-safe wherever they appear in a `details` tree or result row. Everything not on this
210/// list (assay-owned ids, schema names, reason codes, status enums, artifact digests, timestamps,
211/// counts, policy ids, fingerprints) stays raw. The list IS the safety boundary: a key matches by
212/// name at any depth, and once inside an untrusted-keyed subtree every string leaf is untrusted (so a
213/// structured `expected`/`actual`/`assertions[].message` is covered without enumerating its shape).
214pub const UNTRUSTED_FIELDS: &[&str] = &[
215    "prompt",
216    "response",
217    "output",
218    "error",
219    "rationale",
220    "message",
221    "expected",
222    "actual",
223    "diff",
224    "tool_output",
225    "stdout",
226    "stderr",
227];
228
229fn is_untrusted_key(key: &str) -> bool {
230    UNTRUSTED_FIELDS.contains(&key)
231}
232
233/// Recursively render the untrusted string leaves of a `details` / result JSON value safe for `sink`,
234/// leaving assay-owned keys structurally untouched (byte-stable, not merely no-op'd). A string leaf is
235/// untrusted when its own key — or any ancestor key — is in [`UNTRUSTED_FIELDS`]; numbers, bools and
236/// null pass through unchanged. This is the recursive allowlist-by-key-name companion to
237/// [`render_safe`], for whole-blob sinks (the `run.json` serializer) where each field cannot be wired
238/// by hand. `max_len` bounds each rendered leaf; pass [`usize::MAX`] for a record sink that must keep
239/// full (redacted) content rather than a truncated preview.
240pub fn render_details_safe(
241    sink: Sink,
242    value: &serde_json::Value,
243    max_len: usize,
244) -> serde_json::Value {
245    render_details_inner(sink, value, max_len, false)
246}
247
248fn render_details_inner(
249    sink: Sink,
250    value: &serde_json::Value,
251    max_len: usize,
252    in_untrusted: bool,
253) -> serde_json::Value {
254    use serde_json::Value;
255    match value {
256        Value::String(s) if in_untrusted => Value::String(render_safe(sink, s, max_len)),
257        Value::Array(items) => Value::Array(
258            items
259                .iter()
260                .map(|v| render_details_inner(sink, v, max_len, in_untrusted))
261                .collect(),
262        ),
263        Value::Object(map) => {
264            let mut out = serde_json::Map::with_capacity(map.len());
265            for (k, v) in map {
266                let child_untrusted = in_untrusted || is_untrusted_key(k);
267                out.insert(
268                    k.clone(),
269                    render_details_inner(sink, v, max_len, child_untrusted),
270                );
271            }
272            Value::Object(out)
273        }
274        other => other.clone(),
275    }
276}
277
278/// DELIBERATELY WRONG order (truncate raw input FIRST, then redact): used only by the differential
279/// test to prove that this order leaks a truncated secret prefix. Never call this in product code.
280#[doc(hidden)]
281pub fn render_truncate_first_unsafe(sink: Sink, input: &str, max_len: usize) -> String {
282    let bounded = bound(input, max_len);
283    let stripped = strip_control(&bounded);
284    let redacted = redact(&stripped);
285    encode(sink, &redacted.text)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn has_control(s: &str) -> bool {
293        s.contains('\u{1b}') || s.contains('\u{07}') || has_residual_control(s)
294    }
295
296    #[test]
297    fn redacts_secret_shapes_value_free() {
298        let token = format!("ghp_{}", "A".repeat(36));
299        let out = redact(&format!("here is {token} ok"));
300        assert!(out.text.contains("<redacted:github-token>"));
301        assert!(!out.text.contains(&token));
302        assert_eq!(out.secret_hits, 1);
303    }
304
305    #[test]
306    fn strips_terminal_control() {
307        let s = "\u{1b}[31mRED\u{1b}[0m\u{07}\u{202e}rev";
308        let out = strip_control(s);
309        assert!(!has_control(&out));
310        assert!(out.contains("RED"));
311    }
312
313    #[test]
314    fn render_safe_never_leaks_across_sinks() {
315        let secret = format!("ghp_{}", "B".repeat(36));
316        let input = format!("\u{1b}[31m{secret}\u{1b}[0m alice@example.com");
317        for sink in Sink::ALL {
318            let out = render_safe(sink, &input, MAX_RENDER_FIELD);
319            assert!(!out.contains(&secret), "{} leaked secret", sink.as_str());
320            assert!(
321                !out.contains("alice@example.com"),
322                "{} leaked pii",
323                sink.as_str()
324            );
325            assert!(!has_control(&out), "{} leaked control", sink.as_str());
326        }
327    }
328
329    #[test]
330    fn redact_before_truncate_does_not_leak_but_wrong_order_does() {
331        // A secret placed near the truncation boundary: truncate-first cuts it so the shape no longer
332        // matches and a raw `ghp_` fragment leaks; redact-first replaces it whole before bounding.
333        let secret = format!("ghp_{}", "C".repeat(36));
334        let input = format!("{} {secret}", "x".repeat(239));
335        let safe = render_safe(Sink::Stdout, &input, MAX_RENDER_FIELD);
336        assert!(
337            !safe.contains("ghp_"),
338            "redact-before-truncate must not leak"
339        );
340        let unsafe_out = render_truncate_first_unsafe(Sink::Stdout, &input, MAX_RENDER_FIELD);
341        assert!(
342            unsafe_out.contains("ghp_"),
343            "truncate-first is expected to leak"
344        );
345    }
346
347    #[test]
348    fn benign_near_matches_survive() {
349        let benign =
350            "uuid 123e4567-e89b-12d3-a456-426614174000 sha256:deadbeef path /usr/bin/assay";
351        let out = redact(benign);
352        assert!(
353            !out.text.contains("<redacted:"),
354            "benign text over-redacted: {}",
355            out.text
356        );
357    }
358
359    #[test]
360    fn sink_encodings_are_distinct_where_expected() {
361        assert_eq!(Sink::Junit.encoding(), "xml_escape");
362        assert_eq!(Sink::Markdown.encoding(), "markdown_neutralize");
363        // Structured sinks defer escaping to their serializer (no in-adapter pre-escape).
364        assert_eq!(Sink::Sarif.encoding(), "json_serializer");
365    }
366
367    #[test]
368    fn details_walker_redacts_untrusted_keeps_owned_byte_stable() {
369        let secret = format!("ghp_{}", "D".repeat(36));
370        let email = "alice@example.com";
371        let details = serde_json::json!({
372            "prompt": format!("ask {secret}"),
373            "assertions": [{ "message": format!("got {email}") }, { "passed": true }],
374            "expected": "uuid 123e4567-e89b-12d3-a456-426614174000",
375            "skip": { "fingerprint": "abc123def456", "reason": "fingerprint_match" },
376            "score_pct": 42,
377        });
378        let safe = render_details_safe(Sink::Json, &details, usize::MAX);
379        let blob = safe.to_string();
380        // Untrusted leaves redacted (prompt at any depth, assertions[].message nested).
381        assert!(!blob.contains(&secret), "prompt secret leaked");
382        assert!(!blob.contains(email), "nested assertion pii leaked");
383        assert!(blob.contains("<redacted:"), "no redaction markers fired");
384        // Benign content UNDER an untrusted key survives (no over-redaction).
385        assert_eq!(
386            safe["expected"],
387            serde_json::json!("uuid 123e4567-e89b-12d3-a456-426614174000")
388        );
389        // Assay-owned keys are structurally untouched (not merely a render_safe no-op).
390        assert_eq!(
391            safe["skip"]["fingerprint"],
392            serde_json::json!("abc123def456")
393        );
394        assert_eq!(
395            safe["skip"]["reason"],
396            serde_json::json!("fingerprint_match")
397        );
398        assert_eq!(safe["score_pct"], serde_json::json!(42));
399        // A non-string leaf under an untrusted-keyed subtree passes through.
400        assert_eq!(safe["assertions"][1]["passed"], serde_json::json!(true));
401    }
402
403    #[test]
404    fn details_walker_record_sink_keeps_full_length_but_strips_secret() {
405        // run.json is a record sink: redact + control-strip, but no truncation marker (usize::MAX).
406        let secret = format!("ghp_{}", "E".repeat(36));
407        let long = format!("{} {secret}", "z".repeat(400));
408        let details = serde_json::json!({ "response": long });
409        let safe = render_details_safe(Sink::Json, &details, usize::MAX);
410        let rendered = safe["response"].as_str().unwrap();
411        assert!(!rendered.contains(&secret), "record sink leaked secret");
412        assert!(rendered.contains("<redacted:github-token>"));
413        assert!(
414            !rendered.contains("(truncated)"),
415            "record sink must not truncate"
416        );
417        assert!(rendered.len() > 400, "record sink preserved full length");
418    }
419
420    #[test]
421    fn structured_sinks_return_unescaped_value_text() {
422        // A benign value with JSON-special chars must come back unescaped, so a downstream serde
423        // serializer does not double-encode it. (Active-markup sinks still neutralize in-adapter.)
424        let v = r#"path "C:\tmp" <ok>"#;
425        assert_eq!(render_safe(Sink::Json, v, MAX_RENDER_FIELD), v);
426        assert!(render_safe(Sink::Junit, v, MAX_RENDER_FIELD).contains("&lt;ok&gt;"));
427    }
428}