Skip to main content

nexo_core/agent/
redaction.rs

1//! Pre-persistence redaction for transcript content. Patterns are
2//! applied in declared order over the input string; each match is
3//! replaced with `[REDACTED:<label>]`. Built-in patterns target
4//! common credentials and home-directory paths. Operators can append
5//! custom patterns via config.
6//!
7//! The redactor is **destructive by design**: there is no reverse
8//! mapping. Once content is redacted and persisted, the secret is
9//! gone from disk. This is intentional — keeping a mapping would
10//! recreate the leak surface.
11
12use anyhow::Context;
13use nexo_config::types::transcripts::{RedactionConfig, RedactionPattern};
14use regex::Regex;
15
16/// Compiled redactor. Construct via `from_config` (returns disabled
17/// passthrough when `cfg.enabled == false`) or `disabled()`.
18#[derive(Debug)]
19pub struct Redactor {
20    rules: Vec<(Regex, String)>,
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct RedactionReport {
25    pub redacted_text: String,
26    /// (label, count) pairs in the order patterns were declared.
27    pub hits: Vec<(String, usize)>,
28}
29
30impl Redactor {
31    /// Disabled passthrough — `apply` returns the input unchanged with
32    /// empty hits.
33    pub fn disabled() -> Self {
34        Self { rules: Vec::new() }
35    }
36
37    pub fn from_config(cfg: &RedactionConfig) -> anyhow::Result<Self> {
38        if !cfg.enabled {
39            return Ok(Self::disabled());
40        }
41        let mut rules: Vec<(Regex, String)> = Vec::new();
42        if cfg.use_builtins {
43            for (label, src) in builtin_patterns() {
44                let re = Regex::new(src)
45                    .with_context(|| format!("invalid built-in pattern `{label}`"))?;
46                rules.push((re, label.to_string()));
47            }
48        }
49        for (idx, p) in cfg.extra_patterns.iter().enumerate() {
50            rules.push((compile_extra(idx, p)?, p.label.clone()));
51        }
52        Ok(Self { rules })
53    }
54
55    pub fn is_active(&self) -> bool {
56        !self.rules.is_empty()
57    }
58
59    /// Apply every loaded rule in declared order. Each pattern runs
60    /// over the **already-replaced** text, so a later pattern can
61    /// match tokens emitted by earlier `[REDACTED:<label>]` markers.
62    /// Built-in patterns are designed not to collide with the
63    /// `[REDACTED:...]` shape, but operators should keep the same
64    /// invariant when shipping `extra_patterns` — a pattern that
65    /// matches `REDACTED` will produce nested markers
66    /// (`[REDACTED:[REDACTED:foo]]`).
67    pub fn apply(&self, input: &str) -> RedactionReport {
68        if self.rules.is_empty() || input.is_empty() {
69            return RedactionReport {
70                redacted_text: input.to_string(),
71                hits: Vec::new(),
72            };
73        }
74        let mut text = input.to_string();
75        let mut hits: Vec<(String, usize)> = Vec::with_capacity(self.rules.len());
76        for (re, label) in &self.rules {
77            let count = re.find_iter(&text).count();
78            if count > 0 {
79                let replacement = format!("[REDACTED:{label}]");
80                text = re.replace_all(&text, replacement.as_str()).into_owned();
81                hits.push((label.clone(), count));
82            }
83        }
84        RedactionReport {
85            redacted_text: text,
86            hits,
87        }
88    }
89}
90
91fn compile_extra(idx: usize, p: &RedactionPattern) -> anyhow::Result<Regex> {
92    if p.label.trim().is_empty() {
93        anyhow::bail!("invalid extra_patterns[{idx}]: label cannot be empty");
94    }
95    Regex::new(&p.regex)
96        .with_context(|| format!("invalid extra_patterns[{idx}] (label `{}`)", p.label))
97}
98
99/// Built-in patterns. **Order matters** — more specific shapes go first
100/// so they don't get partially shadowed by generic catch-alls below.
101///
102/// **Mirror invariant:** `extensions/onepassword/src/redact.rs` ships
103/// the same pattern set (it can't depend on `nexo-core` because the
104/// extension lives in its own workspace). When you edit either list,
105/// edit both. CI grep enforces nothing — discipline is on the author.
106fn builtin_patterns() -> &'static [(&'static str, &'static str)] {
107    &[
108        ("bearer_jwt", r"Bearer\s+eyJ[\w-]+\.[\w-]+\.[\w-]+"),
109        ("anthropic_key", r"sk-ant-[A-Za-z0-9_\-]{20,}"),
110        ("openai_key", r"sk-[A-Za-z0-9]{20,}"),
111        ("aws_access_key", r"AKIA[0-9A-Z]{16}"),
112        // Note: a generic 40-char base64 pattern (would catch AWS
113        // secret keys) was deliberately omitted — too many false
114        // positives on legitimate hashes / opaque ids. Operators who
115        // need it can add it via `extra_patterns` scoped to their data.
116        // Floor at 64 hex chars: 32-char (MD5) and 40-char (SHA-1 /
117        // git SHA) hashes are too common as legitimate identifiers
118        // to redact by default. SHA-256 (64 chars) is the smallest
119        // shape where false positives become unlikely enough.
120        ("hex_token_64", r"\b[a-fA-F0-9]{64,}\b"),
121        ("home_path", r"/(?:home|Users)/[^\s/]+"),
122    ]
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn enabled_default() -> RedactionConfig {
130        RedactionConfig {
131            enabled: true,
132            use_builtins: true,
133            extra_patterns: Vec::new(),
134        }
135    }
136
137    #[test]
138    fn disabled_passthrough() {
139        let r = Redactor::disabled();
140        let out = r.apply("token sk-abc123def456ghi789jkl0");
141        assert_eq!(out.redacted_text, "token sk-abc123def456ghi789jkl0");
142        assert!(out.hits.is_empty());
143        assert!(!r.is_active());
144    }
145
146    #[test]
147    fn from_config_disabled_returns_passthrough() {
148        let r = Redactor::from_config(&RedactionConfig::default()).unwrap();
149        assert!(!r.is_active());
150        let out = r.apply("AKIAABCDEFGHIJKLMNOP");
151        assert_eq!(out.redacted_text, "AKIAABCDEFGHIJKLMNOP");
152    }
153
154    #[test]
155    fn empty_input_no_panic() {
156        let r = Redactor::from_config(&enabled_default()).unwrap();
157        let out = r.apply("");
158        assert_eq!(out.redacted_text, "");
159        assert!(out.hits.is_empty());
160    }
161
162    #[test]
163    fn redacts_openai_key() {
164        let r = Redactor::from_config(&enabled_default()).unwrap();
165        let out = r.apply("call openai with sk-abc123def456ghi789jkl0mn for billing");
166        assert!(out.redacted_text.contains("[REDACTED:openai_key]"));
167        assert!(!out.redacted_text.contains("sk-abc123"));
168        assert!(out.hits.iter().any(|(l, c)| l == "openai_key" && *c == 1));
169    }
170
171    #[test]
172    fn redacts_anthropic_key_before_generic_openai() {
173        let r = Redactor::from_config(&enabled_default()).unwrap();
174        let out = r.apply("key sk-ant-abcdefghijklmnopqrstuvwxyz123456");
175        assert!(out.redacted_text.contains("[REDACTED:anthropic_key]"));
176        assert!(!out.redacted_text.contains("sk-ant-"));
177    }
178
179    #[test]
180    fn redacts_aws_access_key() {
181        let r = Redactor::from_config(&enabled_default()).unwrap();
182        let out = r.apply("export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
183        assert!(out.redacted_text.contains("[REDACTED:aws_access_key]"));
184    }
185
186    #[test]
187    fn redacts_bearer_jwt() {
188        let r = Redactor::from_config(&enabled_default()).unwrap();
189        let out = r.apply("Authorization: Bearer eyJhbGc.eyJzdWI.dGVzdA");
190        assert!(out.redacted_text.contains("[REDACTED:bearer_jwt]"));
191    }
192
193    #[test]
194    fn redacts_hex_token_at_or_above_64_chars() {
195        let r = Redactor::from_config(&enabled_default()).unwrap();
196        // SHA-256 shape (64 chars) — should redact.
197        let out =
198            r.apply("digest: 5d41402abc4b2a76b9719d911017c5925d41402abc4b2a76b9719d911017c592");
199        assert!(out.redacted_text.contains("[REDACTED:hex_token_64]"));
200    }
201
202    #[test]
203    fn does_not_redact_md5_or_sha1_hashes() {
204        let r = Redactor::from_config(&enabled_default()).unwrap();
205        // MD5 (32) and git/SHA-1 (40) are too common as legitimate ids.
206        let md5 = "5d41402abc4b2a76b9719d911017c592";
207        let sha1 = "356a192b7913b04c54574d18c28d46e6395428ab";
208        let out = r.apply(&format!("md5={md5} sha1={sha1}"));
209        assert!(out.redacted_text.contains(md5), "md5 should pass through");
210        assert!(out.redacted_text.contains(sha1), "sha1 should pass through");
211    }
212
213    #[test]
214    fn redacts_home_path() {
215        let r = Redactor::from_config(&enabled_default()).unwrap();
216        let out = r.apply("error in /home/familia/chat/foo and /Users/alice/bar");
217        assert!(out.redacted_text.contains("[REDACTED:home_path]/chat/foo"));
218        assert!(out.redacted_text.contains("[REDACTED:home_path]/bar"));
219        let h = out.hits.iter().find(|(l, _)| l == "home_path").unwrap();
220        assert_eq!(h.1, 2);
221    }
222
223    #[test]
224    fn custom_pattern_applied_after_builtins() {
225        let cfg = RedactionConfig {
226            enabled: true,
227            use_builtins: true,
228            extra_patterns: vec![RedactionPattern {
229                regex: r"TENANT-\d+".into(),
230                label: "tenant_id".into(),
231            }],
232        };
233        let r = Redactor::from_config(&cfg).unwrap();
234        let out = r.apply("invoice for TENANT-42 on /home/x/file");
235        assert!(out.redacted_text.contains("[REDACTED:tenant_id]"));
236        assert!(out.redacted_text.contains("[REDACTED:home_path]"));
237    }
238
239    #[test]
240    fn invalid_extra_regex_errors_with_index() {
241        let cfg = RedactionConfig {
242            enabled: true,
243            use_builtins: false,
244            extra_patterns: vec![
245                RedactionPattern {
246                    regex: r"ok".into(),
247                    label: "ok".into(),
248                },
249                RedactionPattern {
250                    regex: r"[invalid".into(),
251                    label: "bad".into(),
252                },
253            ],
254        };
255        let err = Redactor::from_config(&cfg).expect_err("invalid regex");
256        let msg = err.to_string();
257        assert!(msg.contains("extra_patterns[1]"), "msg: {msg}");
258        assert!(msg.contains("bad"));
259    }
260
261    #[test]
262    fn empty_label_rejected() {
263        let cfg = RedactionConfig {
264            enabled: true,
265            use_builtins: false,
266            extra_patterns: vec![RedactionPattern {
267                regex: r"x".into(),
268                label: "  ".into(),
269            }],
270        };
271        let err = Redactor::from_config(&cfg).expect_err("empty label");
272        assert!(err.to_string().contains("label cannot be empty"));
273    }
274}