Skip to main content

kimetsu_brain/
redact.rs

1//! v0.4.5: secret redaction at ingest.
2//!
3//! Every memory.text and provenance snapshot that lands in brain.db
4//! passes through [`redact_secrets`] first. The pattern set catches
5//! the credential formats most likely to leak into an agent's tool
6//! output (OAuth bearers, API keys, JWTs, AWS access pairs, etc.)
7//! and replaces each match with a `[REDACTED:<kind>]` placeholder.
8//!
9//! Why redact at ingest, not later: brain.db is durable + content-
10//! addressable + replicated across user / project scopes. A leak
11//! that lands here lives forever and shows up in every retrieval
12//! capsule, agent.done summary, and proposal review screen. The
13//! cost of one false positive (a config string redacted) is much
14//! lower than the cost of one true positive sitting in
15//! `~/.kimetsu/brain.db` for years.
16//!
17//! Detection layers, applied in order:
18//!   1. Exact-prefix patterns: `sk-ant-`, `sk-`, `ghp_`, `gho_`,
19//!      `ghu_`, `ghs_`, `ghr_`, `github_pat_`, `xox[bopasr]-`,
20//!      `AKIA`/`ASIA` (AWS access key IDs), `eyJ` (JWT header).
21//!   2. Generic key/token assignments: `api[_-]?key\s*=\s*<value>`,
22//!      `token\s*[:=]\s*<value>`, `password\s*[:=]\s*<value>`,
23//!      `bearer\s+<value>` (the value must look secret — high
24//!      entropy, no spaces, length ≥ 12).
25//!   3. High-entropy fallback (off by default): would catch random
26//!      base64 strings of length ≥ 40 with entropy > 4.5 bits/char.
27//!      Deferred to v0.4.5.1 so we don't false-positive on hashes,
28//!      ulids, and content-addressable refs.
29//!
30//! The redaction is greedy + non-overlapping: a string matched by
31//! pattern (1) won't be re-scanned for pattern (2). [`RedactionResult`]
32//! returns both the redacted text AND a per-kind tally so callers
33//! (chat REPL banner, CLI warning, MCP response field) can surface
34//! "we found 2 secrets in this memory, kinds: aws_access_key,
35//! github_pat" without re-parsing the redacted text.
36
37use std::sync::OnceLock;
38
39use regex::Regex;
40use serde::Serialize;
41
42/// One detected secret. `kind` is a stable string id usable in
43/// telemetry and human-readable output.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct RedactedMatch {
46    pub kind: &'static str,
47    /// Byte offset within the ORIGINAL (pre-redaction) text where
48    /// the match started.
49    pub start: usize,
50    /// Length of the matched run (bytes, original text).
51    pub len: usize,
52}
53
54/// Output of [`redact_secrets`]. `text` is the redacted string;
55/// `matches` is the per-secret tally + locations.
56#[derive(Debug, Clone, Default, Serialize)]
57pub struct RedactionResult {
58    pub text: String,
59    pub matches: Vec<RedactedMatch>,
60}
61
62impl RedactionResult {
63    pub fn was_redacted(&self) -> bool {
64        !self.matches.is_empty()
65    }
66    /// One-liner like `"redacted 2 secrets: github_pat, openai_api_key"`.
67    /// Empty when nothing was redacted.
68    pub fn summary(&self) -> String {
69        if self.matches.is_empty() {
70            return String::new();
71        }
72        let mut kinds: Vec<&'static str> = self.matches.iter().map(|m| m.kind).collect();
73        kinds.sort_unstable();
74        kinds.dedup();
75        format!(
76            "redacted {} secret{}: {}",
77            self.matches.len(),
78            if self.matches.len() == 1 { "" } else { "s" },
79            kinds.join(", ")
80        )
81    }
82}
83
84/// Redact `text` against the bundled secret pattern set. Returns
85/// the redacted version + per-match tally. Allocates only when at
86/// least one match is found — for the common case (clean text) the
87/// result borrows nothing and matches is empty.
88pub fn redact_secrets(text: &str) -> RedactionResult {
89    merge_and_redact(text, collect_spans(text, patterns()))
90}
91
92/// v3.0 #4 (knowledge packs): scrub credentials AND PII (email / phone / SSN /
93/// credit-card) from a memory before it ships in a shareable pack. A published
94/// pack must never carry secrets or personal data. Fast (regex over the text;
95/// credit-card candidates are Luhn-gated to avoid false positives), no model.
96pub fn scrub_for_export(text: &str) -> RedactionResult {
97    let mut spans = collect_spans(text, patterns());
98    spans.extend(collect_pii_spans(text));
99    merge_and_redact(text, spans)
100}
101
102/// Collect raw `(start, end, kind)` regex matches for `patterns` (no validation
103/// or overlap resolution — that's [`merge_and_redact`]).
104fn collect_spans(text: &str, patterns: &[SecretPattern]) -> Vec<(usize, usize, &'static str)> {
105    let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
106    for pat in patterns {
107        for m in pat.regex.find_iter(text) {
108            spans.push((m.start(), m.end(), pat.kind));
109        }
110    }
111    spans
112}
113
114/// Sort spans, drop overlaps (earliest start wins; longest on a tie), then
115/// rebuild the redacted text + per-match tally. Empty spans → text unchanged.
116fn merge_and_redact(text: &str, mut spans: Vec<(usize, usize, &'static str)>) -> RedactionResult {
117    if spans.is_empty() {
118        return RedactionResult {
119            text: text.to_string(),
120            matches: Vec::new(),
121        };
122    }
123    spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
124    let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
125    let mut cursor = 0usize;
126    for (start, end, kind) in spans {
127        if start < cursor {
128            continue;
129        }
130        accepted.push((start, end, kind));
131        cursor = end;
132    }
133
134    let mut redacted = String::with_capacity(text.len());
135    let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
136    let mut last = 0usize;
137    for (start, end, kind) in accepted {
138        redacted.push_str(&text[last..start]);
139        redacted.push_str(&format!("[REDACTED:{kind}]"));
140        matches.push(RedactedMatch {
141            kind,
142            start,
143            len: end - start,
144        });
145        last = end;
146    }
147    redacted.push_str(&text[last..]);
148    RedactionResult {
149        text: redacted,
150        matches,
151    }
152}
153
154/// Collect PII spans. `credit_card` candidates are kept only when they have
155/// 13–19 digits AND pass the Luhn checksum, so ordinary long digit runs (ids,
156/// timestamps) aren't scrubbed.
157fn collect_pii_spans(text: &str) -> Vec<(usize, usize, &'static str)> {
158    let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
159    for pat in pii_patterns() {
160        for m in pat.regex.find_iter(text) {
161            if pat.kind == "credit_card" {
162                let digits: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
163                if !(13..=19).contains(&digits.len()) || !luhn_valid(&digits) {
164                    continue;
165                }
166            }
167            spans.push((m.start(), m.end(), pat.kind));
168        }
169    }
170    spans
171}
172
173/// Luhn (mod-10) checksum used to validate credit-card candidates.
174fn luhn_valid(digits: &str) -> bool {
175    if digits.is_empty() {
176        return false;
177    }
178    let mut sum = 0u32;
179    let mut double = false;
180    for c in digits.chars().rev() {
181        let mut d = match c.to_digit(10) {
182            Some(d) => d,
183            None => return false,
184        };
185        if double {
186            d *= 2;
187            if d > 9 {
188                d -= 9;
189            }
190        }
191        sum += d;
192        double = !double;
193    }
194    sum % 10 == 0
195}
196
197/// PII pattern set (kept high-precision to avoid scrubbing legit technical text).
198fn pii_patterns() -> &'static [SecretPattern] {
199    static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
200    CELL.get_or_init(|| {
201        vec![
202            SecretPattern {
203                kind: "email",
204                regex: Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap(),
205            },
206            SecretPattern {
207                kind: "ssn",
208                // US SSN `123-45-6789` (dashed only — bare 9-digit runs are too
209                // ambiguous to scrub safely).
210                regex: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
211            },
212            SecretPattern {
213                kind: "phone",
214                // North-American style: optional +country, area code (paren or
215                // bare), then 3-4 with a separator. Requires structure so it
216                // doesn't trip on arbitrary number runs.
217                regex: Regex::new(
218                    r"\b(?:\+?\d{1,3}[ .\-]?)?(?:\(\d{3}\)|\d{3})[ .\-]\d{3}[ .\-]\d{4}\b",
219                )
220                .unwrap(),
221            },
222            SecretPattern {
223                kind: "credit_card",
224                // Candidate 13–19 digit run (spaces/dashes allowed) — Luhn-gated
225                // in `collect_pii_spans`.
226                regex: Regex::new(r"\b\d(?:[ \-]?\d){12,18}\b").unwrap(),
227            },
228        ]
229    })
230}
231
232struct SecretPattern {
233    kind: &'static str,
234    regex: Regex,
235}
236
237fn patterns() -> &'static [SecretPattern] {
238    static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
239    CELL.get_or_init(|| {
240        // Each pattern's `kind` is stable telemetry-grade ID; the
241        // regex MUST be anchored at a unique prefix so we don't
242        // shadow normal source text. Use word boundaries where the
243        // pattern's prefix isn't already distinctive.
244        vec![
245            SecretPattern {
246                kind: "anthropic_oauth",
247                // Anthropic OAuth tokens: `sk-ant-` prefix + opaque tail.
248                // Tail is at least 32 chars of [A-Za-z0-9_-].
249                regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
250            },
251            SecretPattern {
252                kind: "openai_api_key",
253                // OpenAI keys: `sk-` (not `sk-ant-`) + 32+ chars.
254                // Negative lookahead isn't available in `regex`; we
255                // order anthropic_oauth FIRST so it claims those bytes
256                // before openai_api_key sees them.
257                regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
258            },
259            SecretPattern {
260                kind: "github_pat",
261                // Classic + fine-grained GitHub PATs.
262                // ghp_/gho_/ghu_/ghs_/ghr_ + 36 base62.
263                // github_pat_ + base62/underscore length 50+.
264                regex: Regex::new(
265                    r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
266                )
267                .unwrap(),
268            },
269            SecretPattern {
270                kind: "slack_token",
271                regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
272            },
273            SecretPattern {
274                kind: "aws_access_key",
275                // AKIA = long-lived, ASIA = STS temporary. Exactly 16
276                // uppercase alphanum after the prefix per AWS docs.
277                regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
278            },
279            SecretPattern {
280                kind: "jwt",
281                // Three base64url segments separated by dots; first
282                // starts with `eyJ` (base64url of `{"`). Cap total at
283                // 4096 chars so a runaway match doesn't blow the line.
284                regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
285            },
286            SecretPattern {
287                kind: "private_key_pem",
288                // PEM-encoded private key BEGIN line — match the whole
289                // block so the entire payload is wiped.
290                regex: Regex::new(
291                    r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
292                )
293                .unwrap(),
294            },
295            SecretPattern {
296                kind: "google_api_key",
297                // Google-style API keys: AIza + 35 char tail.
298                regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
299            },
300            SecretPattern {
301                kind: "url_credentials",
302                // Credentials embedded in a connection-string URI:
303                // `scheme://user:password@host`. Common in env dumps and
304                // `.env` snippets (DATABASE_URL=postgres://u:p@host, redis://,
305                // amqp://, mongodb://, ...). The generic `password=` rule does
306                // NOT match this shape, so without it the password lands in
307                // brain.db in cleartext. Redact the `scheme://user:pass@` run;
308                // the host stays readable.
309                regex: Regex::new(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@]+:[^\s:/@]{4,}@").unwrap(),
310            },
311            // Generic-assignment patterns. Lower-priority than the
312            // shape-specific ones above; ordered after them so e.g.
313            // `api_key=sk-...` claims the openai_api_key kind, not the
314            // generic_api_key kind.
315            SecretPattern {
316                kind: "generic_bearer",
317                // `Bearer <token>` in HTTP-style logs.
318                regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
319            },
320            SecretPattern {
321                kind: "generic_api_key",
322                // `api_key = "..."` / `api-key:"..."` / `api_key=...`.
323                // Captures both quoted and bare values; value must be
324                // ≥12 chars of secret-looking content.
325                regex: Regex::new(r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#).unwrap(),
326            },
327            SecretPattern {
328                kind: "generic_token",
329                regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
330            },
331            SecretPattern {
332                kind: "generic_password",
333                regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
334            },
335        ]
336    })
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn clean_text_round_trips_untouched() {
345        let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
346        let r = redact_secrets(raw);
347        assert!(!r.was_redacted());
348        assert_eq!(r.text, raw);
349        assert!(r.summary().is_empty());
350    }
351
352    // v3.0 #4: scrub_for_export adds PII on top of credentials.
353    #[test]
354    fn scrub_for_export_redacts_pii_and_credentials() {
355        let raw = "contact alice@example.com or 415-555-0142; ssn 123-45-6789; \
356                   key sk-ant-AbCdEfGhIjKlMnOpQrStUvWx0123456789";
357        let r = scrub_for_export(raw);
358        let kinds: std::collections::BTreeSet<&str> = r.matches.iter().map(|m| m.kind).collect();
359        assert!(kinds.contains("email"), "{r:?}");
360        assert!(kinds.contains("phone"), "{r:?}");
361        assert!(kinds.contains("ssn"), "{r:?}");
362        assert!(kinds.contains("anthropic_oauth"), "{r:?}");
363        assert!(!r.text.contains("alice@example.com"));
364        assert!(!r.text.contains("123-45-6789"));
365        assert!(!r.text.contains("sk-ant-"));
366    }
367
368    #[test]
369    fn scrub_for_export_luhn_gates_credit_cards() {
370        // 4242 4242 4242 4242 passes Luhn → scrubbed.
371        let good = scrub_for_export("card 4242 4242 4242 4242 on file");
372        assert!(
373            good.matches.iter().any(|m| m.kind == "credit_card"),
374            "valid card must scrub: {good:?}"
375        );
376        // A 16-digit run that FAILS Luhn (e.g. an id/timestamp concat) is kept.
377        let bad = scrub_for_export("trace 1234567890123456 step");
378        assert!(
379            !bad.matches.iter().any(|m| m.kind == "credit_card"),
380            "non-Luhn digit run must NOT scrub: {bad:?}"
381        );
382    }
383
384    #[test]
385    fn scrub_for_export_leaves_technical_text_alone() {
386        // Versions, hashes, ulids, ports — must not trip PII patterns.
387        let raw = "build with cargo 1.79; commit a1b2c3d4; port 8787; ulid 01K8YMJ448514TP6CPQ";
388        let r = scrub_for_export(raw);
389        assert!(!r.was_redacted(), "false positive: {r:?}");
390        assert_eq!(r.text, raw);
391    }
392
393    #[test]
394    fn luhn_check() {
395        assert!(luhn_valid("4242424242424242"));
396        assert!(!luhn_valid("4242424242424241"));
397        assert!(!luhn_valid(""));
398    }
399
400    #[test]
401    fn anthropic_oauth_token_is_redacted() {
402        let raw =
403            "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
404        let r = redact_secrets(raw);
405        assert!(r.was_redacted(), "{:?}", r);
406        assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
407        assert!(!r.text.contains("sk-ant-api03"));
408        assert_eq!(r.matches.len(), 1);
409        assert_eq!(r.matches[0].kind, "anthropic_oauth");
410    }
411
412    #[test]
413    fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
414        // Ensure pattern ordering: anthropic claims sk-ant-... first.
415        let raw =
416            "two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
417        let r = redact_secrets(raw);
418        let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
419        assert!(kinds.contains(&"openai_api_key"));
420        assert!(kinds.contains(&"anthropic_oauth"));
421        assert!(r.text.contains("[REDACTED:openai_api_key]"));
422        assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
423    }
424
425    #[test]
426    fn url_embedded_credentials_are_redacted() {
427        let raw = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.internal:5432/prod";
428        let r = redact_secrets(raw);
429        assert!(r.was_redacted(), "{:?}", r);
430        assert!(r.text.contains("[REDACTED:url_credentials]"));
431        assert!(!r.text.contains("S3cr3tP4ssw0rd"));
432        // Host stays readable; only the credentials run is wiped.
433        assert!(r.text.contains("db.internal:5432/prod"));
434
435        // Other common schemes are covered too.
436        let redis = redact_secrets("redis://default:An0therSecret123@cache:6379");
437        assert!(redis.text.contains("[REDACTED:url_credentials]"));
438        assert!(!redis.text.contains("An0therSecret123"));
439
440        // A URL without credentials must NOT be redacted.
441        let clean = redact_secrets("see https://example.com/path?x=1 for docs");
442        assert!(!clean.was_redacted(), "{:?}", clean);
443    }
444
445    #[test]
446    fn github_pat_classic_and_fine_grained_redacted() {
447        // Realistic lengths: classic PATs are `ghp_` + 36 base62;
448        // fine-grained PATs are `github_pat_` + ≥50 base62/underscore.
449        let raw = concat!(
450            "classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
451            "fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
452        );
453        let r = redact_secrets(raw);
454        let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
455        assert_eq!(
456            kinds.iter().filter(|k| **k == "github_pat").count(),
457            2,
458            "two github_pat matches expected; got matches: {:?}",
459            r.matches
460        );
461        assert!(!r.text.contains("ghp_abcdef"));
462        assert!(!r.text.contains("github_pat_11AAA"));
463    }
464
465    #[test]
466    fn slack_aws_jwt_pem_google_all_redact() {
467        let raw = concat!(
468            // Synthetic, non-functional token shaped to exercise the
469            // slack_token detector. The prefix is split so secret
470            // scanners don't flag the literal as a real credential.
471            "slack=",
472            "xoxb",
473            "-12345678-abcdefghijklmnop ",
474            "aws=AKIAIOSFODNN7EXAMPLE ",
475            "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
476            "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
477            "pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
478        );
479        let r = redact_secrets(raw);
480        let kinds: Vec<&'static str> = {
481            let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
482            k.sort_unstable();
483            k.dedup();
484            k
485        };
486        for expected in [
487            "aws_access_key",
488            "google_api_key",
489            "jwt",
490            "private_key_pem",
491            "slack_token",
492        ] {
493            assert!(
494                kinds.contains(&expected),
495                "missing kind {expected}: {kinds:?}"
496            );
497        }
498    }
499
500    #[test]
501    fn generic_assignments_match_only_with_secret_looking_value() {
502        // Should match: long enough value.
503        let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
504        let r_bad = redact_secrets(bad);
505        let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
506        assert!(kinds.contains(&"generic_api_key"));
507        assert!(kinds.contains(&"generic_token"));
508        assert!(kinds.contains(&"generic_password"));
509
510        // Should NOT match: too-short value or non-secret-looking.
511        let safe = "api_key = short  token: 12345  password = a";
512        let r_safe = redact_secrets(safe);
513        assert!(
514            r_safe.matches.is_empty(),
515            "short values should not trip generic patterns: {r_safe:?}"
516        );
517    }
518
519    #[test]
520    fn bearer_token_in_curl_log_is_redacted() {
521        let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
522        let r = redact_secrets(raw);
523        assert!(r.was_redacted());
524        assert_eq!(r.matches[0].kind, "generic_bearer");
525        assert!(r.text.contains("[REDACTED:generic_bearer]"));
526        assert!(!r.text.contains("abc123def456ghi789"));
527    }
528
529    #[test]
530    fn overlapping_matches_keep_first_only() {
531        // The same byte run could match two patterns (e.g. an
532        // openai_api_key inside a `Bearer ` prefix). The earlier
533        // pattern claims it; we don't double-redact.
534        let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
535        let r = redact_secrets(raw);
536        assert_eq!(
537            r.matches.len(),
538            1,
539            "non-overlapping rule should pick one: {r:?}"
540        );
541    }
542
543    #[test]
544    fn summary_lists_unique_kinds() {
545        let raw =
546            "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
547        let r = redact_secrets(raw);
548        let summary = r.summary();
549        assert!(summary.contains("github_pat"));
550        // Only one kind reported even though two matches happened.
551        assert!(summary.starts_with("redacted 2 secrets: github_pat"));
552    }
553
554    #[test]
555    fn match_offsets_point_into_original_text() {
556        let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
557        let r = redact_secrets(raw);
558        assert_eq!(r.matches.len(), 1);
559        let m = &r.matches[0];
560        // The matched bytes start at "sk-ant-..." and extend over the token.
561        let original_match = &raw[m.start..m.start + m.len];
562        assert!(original_match.starts_with("sk-ant-api03"));
563    }
564
565    #[test]
566    fn redaction_preserves_non_secret_surroundings() {
567        let raw = "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
568        let r = redact_secrets(raw);
569        assert!(r.text.starts_with("# Save to .env"));
570        assert!(r.text.ends_with("# Use it"));
571        assert!(
572            r.text
573                .contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]")
574        );
575    }
576}