Skip to main content

codewhale_workflow/
redaction.rs

1//! Redaction for anything that can reach a log, a span, or a durable receipt.
2//!
3//! Two classes of content are stripped before text is allowed to leave the
4//! process — either onto a provider's wire or into a journal:
5//!
6//! 1. **Absolute paths.** `/Users/hunter/src/app`, `/home/x/...`, `C:\Users\…`
7//!    and `~/…` carry the operator's username, home directory, and machine
8//!    layout. A journal travels further than the machine that wrote it.
9//! 2. **Repo-relative paths.** `crates/tui/src/main.rs`, `./deploy.sh`,
10//!    `../../secret/notes.md` — and their escaped spellings, `crates\/tui\/…`
11//!    and `crates\\tui\\…`. An absolute path discloses the machine; a relative
12//!    one discloses the private tree's shape, which is exactly as much as the
13//!    reader of a routing summary at another provider needs to reconstruct it.
14//!    The rule is deliberately conservative — see [`looks_relative`] — because
15//!    the failure it must not trade for is mangling ordinary prose or a
16//!    `provider/model` label.
17//! 3. **Secret-shaped tokens.** Provider keys, bearer tokens, and
18//!    `SOMETHING_KEY=value` assignments. These have no business in a routing
19//!    summary and must never be persisted next to one.
20//!
21//! Redaction is *recorded*, not silent: [`Redaction::kinds`] names what was
22//! removed so a receipt can disclose the fact without disclosing the content.
23
24use std::collections::BTreeSet;
25
26/// A redaction kind, as it appears on a disclosure. These are stable labels —
27/// receipts persist them.
28pub const REDACTION_ABSOLUTE_PATH: &str = "absolute_path";
29pub const REDACTION_RELATIVE_PATH: &str = "relative_path";
30pub const REDACTION_SECRET: &str = "secret";
31
32/// Placeholder substituted for a removed absolute path.
33const PATH_PLACEHOLDER: &str = "<path>";
34/// Placeholder substituted for a removed secret-shaped token.
35const SECRET_PLACEHOLDER: &str = "<redacted>";
36
37/// Case-insensitive substrings that mark an identifier as secret-bearing.
38const SECRET_NAME_MARKERS: &[&str] = &[
39    "api_key",
40    "apikey",
41    "secret",
42    "token",
43    "password",
44    "passwd",
45    "credential",
46    "authorization",
47    "auth_token",
48    "access_key",
49    "private_key",
50    "session_key",
51];
52
53/// Prefixes that are themselves a credential, whatever they are attached to.
54///
55/// Every entry here must be *unambiguously* a credential prefix. A prefix that
56/// is also an ordinary English word — `bearer`, `asia` — belongs nowhere near
57/// this list: it would redact prose and, worse, would report a `secret`
58/// redaction kind on a receipt that removed nothing but a word. The AWS key ids
59/// that motivated `asia`/`akia` are handled by
60/// [`looks_like_aws_access_key`], which requires the full shape.
61const SECRET_VALUE_PREFIXES: &[&str] = &[
62    "sk-",
63    "sk_",
64    "ghp_",
65    "gho_",
66    "ghs_",
67    "github_pat_",
68    "xoxb-",
69    "xoxp-",
70    "xapp-",
71];
72
73/// HTTP authorization scheme keywords, in their canonical HTTP capitalization.
74///
75/// These are *never* the secret — the secret is the token that follows them.
76/// Redacting the keyword and stopping there is the failure this list exists to
77/// prevent: `Authorization: Bearer <token>` would keep the token and still
78/// claim on the receipt that a secret had been removed.
79///
80/// The capitalization is stored, not normalized away, because it carries
81/// evidence: `Bearer` is HTTP syntax and `bearer` is an English noun. See
82/// [`is_canonical_auth_scheme`].
83const AUTH_SCHEMES: &[&str] = &["Bearer", "Basic", "Digest", "Token"];
84
85/// The one scheme keyword whose canonical capitalization is, by itself, enough
86/// to treat the following token as a credential.
87///
88/// The asymmetry is deliberate and is the whole of the false-positive story.
89/// `Basic`, `Digest`, and `Token` are ordinary capitalized English words —
90/// "Basic auth is enabled", "Token holders vote", "Digest the results" — and
91/// arming on them would redact the next word of perfectly ordinary prose. `Bearer`
92/// capitalized is, in a technical corpus, the HTTP scheme essentially every
93/// time. So `Bearer qqq` loses `qqq` even though a three-letter lowercase token
94/// looks like nothing at all, while the other three need header context first.
95///
96/// The residual cost is stated plainly: `Bearer tokens are rotated weekly`
97/// redacts `tokens`. That is a capitalized-`Bearer` sentence, which is header
98/// syntax by shape; ordinary prose says "bearer", and lowercase never arms on
99/// its own.
100const SELF_EVIDENT_AUTH_SCHEME: &str = "Bearer";
101
102/// AWS access-key-id prefixes. Matched case-sensitively and only against the
103/// full 20-character shape, so the word `Asia` is prose and `ASIA…` is a key.
104const AWS_KEY_ID_PREFIXES: &[&str] = &[
105    "AKIA", "ASIA", "AGPA", "AIDA", "AROA", "ANPA", "ANVA", "ASCA", "ABIA", "ACCA",
106];
107
108/// Minimum length of an AWS access key id (`AKIA` + 16).
109const AWS_KEY_ID_LEN: usize = 20;
110
111/// Minimum length before a bare token is treated as a credential value.
112const CREDENTIAL_VALUE_MIN_LEN: usize = 16;
113
114/// The result of redacting one string.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Redaction {
117    text: String,
118    kinds: BTreeSet<String>,
119}
120
121impl Redaction {
122    /// The redacted text.
123    #[must_use]
124    pub fn text(&self) -> &str {
125        &self.text
126    }
127
128    /// Consume the redaction, yielding the redacted text.
129    #[must_use]
130    pub fn into_text(self) -> String {
131        self.text
132    }
133
134    /// Whether anything was removed.
135    #[must_use]
136    pub fn redacted(&self) -> bool {
137        !self.kinds.is_empty()
138    }
139
140    /// Which classes of content were removed — never the content itself.
141    #[must_use]
142    pub fn kinds(&self) -> Vec<String> {
143        self.kinds.iter().cloned().collect()
144    }
145}
146
147/// How strongly the preceding tokens claim that the *next* token is a
148/// credential.
149///
150/// A credential is routinely written as two or three tokens
151/// (`Authorization: Bearer <token>`), so the decision cannot be made per token.
152/// But the evidence for "a credential follows" is not uniform, and collapsing it
153/// to a boolean is what produces either a leak or a mangled sentence:
154///
155/// - `Authorization: Bearer qqq` — the credential is `qqq`, three lowercase
156///   letters, indistinguishable in shape from a word. Only the *context* says
157///   it is a secret, and the context says so unambiguously.
158/// - `authorization: bearer shares responsibility` — the same context markers,
159///   lowercase, and the next token is an English verb. Redacting `shares` would
160///   destroy a sentence and put a false `secret` kind on a receipt.
161///
162/// So the arming carries its own strength, and the shape test is applied only
163/// where the context is weak enough to need it.
164#[derive(Clone, Copy, PartialEq, Eq)]
165enum CredentialArm {
166    /// Nothing is expected; the next token is judged on its own.
167    None,
168    /// The next token is the credential only if it also *looks* like one.
169    /// Lowercase scheme words and bare `Authorization:` land here.
170    IfShaped,
171    /// The next token is the credential whatever it looks like. Reached by
172    /// canonical HTTP capitalization — `Authorization: Bearer …`, or a bare
173    /// `Bearer` — where the syntax alone settles it.
174    Certain,
175}
176
177impl CredentialArm {
178    const fn is_armed(self) -> bool {
179        !matches!(self, Self::None)
180    }
181}
182
183/// What one token resolved to, and whether it armed the *next* token.
184struct TokenOutcome {
185    /// `None` keeps the token verbatim.
186    text: Option<String>,
187    /// Whether — and how strongly — the following token carries the credential
188    /// this one introduced. `Authorization:` and a bare `Bearer` reveal nothing
189    /// themselves; the value after them is the whole secret.
190    arm: CredentialArm,
191}
192
193impl TokenOutcome {
194    const fn keep() -> Self {
195        Self {
196            text: None,
197            arm: CredentialArm::None,
198        }
199    }
200
201    const fn keep_and_arm(arm: CredentialArm) -> Self {
202        Self { text: None, arm }
203    }
204
205    fn replace(text: String) -> Self {
206        Self {
207            text: Some(text),
208            arm: CredentialArm::None,
209        }
210    }
211}
212
213/// Redact absolute paths and secret-shaped tokens from `input`.
214///
215/// Operates token-by-token over whitespace-free runs, which is enough for the
216/// bounded, already whitespace-collapsed text this crate transmits and keeps
217/// the rule set auditable without a regex dependency.
218///
219/// One piece of state crosses token boundaries, and it has to: a credential is
220/// routinely written as *two* tokens (`Authorization: Bearer <token>`), so a
221/// purely per-token rule either leaks the value or redacts the English word
222/// `bearer`. Carrying "the next token is the credential" forward — and *how
223/// certainly*, see `CredentialArm` — is what lets this do neither.
224#[must_use]
225pub fn redact_for_disclosure(input: &str) -> Redaction {
226    let mut kinds = BTreeSet::new();
227    let tokens: Vec<&str> = input.split(' ').collect();
228    let mut out: Vec<String> = Vec::with_capacity(tokens.len());
229    let mut arm = CredentialArm::None;
230
231    for (index, token) in tokens.iter().enumerate() {
232        if token.is_empty() {
233            out.push(String::new());
234            continue;
235        }
236        if arm.is_armed() {
237            // `Authorization: Bearer <token>` — the scheme keyword is not the
238            // secret, so it survives and the arming carries past it. A
239            // canonically capitalized keyword also *upgrades* the arming: the
240            // header name alone left the shape in doubt, and `Bearer` settles
241            // it.
242            if is_auth_scheme(token) {
243                if is_canonical_auth_scheme(token) {
244                    arm = CredentialArm::Certain;
245                }
246                out.push((*token).to_string());
247                continue;
248            }
249            // Weak arming still defers to shape, so an `authorization:` that
250            // introduces a sentence rather than a secret leaves the sentence
251            // intact — and leaves the receipt honest about having removed
252            // nothing.
253            if arm == CredentialArm::Certain || looks_like_credential_value(token) {
254                kinds.insert(REDACTION_SECRET.to_string());
255                out.push(SECRET_PLACEHOLDER.to_string());
256                arm = CredentialArm::None;
257                continue;
258            }
259            // Fall through: the token is judged on its own merits, and the
260            // arming state is replaced wholesale by `redact_token` below.
261        }
262
263        let next = tokens[index + 1..]
264            .iter()
265            .copied()
266            .find(|candidate| !candidate.is_empty());
267        let outcome = redact_token(token, next, &mut kinds);
268        arm = outcome.arm;
269        out.push(outcome.text.unwrap_or_else(|| (*token).to_string()));
270    }
271
272    Redaction {
273        text: out.join(" "),
274        kinds,
275    }
276}
277
278/// Redact one whitespace-free token, recording what was removed.
279///
280/// `next` is the following non-empty token, used only to decide whether a bare
281/// authorization scheme keyword is introducing a credential or is just a word.
282fn redact_token(token: &str, next: Option<&str>, kinds: &mut BTreeSet<String>) -> TokenOutcome {
283    // `NAME=value` / `NAME:value` — a secret-bearing name redacts its value and
284    // keeps the name, which is the useful half.
285    for separator in ['=', ':'] {
286        let Some((name, value)) = token.split_once(separator) else {
287            continue;
288        };
289        let lowered = name.to_ascii_lowercase();
290        let secret_name = SECRET_NAME_MARKERS
291            .iter()
292            .any(|marker| lowered.contains(marker));
293        if secret_name {
294            // `Authorization:Bearer` carries no secret of its own; the
295            // credential is the next token. Canonical capitalization on the
296            // scheme makes that certain; `authorization:bearer` does not.
297            if is_auth_scheme(value) {
298                return TokenOutcome::keep_and_arm(if is_canonical_auth_scheme(value) {
299                    CredentialArm::Certain
300                } else {
301                    CredentialArm::IfShaped
302                });
303            }
304            // A bare `Authorization:` only introduces a credential when one
305            // actually follows. `authorization: needed before merge` is a
306            // sentence, and redacting `needed` would report a secret that was
307            // never there. The arming stays weak here even when a scheme word
308            // follows — the scheme token itself decides, on the next pass,
309            // whether its capitalization upgrades it.
310            if value.is_empty() {
311                return if next
312                    .is_some_and(|next| is_auth_scheme(next) || looks_like_credential_value(next))
313                {
314                    TokenOutcome::keep_and_arm(CredentialArm::IfShaped)
315                } else {
316                    TokenOutcome::keep()
317                };
318            }
319            kinds.insert(REDACTION_SECRET.to_string());
320            return TokenOutcome::replace(format!("{name}{separator}{SECRET_PLACEHOLDER}"));
321        }
322        // A path assigned to a variable is still a path.
323        if let Some(kind) = classify_path(value) {
324            kinds.insert(kind.to_string());
325            return TokenOutcome::replace(format!("{name}{separator}{PATH_PLACEHOLDER}"));
326        }
327    }
328
329    // A bare `Bearer` in canonical HTTP capitalization introduces a credential
330    // on its own — that is what makes `Bearer qqq` lose `qqq`, which no shape
331    // test could ever do. Any other scheme keyword, and any other
332    // capitalization, needs something credential-shaped to actually follow:
333    // `bearer of bad news` and `Token holders vote` are prose.
334    if unwrap_token(token) == SELF_EVIDENT_AUTH_SCHEME {
335        return TokenOutcome::keep_and_arm(CredentialArm::Certain);
336    }
337    if is_auth_scheme(token) && next.is_some_and(looks_like_credential_value) {
338        return TokenOutcome::keep_and_arm(CredentialArm::IfShaped);
339    }
340
341    let lowered = token.to_ascii_lowercase();
342    if SECRET_VALUE_PREFIXES
343        .iter()
344        .any(|prefix| lowered.starts_with(prefix))
345        || looks_like_aws_access_key(token)
346    {
347        kinds.insert(REDACTION_SECRET.to_string());
348        return TokenOutcome::replace(SECRET_PLACEHOLDER.to_string());
349    }
350
351    if let Some(kind) = classify_path(token) {
352        kinds.insert(kind.to_string());
353        return TokenOutcome::replace(PATH_PLACEHOLDER.to_string());
354    }
355
356    TokenOutcome::keep()
357}
358
359/// Strip the punctuation a token picks up from surrounding prose.
360fn unwrap_token(token: &str) -> &str {
361    token
362        .trim_start_matches(['(', '[', '"', '\'', '<'])
363        .trim_end_matches([',', '.', ';', ':', ')', ']', '"', '\'', '>', '!', '?'])
364}
365
366/// Whether a token is an HTTP authorization scheme keyword (and nothing else),
367/// in any capitalization.
368fn is_auth_scheme(token: &str) -> bool {
369    let word = unwrap_token(token);
370    !word.is_empty()
371        && word.chars().all(|ch| ch.is_ascii_alphabetic())
372        && AUTH_SCHEMES
373            .iter()
374            .any(|scheme| scheme.eq_ignore_ascii_case(word))
375}
376
377/// Whether a token is a scheme keyword spelled the way HTTP spells it —
378/// `Bearer`, not `bearer` or `BEARER`.
379///
380/// This is the evidence that separates syntax from prose. It is a weak signal
381/// read honestly: capitalization is *suggestive*, so it upgrades an arming that
382/// header context already established, and stands alone only for
383/// [`SELF_EVIDENT_AUTH_SCHEME`].
384fn is_canonical_auth_scheme(token: &str) -> bool {
385    AUTH_SCHEMES.contains(&unwrap_token(token))
386}
387
388/// Whether a token has the shape of an opaque credential value.
389///
390/// Deliberately conservative: long, punctuation-free-ish, and mixing letters
391/// with digits. Ordinary words — however long — never qualify, which is what
392/// keeps `bearer of responsibility` out of the redactor.
393fn looks_like_credential_value(token: &str) -> bool {
394    let value = unwrap_token(token);
395    if value.chars().count() < CREDENTIAL_VALUE_MIN_LEN {
396        return false;
397    }
398    let mut has_digit = false;
399    let mut has_alpha = false;
400    for ch in value.chars() {
401        if ch.is_ascii_digit() {
402            has_digit = true;
403        } else if ch.is_ascii_alphabetic() {
404            has_alpha = true;
405        } else if !matches!(ch, '-' | '_' | '.' | '=' | '+' | '/' | '~') {
406            return false;
407        }
408    }
409    has_digit && has_alpha
410}
411
412/// Whether a token is an AWS access key id.
413///
414/// Requires the exact uppercase prefix *and* the full length, so `Asia` and
415/// `ASIA` (the continent, in prose or in a shouted heading) are left alone
416/// while `ASIA` + 16 key characters is removed.
417fn looks_like_aws_access_key(token: &str) -> bool {
418    let value = unwrap_token(token);
419    if value.len() < AWS_KEY_ID_LEN {
420        return false;
421    }
422    if !AWS_KEY_ID_PREFIXES
423        .iter()
424        .any(|prefix| value.starts_with(prefix))
425    {
426        return false;
427    }
428    value
429        .chars()
430        .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
431}
432
433/// Whether a token is an absolute or home-relative filesystem path.
434///
435/// A lone `/` or `~` is punctuation, not a path; a Windows drive letter needs
436/// its `:\` to count.
437fn looks_absolute(token: &str) -> bool {
438    let trimmed = token.trim_start_matches(['(', '[', '"', '\'']);
439    if trimmed.len() < 2 {
440        return false;
441    }
442    if let Some(rest) = trimmed.strip_prefix('/') {
443        return rest.starts_with(|ch: char| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_');
444    }
445    if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
446        return true;
447    }
448    if trimmed.starts_with("\\\\") {
449        return true;
450    }
451    let mut chars = trimmed.chars();
452    matches!(
453        (chars.next(), chars.next(), chars.next()),
454        (Some(drive), Some(':'), Some('\\' | '/')) if drive.is_ascii_alphabetic()
455    )
456}
457
458/// Which path kind a token is, if any — the single decision both the bare-token
459/// and the `NAME=value` rules ask, so an assignment can never be classified
460/// differently from the same value standing alone.
461///
462/// Escaped spellings are resolved first: a path that arrives inside a JSON
463/// string is written `crates\/tui\/src\/main.rs` or `crates\\tui\\src\\main.rs`,
464/// and reading only the literal characters would let either spelling through
465/// while the receipt claimed nothing was removed.
466///
467/// A URL is not a filesystem path and is left to the URL-bearing-input guard
468/// that already refuses such a task, so a token carrying a scheme is declined
469/// here rather than silently reclassified.
470fn classify_path(token: &str) -> Option<&'static str> {
471    let raw = trim_path_punctuation(token);
472    let unescaped = unescape_path(raw);
473    let candidate = trim_path_punctuation(&unescaped);
474    if candidate.contains("://") {
475        return None;
476    }
477    // Both spellings are asked, because unescaping is lossy in one direction
478    // that matters: a UNC share is *literally* `\\host\share`, and collapsing
479    // its leading pair would demote a machine-identifying path to a relative
480    // one.
481    if looks_absolute(raw) || looks_absolute(candidate) {
482        return Some(REDACTION_ABSOLUTE_PATH);
483    }
484    if looks_relative(candidate) {
485        return Some(REDACTION_RELATIVE_PATH);
486    }
487    None
488}
489
490/// Strip the punctuation a path picks up from surrounding prose, including the
491/// escaped quotes it picks up from a JSON string.
492fn trim_path_punctuation(token: &str) -> &str {
493    token
494        .trim_start_matches(['(', '[', '{', '"', '\'', '<', '`'])
495        .trim_end_matches([
496            ',', ';', ':', '.', ')', ']', '}', '"', '\'', '>', '`', '!', '?',
497        ])
498}
499
500/// Resolve `\/` and `\\` to the separator they escape, and drop escaped quotes.
501///
502/// Returns an owned string only because most tokens need no work; the borrowed
503/// fast path is not worth a second code path in a function this small.
504fn unescape_path(token: &str) -> String {
505    let mut out = String::with_capacity(token.len());
506    let mut chars = token.chars().peekable();
507    while let Some(ch) = chars.next() {
508        if ch != '\\' {
509            out.push(ch);
510            continue;
511        }
512        match chars.peek() {
513            Some('/') => {
514                out.push('/');
515                chars.next();
516            }
517            Some('\\') => {
518                out.push('\\');
519                chars.next();
520            }
521            Some('"') | Some('\'') => {
522                chars.next();
523            }
524            // A lone backslash is a separator in its own right (`C:\Users`).
525            _ => out.push('\\'),
526        }
527    }
528    out
529}
530
531/// Whether a token is a repo-relative filesystem path, judged conservatively.
532///
533/// Two signals, and nothing else, because the cost of a false positive here is
534/// paid in the operator's own summary: a redacted word plus a `relative_path`
535/// kind on a receipt that removed prose.
536///
537/// 1. **Explicit relative syntax** — `./x`, `../x`, and their backslash forms.
538///    Nothing but a path is spelled that way.
539/// 2. **A file extension on the last segment** of a multi-segment token: stem
540///    plus 1–8 ASCII *alphabetic* characters. The alphabetic requirement is
541///    what keeps `zai/glm-5.2` a model label rather than a file, and the
542///    multi-segment requirement is what keeps every bare `provider/model` pair
543///    — `deepseek/deepseek-v4-flash`, `anthropic/claude-opus-5` — intact.
544///
545/// The deliberate gap is an extension-less directory (`crates/tui/src`), which
546/// stays. Catching it would need a rule that cannot tell a directory from
547/// `read/write/execute`, and shredding prose to hide a directory name is the
548/// worse trade.
549fn looks_relative(candidate: &str) -> bool {
550    if !candidate.contains(['/', '\\']) {
551        return false;
552    }
553    let explicit_prefix = ["./", "../", ".\\", "..\\"]
554        .iter()
555        .any(|prefix| candidate.starts_with(prefix));
556    if explicit_prefix {
557        return true;
558    }
559    let trimmed = candidate.trim_end_matches(['/', '\\']);
560    let segments: Vec<&str> = trimmed.split(['/', '\\']).collect();
561    if segments.len() < 2 || segments.iter().any(|segment| segment.is_empty()) {
562        return false;
563    }
564    let last = segments[segments.len() - 1];
565    let Some((stem, extension)) = last.rsplit_once('.') else {
566        return false;
567    };
568    !stem.is_empty()
569        && (1..=8).contains(&extension.chars().count())
570        && extension.chars().all(|ch| ch.is_ascii_alphabetic())
571}
572
573/// Whether a string contains anything this module would redact.
574///
575/// Used by assertions and by durable-write guards that must fail loudly rather
576/// than persist a path or a key.
577#[must_use]
578#[cfg(test)]
579pub fn contains_redactable(input: &str) -> bool {
580    redact_for_disclosure(input).redacted()
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn absolute_paths_are_replaced_and_recorded() {
589        let redaction = redact_for_disclosure("fix /Users/hunter/src/app/main.rs and ~/notes.md");
590
591        assert!(!redaction.text().contains("/Users/"));
592        assert!(!redaction.text().contains("~/"));
593        assert!(redaction.text().contains(PATH_PLACEHOLDER));
594        assert!(redaction.redacted());
595        assert_eq!(redaction.kinds(), vec![REDACTION_ABSOLUTE_PATH.to_string()]);
596    }
597
598    #[test]
599    fn windows_paths_and_unc_shares_count_as_absolute() {
600        for token in ["C:\\Users\\hunter\\app", "\\\\share\\team\\notes"] {
601            let redaction = redact_for_disclosure(token);
602            assert!(redaction.redacted(), "{token} must be redacted");
603            assert_eq!(redaction.text(), PATH_PLACEHOLDER);
604        }
605    }
606
607    #[test]
608    fn secret_shaped_tokens_and_assignments_are_replaced() {
609        let redaction = redact_for_disclosure("use sk-live-abc123 and ZAI_API_KEY=zzz");
610
611        assert!(!redaction.text().contains("sk-live-abc123"));
612        assert!(!redaction.text().contains("zzz"));
613        assert!(
614            redaction.text().contains("ZAI_API_KEY=<redacted>"),
615            "the name stays, the value goes: {}",
616            redaction.text()
617        );
618        assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
619    }
620
621    /// The credential in an `Authorization` header is a *separate token* from
622    /// the header name and from the scheme keyword. Redacting only the keyword
623    /// leaves the secret in the clear while the receipt claims a secret was
624    /// removed — the exact failure this covers.
625    #[test]
626    fn a_multi_token_authorization_header_loses_its_credential() {
627        let credentials = [
628            ["sk", "-live-abc123def456"].concat(),
629            ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpX", "VCJ9"].concat(),
630            ["abcdef0123456789", "abcdef"].concat(),
631        ];
632        let headers = [
633            format!("Authorization: Bearer {}", credentials[0]),
634            format!("authorization: bearer {}", credentials[1]),
635            format!("-H Authorization:Bearer {}", credentials[2]),
636        ];
637        for header in headers {
638            let redaction = redact_for_disclosure(&header);
639            let text = redaction.text();
640            assert!(redaction.redacted(), "{header} must be redacted");
641            assert!(
642                text.contains(SECRET_PLACEHOLDER),
643                "{header} must carry a placeholder: {text}"
644            );
645            for leaked in &credentials {
646                assert!(!text.contains(leaked), "{leaked} leaked through: {text}");
647            }
648            assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
649        }
650    }
651
652    /// A bare scheme keyword introduces a credential only when a
653    /// credential-shaped token actually follows it.
654    #[test]
655    fn a_bare_bearer_token_is_removed_but_the_scheme_word_survives() {
656        let redaction = redact_for_disclosure("send Bearer 9f8e7d6c5b4a3f2e1d0c9b8a and retry");
657        let text = redaction.text();
658
659        assert!(
660            text.contains("Bearer"),
661            "the scheme keyword is not a secret"
662        );
663        assert!(!text.contains("9f8e7d6c5b4a3f2e1d0c9b8a"), "{text}");
664        assert!(text.ends_with("and retry"), "{text}");
665    }
666
667    /// Ordinary words that merely *look* like credential prefixes must survive,
668    /// and must not report a `secret` redaction kind. `Asia`, `bearer`, and any
669    /// identifier containing them are prose, not keys.
670    #[test]
671    fn ordinary_words_and_identifiers_are_not_mistaken_for_secrets() {
672        for text in [
673            "ship the Asia region rollout",
674            "ASIA is a continent, not a key",
675            "the bearer of this note may enter",
676            "authorization: needed before merge",
677            "rename bearer_token_header to auth_header_name",
678            "aws_region defaults to us-east-1",
679            "pk_display is a public identifier",
680        ] {
681            let redaction = redact_for_disclosure(text);
682            assert!(!redaction.redacted(), "{text} must survive: {redaction:?}");
683            assert_eq!(redaction.text(), text);
684        }
685    }
686
687    /// The adversarial prose set. Every line here is ordinary English that the
688    /// scheme/prefix rules could plausibly mistake for credential syntax, and
689    /// every one of them must come back byte-identical with an empty `kinds`.
690    ///
691    /// A false positive is not a harmless over-redaction: it mangles the routing
692    /// summary a human reads *and* writes `secret` onto a durable receipt that
693    /// removed nothing, which makes the disclosure a lie in the safe direction.
694    #[test]
695    fn adversarial_prose_survives_the_credential_state_machine() {
696        for text in [
697            // The lowercase scheme word, in every position that could arm it.
698            "bearer shares responsibility for the rollout",
699            "the bearer of bad news is rarely thanked",
700            "bearer",
701            "each bearer token header is rewritten downstream",
702            // Capitalized scheme words that are ordinary English. These are why
703            // canonical capitalization arms only for `Bearer`.
704            "Token holders vote on the proposal",
705            "Basic auth is enabled for the staging endpoint",
706            "Digest the results before the review",
707            // Weak header context introducing a sentence, not a secret.
708            "authorization: needed before merge",
709            "authorization: bearer shares responsibility",
710            // Identifiers and prefixes that resemble key material.
711            "variables like aws_region and pk_display stay readable",
712            "aws_ prefixed variables are documented in the runbook",
713            // `pk_` is a *public* key prefix and carries nothing; it is
714            // deliberately absent from SECRET_VALUE_PREFIXES. (`sk_` is not
715            // listed here because it genuinely is a secret prefix and redacting
716            // it is correct.)
717            "pk_ and pub_ are conventions, not values",
718            "asia and akia are four letter strings",
719            "the variables were renamed in the same commit",
720            // Long lowercase words are still words: shape alone must not fire.
721            "internationalization is spelled with eighteen letters",
722        ] {
723            let redaction = redact_for_disclosure(text);
724            assert!(
725                !redaction.redacted(),
726                "{text:?} is prose and must survive untouched: {redaction:?}"
727            );
728            assert_eq!(redaction.text(), text);
729            assert!(
730                redaction.kinds().is_empty(),
731                "{text:?} must not claim a redaction it did not make"
732            );
733        }
734    }
735
736    /// The adversarial credential set. A real credential is often short,
737    /// lowercase, punctuated, or otherwise shapeless — `Bearer qqq` is the
738    /// canonical example, and no shape test could ever catch it. Context has to.
739    #[test]
740    fn adversarial_credentials_lose_the_whole_value() {
741        for (text, leaked) in [
742            // The short, shapeless credential. This is the leak the evidence
743            // model exists to close.
744            ("Bearer qqq", "qqq"),
745            ("Authorization: Bearer qqq", "qqq"),
746            ("authorization: Bearer qqq", "qqq"),
747            // Punctuated and quoted header forms.
748            ("Authorization: Bearer qqq.", "qqq"),
749            ("-H \"Authorization: Bearer qqq\"", "qqq"),
750            ("Authorization:Bearer qqq", "qqq"),
751            // The scheme keyword may not absorb the redaction and leave the
752            // value behind.
753            ("send Bearer hunter2 now", "hunter2"),
754            (
755                "curl -H Authorization: Bearer sk-live-0000 -X POST",
756                "sk-live-0000",
757            ),
758        ] {
759            let redaction = redact_for_disclosure(text);
760            let redacted_text = redaction.text();
761            assert!(
762                redaction.redacted(),
763                "{text:?} carries a credential and must be redacted"
764            );
765            assert!(
766                !redacted_text.split(' ').any(|token| token == leaked
767                    || token.trim_end_matches(['.', ',', '"', '\'']) == leaked),
768                "{leaked:?} leaked through {text:?}: {redacted_text}"
769            );
770            assert!(
771                redacted_text.contains(SECRET_PLACEHOLDER),
772                "{text:?} must carry a placeholder: {redacted_text}"
773            );
774            assert!(
775                redaction.kinds().contains(&REDACTION_SECRET.to_string()),
776                "{text:?} must disclose the secret kind"
777            );
778            // The scheme keyword is not the secret and must still be readable,
779            // so a reader can tell *what* was removed.
780            assert!(
781                redacted_text.to_ascii_lowercase().contains("bearer"),
782                "the scheme keyword must survive: {redacted_text}"
783            );
784        }
785    }
786
787    /// The one documented false positive of the capitalization rule, pinned so
788    /// it stays deliberate rather than becoming a surprise. Capitalized `Bearer`
789    /// followed by a word is treated as header syntax; ordinary prose spells it
790    /// lowercase, which the test above covers.
791    #[test]
792    fn capitalized_bearer_arms_even_in_prose_and_that_is_the_known_cost() {
793        let redaction = redact_for_disclosure("Bearer tokens are rotated weekly");
794        assert_eq!(redaction.text(), "Bearer <redacted> are rotated weekly");
795
796        // The lowercase spelling — what prose actually uses — is untouched.
797        let prose = redact_for_disclosure("bearer tokens are rotated weekly");
798        assert!(!prose.redacted());
799    }
800
801    /// The real AWS shape still goes, so dropping the bare `asia`/`akia`
802    /// prefixes did not trade a false positive for a false negative.
803    #[test]
804    fn full_aws_access_key_ids_are_still_removed() {
805        for key in [
806            ["AKIA", "IOSFODNN7EXAMPLE"].concat(),
807            ["ASIA", "IOSFODNN7EXAMPLE"].concat(),
808        ] {
809            let redaction = redact_for_disclosure(&format!("creds {key} rotated"));
810            assert!(!redaction.text().contains(&key), "{}", redaction.text());
811            assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
812        }
813    }
814
815    #[test]
816    fn ordinary_prose_is_left_alone() {
817        let redaction = redact_for_disclosure("refactor the parser and add a regression test");
818        assert!(!redaction.redacted());
819        assert_eq!(
820            redaction.text(),
821            "refactor the parser and add a regression test"
822        );
823        assert!(redaction.kinds().is_empty());
824    }
825
826    /// A repo-relative path discloses the private tree's shape to whatever
827    /// provider the routing summary reaches, and is persisted next to it. Every
828    /// spelling one arrives in — bare, quoted, JSON-escaped with `\/` or `\\`,
829    /// explicitly relative, assigned to a name, trailing prose punctuation —
830    /// must lose the path *and* say so on the receipt.
831    #[test]
832    fn repo_relative_paths_are_redacted_in_every_spelling_and_disclosed() {
833        for token in [
834            "crates/tui/src/main.rs",
835            "src/lib.rs",
836            "web/lib/deploy-preflight.test.ts",
837            ".github/workflows/web.yml",
838            "crates\\tui\\src\\main.rs",
839            "crates\\/tui\\/src\\/main.rs",
840            "\\\"crates/tui/src/main.rs\\\"",
841            "\"crates/tui/src/main.rs\"",
842            "(crates/tui/src/main.rs)",
843            "./deploy.sh",
844            "../../secret/notes.md",
845            "..\\secret\\notes.md",
846        ] {
847            let redaction = redact_for_disclosure(token);
848            assert!(redaction.redacted(), "{token} must be redacted");
849            assert!(
850                !redaction.text().contains("main.rs")
851                    && !redaction.text().contains("notes.md")
852                    && !redaction.text().contains("deploy"),
853                "{token} leaked: {}",
854                redaction.text()
855            );
856            assert!(
857                redaction
858                    .kinds()
859                    .contains(&REDACTION_RELATIVE_PATH.to_string()),
860                "{token} must disclose the relative_path kind: {:?}",
861                redaction.kinds()
862            );
863        }
864
865        // In a sentence, and as a value: the name survives, the path does not.
866        let sentence = redact_for_disclosure("patch crates/tui/src/main.rs, then path=src/lib.rs");
867        assert_eq!(
868            sentence.text(),
869            "patch <path> then path=<path>",
870            "prose keeps its shape around the placeholder"
871        );
872        assert_eq!(
873            sentence.kinds(),
874            vec![REDACTION_RELATIVE_PATH.to_string()],
875            "one kind, honestly reported"
876        );
877    }
878
879    /// The other half of the same rule: it must not shred ordinary prose,
880    /// `provider/model` labels, or bare punctuation, because a false positive
881    /// here costs the operator their own summary *and* puts a redaction kind on
882    /// a receipt that removed nothing.
883    #[test]
884    fn prose_labels_and_bare_punctuation_are_not_paths() {
885        for token in [
886            // Provider/model labels — the exact shape a Fleet receipt carries.
887            "deepseek/deepseek-v4-flash",
888            "zai/glm-5.2",
889            "anthropic/claude-opus-5",
890            "workspace/glm-pair",
891            // Prose that happens to carry separators.
892            "a/b",
893            "and/or",
894            "read/write/execute",
895            "TODO/FIXME",
896            "provider/model/reasoning",
897            // Bare punctuation and non-paths.
898            "/",
899            "~",
900            "5:30",
901            "v0.9.2",
902            // A URL is not a filesystem path; the URL-bearing-input guard owns
903            // it, and reclassifying it here would be a silent behavior change.
904            "https://example.test/a/b.rs",
905        ] {
906            let redaction = redact_for_disclosure(token);
907            assert!(!redaction.redacted(), "{token} must not be redacted");
908            assert_eq!(redaction.text(), token, "{token} must survive verbatim");
909        }
910    }
911
912    /// The documented gap, pinned so it stays a decision rather than a
913    /// surprise: an extension-less directory survives, because no rule can
914    /// separate it from `read/write/execute` without shredding prose.
915    #[test]
916    fn an_extension_less_directory_is_the_known_residual() {
917        let redaction = redact_for_disclosure("look in crates/tui/src");
918        assert!(!redaction.redacted());
919    }
920
921    #[test]
922    fn contains_redactable_matches_the_redactor() {
923        assert!(contains_redactable("/Users/hunter"));
924        assert!(contains_redactable("token=abc"));
925        assert!(!contains_redactable("land a fix in the workflow crate"));
926    }
927}