Skip to main content

agent_first_data/
redaction.rs

1use serde_json::Value;
2use std::collections::HashSet;
3
4// ═══════════════════════════════════════════
5// Public API: Output Formatters
6// ═══════════════════════════════════════════
7
8/// Which fields a [`Redactor`] scrubs. The default is [`RedactionPolicy::All`].
9///
10/// The policy selects a *scope* inside a structured value. A command line
11/// ([`Redactor::argv`]) and a bare URL string ([`Redactor::url`]) have no
12/// `result`/`trace` split to scope to, so on those two paths `TraceOnly`
13/// redacts in full like `All`, and only [`RedactionPolicy::Off`] turns
14/// redaction off.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum RedactionPolicy {
17    /// Redact every secret field anywhere in the value (the default).
18    #[default]
19    All,
20    /// Redact only inside the top-level `trace` object.
21    TraceOnly,
22    /// Do not redact anything.
23    Off,
24}
25
26impl RedactionPolicy {
27    /// Whether this policy redacts an input that carries no scope of its own —
28    /// a command line or a single URL string, as opposed to a JSON value with a
29    /// `trace` object to narrow to.
30    ///
31    /// `TraceOnly` narrows *where* redaction applies within a value; it does not
32    /// weaken redaction. A standalone argv or URL has no non-`trace` half to
33    /// leave alone — and both are diagnostic material by construction, the very
34    /// thing `TraceOnly` scrubs — so `TraceOnly` redacts them in full, exactly
35    /// like `All`. Only `Off`, the caller explicitly asking for raw output,
36    /// disables redaction, and it does so on all three paths alike.
37    fn redacts_unscoped_input(self) -> bool {
38        !matches!(self, RedactionPolicy::Off)
39    }
40}
41
42/// Rendering style for plain (logfmt) output only. JSON and YAML are always
43/// structure-preserving and ignore this.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum PlainStyle {
46    /// Human-readable AFDATA rendering: strip suffixes and format values.
47    #[default]
48    Readable,
49    /// Schema-preserving rendering: keep keys and values unchanged after redaction.
50    Raw,
51}
52
53/// Configurable redaction builder for secrets and legacy field names.
54///
55/// `Redactor` encapsulates redaction policy and custom secret field names.
56/// Build with [`Redactor::new()`], configure via builder methods, then pass to
57/// redaction functions like [`redacted_value`] or [`redact_url_secrets`].
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct Redactor {
60    policy: RedactionPolicy,
61    secret_names: Vec<String>,
62}
63
64impl Redactor {
65    /// Create a new default redactor (full redaction, no custom secret names).
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Set custom field names to treat as secrets in addition to `_secret` suffixes.
71    ///
72    /// Matching is exact field-name equality at any nesting level. The same
73    /// list also matches URL query-parameter names inside `_url` fields.
74    /// Builder style: returns `self`.
75    pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
76        self.secret_names = names.into_iter().map(|s| s.into()).collect();
77        self
78    }
79
80    /// Set the redaction policy (default: full redaction).
81    /// Builder style: returns `self`.
82    pub fn policy(mut self, policy: RedactionPolicy) -> Self {
83        self.policy = policy;
84        self
85    }
86
87    /// Redact a JSON value copy using this redactor's policy and secret names.
88    ///
89    /// Clones `value` first; for a large payload you already own and can
90    /// mutate, prefer [`Redactor::redact_in_place`] to avoid the copy.
91    pub fn value(&self, value: &Value) -> Value {
92        let mut v = value.clone();
93        self.redact_in_place(&mut v);
94        v
95    }
96
97    /// Redact secret components of a URL string using this redactor's settings.
98    ///
99    /// A query parameter is redacted iff its (form-decoded) name ends in
100    /// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. A
101    /// fragment written in the same `k=v&k=v` shape — how an OAuth implicit-flow
102    /// response carries its token — is redacted by that same rule; any other
103    /// fragment passes through byte-for-byte. The userinfo password
104    /// (`scheme://user:pass@host`) is always redacted as a structural rule.
105    /// Only the secret spans are replaced with `***`; every other byte is
106    /// preserved. A string that is not a single, whitespace-free,
107    /// scheme-prefixed URL (including a URL embedded in surrounding prose) is
108    /// returned unchanged.
109    ///
110    /// A URL is unscoped input: `RedactionPolicy::Off` returns it unchanged,
111    /// every other policy redacts it in full.
112    pub fn url(&self, url: &str) -> String {
113        if !self.policy.redacts_unscoped_input() {
114            return url.to_string();
115        }
116        let context = RedactionContext::from_redactor(self);
117        redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
118    }
119
120    /// Redact `value` in place, using this redactor's policy and secret names.
121    ///
122    /// The zero-copy counterpart of [`Redactor::value`] — use it on a large
123    /// payload you already own to avoid cloning.
124    pub fn redact_in_place(&self, value: &mut Value) {
125        let context = RedactionContext::from_redactor(self);
126        apply_redaction_policy_with_context(value, self.policy, &context);
127    }
128
129    /// Redact secret *values* out of a command line, using this redactor's
130    /// policy and secret names.
131    ///
132    /// A long flag whose name is secret by AFDATA naming (`--api-key-secret`,
133    /// or an exact `secret_names` entry) has its value replaced by `***`, in
134    /// both `--flag=value` and `--flag value` spellings. Everything else is
135    /// preserved byte-for-byte.
136    ///
137    /// Free text is deliberately never scanned: a bare `api_key_secret=sk-live`
138    /// positional, or a secret-looking token after a non-secret flag, is left
139    /// alone. AFDATA decides sensitivity from the *field name*, and argv is no
140    /// exception — rename the flag rather than pattern-matching values. A flag
141    /// with no value (end of argv, or followed by another flag) is likewise
142    /// left inspectable.
143    ///
144    /// Only long (`--`) flags are recognized, matching the convention's
145    /// long-flags-only rule.
146    ///
147    /// A command line is unscoped input: `RedactionPolicy::Off` returns `args`
148    /// unchanged, every other policy redacts in full.
149    pub fn argv<S: AsRef<str>>(&self, args: &[S]) -> Vec<String> {
150        if !self.policy.redacts_unscoped_input() {
151            return args.iter().map(|arg| arg.as_ref().to_string()).collect();
152        }
153        let context = RedactionContext::from_redactor(self);
154        let mut out = Vec::with_capacity(args.len());
155        let mut redact_next = false;
156        for arg in args {
157            let arg = arg.as_ref();
158            if redact_next {
159                redact_next = false;
160                if !arg.starts_with('-') {
161                    out.push(REDACTED_MARKER.to_string());
162                    continue;
163                }
164            }
165            if let Some(rest) = arg.strip_prefix("--") {
166                if let Some((name, _)) = rest.split_once('=') {
167                    if is_secret_flag_name(name, &context) {
168                        out.push(format!("--{name}={REDACTED_MARKER}"));
169                        continue;
170                    }
171                } else if is_secret_flag_name(rest, &context) {
172                    redact_next = true;
173                }
174            }
175            out.push(arg.to_string());
176        }
177        out
178    }
179
180    /// True when `name` would be treated as a secret field name by this
181    /// redactor: an exact `_secret`/`_SECRET` suffix, or an exact match
182    /// against a configured `secret_names` entry.
183    ///
184    /// Exposed for callers that must gate on a single *targeted* field name
185    /// (for example a CLI dot-path leaf) rather than redact a whole value —
186    /// [`Redactor::value`] only rewrites fields it finds while walking an
187    /// object, so a bare scalar pulled out from under its field name needs
188    /// this explicit check instead.
189    pub fn is_secret_name(&self, name: &str) -> bool {
190        RedactionContext::from_redactor(self).is_secret_key(name)
191    }
192}
193
194impl From<RedactionPolicy> for Redactor {
195    fn from(policy: RedactionPolicy) -> Self {
196        Self {
197            policy,
198            secret_names: Vec::new(),
199        }
200    }
201}
202
203/// Output options combining redaction and rendering style.
204#[derive(Clone, Debug, Default, PartialEq, Eq)]
205pub struct OutputOptions {
206    /// Redactor applied before rendering.
207    pub redaction: Redactor,
208    /// Rendering style for plain output only.
209    pub style: PlainStyle,
210}
211
212impl From<RedactionPolicy> for OutputOptions {
213    fn from(policy: RedactionPolicy) -> Self {
214        Self {
215            redaction: Redactor::from(policy),
216            style: PlainStyle::default(),
217        }
218    }
219}
220
221// ═══════════════════════════════════════════
222// Public API: Redaction & Utility
223// ═══════════════════════════════════════════
224
225/// Return a JSON value copy with default `_secret` redaction applied.
226pub fn redacted_value(value: &Value) -> Value {
227    Redactor::new().value(value)
228}
229
230/// Redact secret values out of a command line, using default options.
231///
232/// Returns `args` with the value of every `_secret`-suffixed long flag replaced
233/// by `***`, covering both `--flag=value` and `--flag value`. Use
234/// [`Redactor::argv`] for custom `secret_names` or a non-default policy.
235///
236/// Intended for CLIs that record their own invocation — startup diagnostics,
237/// audit trails, crash reports — where writing argv verbatim would put a
238/// credential in the log.
239pub fn redact_argv<S: AsRef<str>>(args: &[S]) -> Vec<String> {
240    Redactor::new().argv(args)
241}
242
243/// Redact secret components of a single URL string, using default options.
244///
245/// Returns `url` with its userinfo password and any `_secret`-suffixed query
246/// parameter values replaced by `***`.
247pub fn redact_url_secrets(url: &str) -> String {
248    Redactor::new().url(url)
249}
250
251// ═══════════════════════════════════════════
252// Secret Redaction
253// ═══════════════════════════════════════════
254
255/// The scalar every redacted span, value, and subtree is replaced with. Also
256/// the signal plain rendering reads to tell a hidden field from a live one.
257pub(crate) const REDACTED_MARKER: &str = "***";
258
259#[derive(Default)]
260pub(crate) struct RedactionContext {
261    secret_names: HashSet<String>,
262}
263
264impl RedactionContext {
265    fn from_redactor(redactor: &Redactor) -> Self {
266        let secret_names = redactor.secret_names.iter().cloned().collect();
267        Self { secret_names }
268    }
269
270    fn is_secret_key(&self, key: &str) -> bool {
271        key_has_secret_suffix(key) || self.secret_names.contains(key)
272    }
273}
274
275fn key_has_secret_suffix(key: &str) -> bool {
276    key.ends_with("_secret") || key.ends_with("_SECRET")
277}
278
279fn key_has_url_suffix(key: &str) -> bool {
280    key.ends_with("_url") || key.ends_with("_URL")
281}
282
283/// Whether a long flag's name is secret, normalizing the kebab-case flag
284/// spelling to the snake_case field spelling the convention is defined in.
285///
286/// Ungated: core argv redaction relies on it.
287pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
288    let normalized = flag_name.replace('-', "_");
289    context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
290}
291
292const MAX_DEPTH: usize = 256;
293const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
294
295fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
296    redact_secrets_with_context_depth(value, context, 0);
297}
298
299fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
300    if depth >= MAX_DEPTH {
301        *value = Value::String(MAX_DEPTH_MARKER.into());
302        return;
303    }
304    match value {
305        Value::Object(map) => {
306            let keys: Vec<String> = map.keys().cloned().collect();
307            for key in keys {
308                if context.is_secret_key(&key) {
309                    // A null secret is an *absent* secret. Masking it would
310                    // manufacture the appearance of a configured credential:
311                    // readers cannot tell `"***"`-because-set from
312                    // `"***"`-because-null, so a tool showing its own config
313                    // would report every unset secret as configured. Redaction
314                    // hides a value that exists; it does not invent one.
315                    if !map.get(&key).is_some_and(Value::is_null) {
316                        map.insert(key, Value::String(REDACTED_MARKER.into()));
317                    }
318                } else if key_has_url_suffix(&key) {
319                    if let Some(Value::String(s)) = map.get_mut(&key) {
320                        *s = redact_url_field_value(s, context);
321                    } else if let Some(v) = map.get_mut(&key) {
322                        redact_secrets_with_context_depth(v, context, depth + 1);
323                    }
324                } else if let Some(v) = map.get_mut(&key) {
325                    redact_secrets_with_context_depth(v, context, depth + 1);
326                }
327            }
328        }
329        Value::Array(arr) => {
330            for v in arr {
331                redact_secrets_with_context_depth(v, context, depth + 1);
332            }
333        }
334        _ => {}
335    }
336}
337
338/// Redact secret components of a single URL string, returning `Some(redacted)`
339/// when `s` is a processable URL, or `None` when it is not (so callers can keep
340/// the original). Only secret spans change; all other bytes are preserved.
341fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
342    // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
343    // The gate is scheme + no-whitespace only — NOT "parses as a URL library
344    // object". Span location below is purely byte-wise, so we never re-serialize
345    // the URL; adding a `url::Url::parse` gate here would diverge across
346    // languages (e.g. ports > 65535 or empty hosts that one library rejects and
347    // another accepts) and silently leak secrets in the values it rejects.
348    if !s.contains("://") || !is_single_url(s) {
349        return None;
350    }
351    let scheme_sep = s.find("://")?;
352    let scheme = &s[..scheme_sep];
353    let rest = &s[scheme_sep + 3..];
354
355    // Authority runs from after "://" to the first '/', '?', or '#'.
356    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
357    let authority = &rest[..auth_end];
358    let remainder = &rest[auth_end..];
359
360    let new_authority = redact_userinfo_password(authority);
361
362    // `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
363    // fragment off first and the query out of what is left.
364    let (before_fragment, fragment) = match remainder.split_once('#') {
365        Some((before, fragment)) => (before, Some(fragment)),
366        None => (remainder, None),
367    };
368    let new_before_fragment = match before_fragment.split_once('?') {
369        Some((path, query)) => format!("{path}?{}", redact_query(query, context)),
370        None => before_fragment.to_string(),
371    };
372    // A fragment gets the same treatment as the query: `k=v&k=v` after the '#'
373    // is exactly how an OAuth implicit-flow response hands back a token, so a
374    // secret-named fragment parameter must not survive where the identically
375    // named query parameter would not. A fragment that is not in that shape has
376    // no '=' in its segments and passes through byte-for-byte.
377    let new_fragment = match fragment {
378        Some(fragment) => format!("#{}", redact_query(fragment, context)),
379        None => String::new(),
380    };
381
382    Some(format!(
383        "{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
384    ))
385}
386
387fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
388    if let Some(redacted) = redact_url_in_str(s, context) {
389        return redacted;
390    }
391    let trimmed = s.trim();
392    if trimmed != s
393        && let Some(redacted) = redact_url_in_str(trimmed, context)
394    {
395        return redacted;
396    }
397    // Fail closed: a `_url` value we could not parse as a clean scheme-prefixed
398    // URL, yet which carries a credential sigil (`@` userinfo) or internal
399    // whitespace, is redacted wholesale rather than passed through. A schemeless
400    // connection string like `user:pass@host/db` has no scheme anchor for the
401    // surgical span logic above, so blanket redaction is the safe default.
402    if s.chars().any(char::is_whitespace) || s.contains('@') {
403        return REDACTED_MARKER.to_string();
404    }
405    s.to_string()
406}
407
408/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
409/// username. Authority without `@`, or userinfo without `:`, is unchanged.
410fn redact_userinfo_password(authority: &str) -> String {
411    let Some(at) = authority.rfind('@') else {
412        return authority.to_string();
413    };
414    let userinfo = &authority[..at];
415    match userinfo.find(':') {
416        Some(colon) => format!(
417            "{}:{REDACTED_MARKER}{}",
418            &authority[..colon],
419            &authority[at..]
420        ),
421        None => authority.to_string(),
422    }
423}
424
425/// Redact the values of secret-named query parameters, preserving raw bytes of
426/// every other segment (keys, benign values, encoding, ordering, separators).
427fn redact_query(query: &str, context: &RedactionContext) -> String {
428    query
429        .split('&')
430        .map(|segment| {
431            let Some(eq) = segment.find('=') else {
432                return segment.to_string();
433            };
434            let raw_key = &segment[..eq];
435            // Form-decode the name (`+` → space, percent-decode) for the check.
436            let name = url::form_urlencoded::parse(segment.as_bytes())
437                .next()
438                .map(|(k, _)| k.into_owned())
439                .unwrap_or_default();
440            if context.is_secret_key(&name) {
441                format!("{raw_key}={REDACTED_MARKER}")
442            } else {
443                segment.to_string()
444            }
445        })
446        .collect::<Vec<_>>()
447        .join("&")
448}
449
450/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
451/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
452/// a URL embedded in prose.
453fn is_single_url(s: &str) -> bool {
454    if s.bytes().any(|b| b.is_ascii_whitespace()) {
455        return false;
456    }
457    let bytes = s.as_bytes();
458    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
459        return false;
460    }
461    let mut i = 1;
462    while i < bytes.len() {
463        let c = bytes[i];
464        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
465            i += 1;
466        } else {
467            break;
468        }
469    }
470    s[i..].starts_with("://")
471}
472
473fn apply_redaction_policy_with_context(
474    value: &mut Value,
475    redaction_policy: RedactionPolicy,
476    context: &RedactionContext,
477) {
478    match redaction_policy {
479        RedactionPolicy::All => redact_secrets_with_context(value, context),
480        RedactionPolicy::TraceOnly => {
481            if let Value::Object(map) = value
482                && let Some(trace) = map.get_mut("trace")
483            {
484                redact_secrets_with_context(trace, context);
485            }
486        }
487        RedactionPolicy::Off => {}
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use serde_json::json;
495
496    // ── URL fragments carry secrets too ──────────
497
498    #[test]
499    fn url_fragment_params_redacted_like_query_params() {
500        assert_eq!(
501            redact_url_secrets("https://h/p?token_secret=QUERYLEAK#token_secret=FRAGLEAK"),
502            "https://h/p?token_secret=***#token_secret=***"
503        );
504    }
505
506    #[test]
507    fn url_fragment_params_redacted_without_a_query() {
508        // The OAuth implicit-flow shape: the credential exists only after '#'.
509        assert_eq!(
510            redact_url_secrets("https://h/cb#access_token_secret=abc&state=xyz"),
511            "https://h/cb#access_token_secret=***&state=xyz"
512        );
513    }
514
515    #[test]
516    fn url_fragment_without_params_is_preserved() {
517        for url in [
518            "https://h/p?a=1#section",
519            "https://h/p#",
520            "https://h/p#a/b?c",
521        ] {
522            assert_eq!(redact_url_secrets(url), url);
523        }
524    }
525
526    #[test]
527    fn url_fragment_honors_secret_names() {
528        let redactor = Redactor::new().secret_names(vec!["token".to_string()]);
529        assert_eq!(
530            redactor.url("https://h/cb#token=abc&page=2"),
531            "https://h/cb#token=***&page=2"
532        );
533    }
534
535    // ── One policy meaning across value, argv, url ──────────
536
537    fn scoped_value() -> Value {
538        json!({
539            "result": {"api_key_secret": "sk-result"},
540            "trace": {"api_key_secret": "sk-trace"}
541        })
542    }
543
544    fn argv() -> Vec<String> {
545        vec!["tool".to_string(), "--api-key-secret=sk-live".to_string()]
546    }
547
548    #[test]
549    fn all_policy_redacts_every_path() {
550        let redactor = Redactor::new().policy(RedactionPolicy::All);
551        assert_eq!(
552            redactor.value(&scoped_value()),
553            json!({
554                "result": {"api_key_secret": "***"},
555                "trace": {"api_key_secret": "***"}
556            })
557        );
558        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
559        assert_eq!(
560            redactor.url("https://u:pw@h/cb?token_secret=abc"),
561            "https://u:***@h/cb?token_secret=***"
562        );
563    }
564
565    #[test]
566    fn trace_only_scopes_a_value_but_redacts_argv_and_url_in_full() {
567        let redactor = Redactor::new().policy(RedactionPolicy::TraceOnly);
568        // Scoped input: only the `trace` half is scrubbed.
569        assert_eq!(
570            redactor.value(&scoped_value()),
571            json!({
572                "result": {"api_key_secret": "sk-result"},
573                "trace": {"api_key_secret": "***"}
574            })
575        );
576        // Unscoped input: a command line and a bare URL have no non-`trace`
577        // half to leave alone, so they are redacted like `All`.
578        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
579        assert_eq!(
580            redactor.url("https://u:pw@h/cb?token_secret=abc"),
581            "https://u:***@h/cb?token_secret=***"
582        );
583    }
584
585    #[test]
586    fn off_policy_disables_every_path() {
587        let redactor = Redactor::new().policy(RedactionPolicy::Off);
588        assert_eq!(redactor.value(&scoped_value()), scoped_value());
589        assert_eq!(redactor.argv(&argv()), argv());
590        assert_eq!(
591            redactor.url("https://u:pw@h/cb?token_secret=abc"),
592            "https://u:pw@h/cb?token_secret=abc"
593        );
594    }
595}