nexo_core/agent/
redaction.rs1use anyhow::Context;
13use nexo_config::types::transcripts::{RedactionConfig, RedactionPattern};
14use regex::Regex;
15
16#[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 pub hits: Vec<(String, usize)>,
28}
29
30impl Redactor {
31 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 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
99fn 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 ("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 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 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}