diff --git a/.changeset/rust-pii-hashing.md b/.changeset/rust-pii-hashing.md new file mode 100644 index 0000000..6cc4d46 --- /dev/null +++ b/.changeset/rust-pii-hashing.md @@ -0,0 +1,25 @@ +--- +'@smooai/observability': minor +--- + +Rust: PII is now hashed rather than passed through. `pii::scrub_string` handled +credentials only — `Bearer`, `password=`, `token`/`api_key`/`secret=`, `sk-…` — +while the module doc claimed PII scrubbing, so an email or phone in a message, +breadcrumb or GenAI tool argument reached the wire intact. + +Emails, phone numbers and street addresses are now detected and replaced with a +keyed token: `a@b.com` → `[email:9f2a41c8]`. HMAC-SHA256, not a bare digest — +those values are a small enumerable space a rainbow table reverses in seconds — +and the org id is mixed into the message so identical PII hashes differently in +different orgs. The type prefix stays visible, which keeps "are these two spans +the same person?" answerable while storing nothing reversible. + +Credentials are still **dropped**, never hashed: a hash of a live token is a +token oracle. With no key configured (`SMOOAI_OBSERVABILITY_PII_HASH_KEY`, or +`pii::set_pii_hash_key`), personal identifiers are fully redacted rather than +hashed under a guessable key. + +New: `pii::scrub_string_for_org`, `pii::scrub_headers_for_org`, `pii::pii_token`, +`pii::PiiKind`, `pii::set_pii_hash_key`, `BootstrapEnv::pii_hash_key`. +`scrub_string` / `scrub_headers` keep their signatures and now scrub personal +identifiers too, under the empty org salt. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fd495df..b00616a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -516,6 +516,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "http" version = "1.4.0" @@ -958,7 +964,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1845,6 +1851,7 @@ dependencies = [ "async-trait", "backtrace", "bytes", + "hmac-sha256", "http", "once_cell", "opentelemetry", diff --git a/rust/observability/Cargo.toml b/rust/observability/Cargo.toml index b8cf23b..8ea5984 100644 --- a/rust/observability/Cargo.toml +++ b/rust/observability/Cargo.toml @@ -31,6 +31,10 @@ uuid = { version = "1", features = ["v4"] } backtrace = "0.3" regex = "1" once_cell = "1" +# Keyed PII hashing (`pii.rs`). Single-purpose, zero-dependency HMAC-SHA256 — +# chosen over the `hmac` + `sha2` + `digest` trait stack because this crate needs +# exactly one primitive and nothing generic over it. +hmac-sha256 = "1" async-trait = "0.1" http = "1" bytes = "1" diff --git a/rust/observability/README.md b/rust/observability/README.md index e7fd686..bd86d60 100644 --- a/rust/observability/README.md +++ b/rust/observability/README.md @@ -18,7 +18,8 @@ error-safe and degrades to a no-op (plus one stderr line) rather than panicking. | Stack capture (`backtrace`) | `stack-parser.ts` (string parse) | ✅ | | Scope / context (per-task) | `scope.ts` | ✅ | | Breadcrumb buffer (max 100) | `scope.ts` | ✅ | -| PII scrubbing | `pii.ts` | ✅ | +| PII scrubbing (credentials) | `pii.ts` | ✅ | +| PII **hashing** (email/phone/addr) | — (Rust only) | ✅ | | Batched webhook transport + retry | `transport.ts` | ✅ | | OTLP traces + metrics export | `otel/setup-otel-sdk.ts` | ✅ | | Per-request M2M auth (no staleness)| `otel/auth-injecting-exporter.ts` | ✅ | @@ -63,6 +64,37 @@ walkthrough (scope, error capture with cause chains, `with_scope`, metrics). re-mints on 401, so a rotated token is picked up on the next export with no exporter restart (the Rust analogue of the TS SMOODEV-1206 fix). +## PII scrubbing + +Two classes, handled differently: + +- **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, `sk-…`) + are **dropped**. A hash of a live token is still a token oracle. +- **Personal identifiers** (email, phone, street address) are **hashed**: + `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible, so you can see + *what kind* of value was there and that two spans carry the *same* one — + without ever seeing it. + +The hash is **HMAC-SHA256**, not a bare digest (emails and phones are a small +enumerable space a rainbow table reverses in seconds), and the org id is mixed +into the message so the same value hashes **differently in different orgs**. + +```rust +use smooai_observability::pii::{scrub_string_for_org, pii_token, PiiKind}; + +let scrubbed = scrub_string_for_org("mail a@b.com", org_id); // "mail [email:9f2a41c8]" +// Search: hash the typed term the same way and match the stored token. +let needle = pii_token(PiiKind::Email, "A@B.com", org_id); +``` + +Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by `bootstrap()`) or +`pii::set_pii_hash_key`. **With no key, personal identifiers are fully redacted** +(`[email:redacted]`) rather than hashed under a guessable one. + +⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks +correlation with every hash already stored — treat the key as permanent, and do +not reuse a secret that rotates on a schedule. + ## GenAI spans ```rust diff --git a/rust/observability/src/bootstrap.rs b/rust/observability/src/bootstrap.rs index 3a363b1..fe6eaab 100644 --- a/rust/observability/src/bootstrap.rs +++ b/rust/observability/src/bootstrap.rs @@ -20,6 +20,10 @@ //! - `SMOOAI_OBSERVABILITY_ENVIRONMENT` — default `STAGE` / `unknown`. //! - `SMOOAI_OBSERVABILITY_RELEASE` — default `GIT_SHA` / `dev`. //! - `SMOOAI_OBSERVABILITY_DISABLED` — `1`/`true` skips bootstrap entirely. +//! - `SMOOAI_OBSERVABILITY_PII_HASH_KEY` — HMAC key used to hash emails / +//! phones / addresses in scrubbed strings (see [`crate::pii`]). Unset means +//! those values are fully redacted instead of hashed. **Rotating it breaks +//! correlation with every hash already stored** — treat it as permanent. //! //! Never panics: missing config / init errors are logged to stderr and the SDK //! degrades gracefully. Idempotent: a second call returns the same handle. @@ -46,6 +50,7 @@ pub struct BootstrapEnv { pub environment: Option, pub release: Option, pub disabled: bool, + pub pii_hash_key: Option, } impl BootstrapEnv { @@ -69,6 +74,7 @@ impl BootstrapEnv { .ok() .or_else(|| env::var("GIT_SHA").ok()), disabled: truthy(env::var("SMOOAI_OBSERVABILITY_DISABLED").ok().as_deref()), + pii_hash_key: env::var("SMOOAI_OBSERVABILITY_PII_HASH_KEY").ok(), } } } @@ -105,6 +111,13 @@ pub async fn bootstrap_with(env: BootstrapEnv) -> BootstrapResult { } async fn build(env: BootstrapEnv) -> BootstrapResult { + // Before anything can emit: a scrubbed string written without this key + // redacts PII outright, so installing it late would silently produce a + // window of uncorrelatable spans rather than an error. + if let Some(key) = &env.pii_hash_key { + crate::pii::set_pii_hash_key(key.as_bytes()); + } + let service_name = env .service_name .clone() diff --git a/rust/observability/src/otel_capture.rs b/rust/observability/src/otel_capture.rs index 239582f..832b865 100644 --- a/rust/observability/src/otel_capture.rs +++ b/rust/observability/src/otel_capture.rs @@ -212,7 +212,17 @@ mod tests { client.capture_message("just fyi", Level::Info); provider.force_flush().ok(); - let spans = exporter.get_finished_spans().expect("exporter readable"); + // Filter to the spans THIS test produced: the provider is process-wide, + // so every other test in the binary that opens a span exports into the + // same in-memory exporter. Counting everything made this assertion fail + // as the suite grew (8 spans, not 2) while still catching what it is + // for — a double-report shows up as three `capture_` spans, not two. + let spans: Vec<_> = exporter + .get_finished_spans() + .expect("exporter readable") + .into_iter() + .filter(|s| s.name.starts_with("observability.capture_")) + .collect(); assert_eq!(spans.len(), 2, "one synthetic span per capture"); let exception_span = spans diff --git a/rust/observability/src/pii.rs b/rust/observability/src/pii.rs index 7a9479f..f856bf7 100644 --- a/rust/observability/src/pii.rs +++ b/rust/observability/src/pii.rs @@ -1,12 +1,41 @@ -//! PII scrubbing — applied to message strings, breadcrumb messages, and headers -//! before transport. Stays opinionated and minimal; tenants can extend in -//! `before_send`. +//! PII scrubbing — applied to message strings, breadcrumb messages, headers, +//! and (downstream) GenAI tool arguments/results before transport. //! -//! Direct port of the TS `pii.ts` patterns so Rust + TS scrub identically. +//! Two classes, handled differently on purpose: +//! +//! - **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, +//! `sk-…`) are **dropped**. A hash of a live token is still a token oracle, +//! and there is no correlation value in a secret. +//! - **Personal identifiers** (email, phone, street address) are **hashed**, +//! not dropped: `a@b.com` → `[email:9f2a41c8]`. That keeps the one question +//! worth asking — "are these two spans the same person?" — answerable while +//! storing nothing reversible. +//! +//! The hash is **HMAC-SHA256, keyed**, not a bare digest: emails and phone +//! numbers are a small enumerable space that a rainbow table reverses in +//! seconds. The org id is mixed into the HMAC message, so identical PII hashes +//! **differently in different orgs** — no cross-tenant correlation, matching +//! the ClickHouse org-isolation posture. +//! +//! **The key and the org salt are load-bearing and must not rotate casually.** +//! Rotating either silently breaks correlation with every previously stored +//! hash. Supply the key once at startup via `SMOOAI_OBSERVABILITY_PII_HASH_KEY` +//! (read by [`crate::bootstrap`]) or [`set_pii_hash_key`]. **With no key +//! configured, personal identifiers are fully redacted** (`[email:redacted]`) +//! rather than hashed under a guessable one — fail safe, never fail open. +//! +//! Pattern matching never catches everything. It fails safe, which is the right +//! default for data we persist from end-user conversations; tenants can extend +//! in `before_send`. +//! +//! Credential patterns are a direct port of the TS `pii.ts` patterns. The +//! hashing layer is Rust-only today — the other four SDKs still scrub +//! credentials only. -use once_cell::sync::Lazy; +use once_cell::sync::{Lazy, OnceCell}; use regex::Regex; use std::collections::BTreeMap; +use std::fmt::Write as _; struct PiiPattern { re: Regex, @@ -16,7 +45,9 @@ struct PiiPattern { replacement: Option<&'static str>, } -static PII_PATTERNS: Lazy> = Lazy::new(|| { +/// Credentials. Matched FIRST so a personal identifier sitting inside a secret +/// (`token=a@b.com`) is dropped with the secret rather than surviving as a hash. +static CREDENTIAL_PATTERNS: Lazy> = Lazy::new(|| { vec![ // Bearer tokens. PiiPattern { @@ -46,6 +77,68 @@ static PII_PATTERNS: Lazy> = Lazy::new(|| { ] }); +/// The class of personal identifier a match represents. Drives both the visible +/// prefix in the output token and the normalization applied before hashing, so +/// `(555) 555-0142` and `555-555-0142` correlate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PiiKind { + Email, + Phone, + Address, +} + +impl PiiKind { + /// The prefix that stays visible in the scrubbed output. + pub fn label(self) -> &'static str { + match self { + PiiKind::Email => "email", + PiiKind::Phone => "phone", + PiiKind::Address => "address", + } + } + + fn normalize(self, raw: &str) -> String { + match self { + PiiKind::Email => raw.trim().to_lowercase(), + // Digits only: formatting must not fork the hash. + PiiKind::Phone => raw.chars().filter(|c| c.is_ascii_digit()).collect(), + // Case-fold and collapse runs of whitespace. + PiiKind::Address => raw + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(), + } + } +} + +/// Personal identifiers — hashed, not dropped. Order matters only in that these +/// all run after [`CREDENTIAL_PATTERNS`]. +static PERSONAL_PATTERNS: Lazy> = Lazy::new(|| { + vec![ + ( + PiiKind::Email, + Regex::new(r"(?i)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b").unwrap(), + ), + // Phone: optional country code / area code, then the 3-4 local pair. + // A separator is REQUIRED so bare digit runs (ids, timestamps, amounts) + // don't get eaten. + ( + PiiKind::Phone, + Regex::new(r"(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])?\b\d{3}[ .-]\d{4}\b") + .unwrap(), + ), + // US-style street address: house number, 1-3 words, a street suffix. + ( + PiiKind::Address, + Regex::new( + r"(?i)\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,3}(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|highway|hwy|parkway|pkwy|square|sq)\b\.?", + ) + .unwrap(), + ), + ] +}); + static SECRET_KEY_VALUE_RE: Lazy = Lazy::new(|| Regex::new(r"^(.*?[:=]).*$").unwrap()); const SENSITIVE_HEADERS: &[&str] = &[ @@ -56,10 +149,76 @@ const SENSITIVE_HEADERS: &[&str] = &[ "x-auth-token", ]; -/// Scrub a free-form string. Idempotent enough for repeated calls. +/// Hex characters kept from the HMAC. Long enough that collisions are rare +/// across an org's traces, short enough to read in a span attribute. +const HASH_HEX_LEN: usize = 8; + +static PII_HASH_KEY: OnceCell> = OnceCell::new(); + +/// Install the process-wide HMAC key used to hash personal identifiers. +/// Idempotent and set-once: returns `false` if a key was already installed +/// (the existing key is kept — a mid-process rotation would silently fork +/// every correlation). [`crate::bootstrap`] calls this from +/// `SMOOAI_OBSERVABILITY_PII_HASH_KEY`. +pub fn set_pii_hash_key(key: impl Into>) -> bool { + let key = key.into(); + if key.is_empty() { + return false; + } + PII_HASH_KEY.set(key).is_ok() +} + +fn pii_hash_key() -> Option<&'static [u8]> { + PII_HASH_KEY.get().map(|k| k.as_slice()) +} + +/// Hash one known-personal value into its scrubbed token — the same token +/// [`scrub_string_for_org`] would have written. This is how a UI search box +/// finds stored hashes: hash the typed term with the same org and match. +/// +/// Returns `[:redacted]` when no key is installed. +pub fn pii_token(kind: PiiKind, raw: &str, org_id: &str) -> String { + token_with_key(kind, raw, org_id, pii_hash_key()) +} + +fn token_with_key(kind: PiiKind, raw: &str, org_id: &str, key: Option<&[u8]>) -> String { + let Some(key) = key else { + return format!("[{}:redacted]", kind.label()); + }; + let normalized = kind.normalize(raw); + // org_id in the HMAC message IS the per-org salt. The kind is in there too + // so a phone and an address that normalize alike can't collide. + let mut msg = Vec::with_capacity(org_id.len() + normalized.len() + 16); + msg.extend_from_slice(org_id.as_bytes()); + msg.push(0); + msg.extend_from_slice(kind.label().as_bytes()); + msg.push(0); + msg.extend_from_slice(normalized.as_bytes()); + + let mac = hmac_sha256::HMAC::mac(&msg, key); + let mut hex = String::with_capacity(HASH_HEX_LEN); + for byte in mac.iter().take(HASH_HEX_LEN / 2) { + let _ = write!(hex, "{byte:02x}"); + } + format!("[{}:{}]", kind.label(), hex) +} + +/// Scrub a free-form string with no org context — credentials dropped, personal +/// identifiers hashed under the empty org salt. Prefer +/// [`scrub_string_for_org`] wherever an org id is in hand, so hashes can't be +/// correlated across tenants. Idempotent enough for repeated calls. pub fn scrub_string(input: &str) -> String { + scrub_with_key(input, "", pii_hash_key()) +} + +/// Scrub a free-form string, salting personal-identifier hashes with `org_id`. +pub fn scrub_string_for_org(input: &str, org_id: &str) -> String { + scrub_with_key(input, org_id, pii_hash_key()) +} + +fn scrub_with_key(input: &str, org_id: &str, key: Option<&[u8]>) -> String { let mut out = input.to_string(); - for pattern in PII_PATTERNS.iter() { + for pattern in CREDENTIAL_PATTERNS.iter() { out = match pattern.replacement { Some(repl) => pattern.re.replace_all(&out, repl).into_owned(), None => { @@ -78,6 +237,13 @@ pub fn scrub_string(input: &str) -> String { } }; } + for (kind, re) in PERSONAL_PATTERNS.iter() { + out = re + .replace_all(&out, |caps: ®ex::Captures| { + token_with_key(*kind, &caps[0], org_id, key) + }) + .into_owned(); + } out } @@ -85,12 +251,20 @@ pub fn scrub_string(input: &str) -> String { /// values are run through [`scrub_string`]. Header-name comparison is /// case-insensitive. pub fn scrub_headers(headers: &BTreeMap) -> BTreeMap { + scrub_headers_for_org(headers, "") +} + +/// [`scrub_headers`] with an org salt for the personal-identifier hashes. +pub fn scrub_headers_for_org( + headers: &BTreeMap, + org_id: &str, +) -> BTreeMap { let mut out = BTreeMap::new(); for (k, v) in headers { if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) { out.insert(k.clone(), "[redacted]".to_string()); } else { - out.insert(k.clone(), scrub_string(v)); + out.insert(k.clone(), scrub_string_for_org(v, org_id)); } } out @@ -100,6 +274,18 @@ pub fn scrub_headers(headers: &BTreeMap) -> BTreeMap String { + scrub_with_key(input, org, Some(KEY)) + } + + // ---- credentials: dropped, never hashed ------------------------------- + #[test] fn scrubs_bearer_tokens() { let s = scrub_string("Authorization: Bearer abc.def-ghi_123"); @@ -128,6 +314,123 @@ mod tests { assert!(s.contains("sk-[redacted]"), "{s}"); } + #[test] + fn credentials_are_dropped_not_hashed() { + // A live token must never become a correlatable handle — hashing one + // still yields an oracle you can test candidate tokens against. + let s = scrub( + "Bearer abc.def-ghi_123 password=hunter2 sk-ABCDEFGHIJKLMNOPQRSTUVWX", + "org-1", + ); + assert!(s.contains("Bearer [redacted]"), "{s}"); + assert!(s.contains("password=[redacted]"), "{s}"); + assert!(s.contains("sk-[redacted]"), "{s}"); + assert!(!s.contains("[token:"), "{s}"); + assert!(!s.contains("[credential:"), "{s}"); + // Nothing hash-shaped anywhere: no `[:]` token was emitted. + assert!(!s.contains("[email:"), "{s}"); + assert!(!s.contains("[phone:"), "{s}"); + // And a personal identifier hiding inside a secret goes with it. + let s2 = scrub("token=a@b.com", "org-1"); + assert!(!s2.contains("a@b.com"), "{s2}"); + assert!(!s2.contains("[email:"), "{s2}"); + } + + // ---- personal identifiers: hashed, prefix preserved ------------------- + + #[test] + fn hashes_emails_keeping_the_type_prefix() { + let s = scrub("contact me at Alice@Example.com please", "org-1"); + assert!(!s.contains("Alice@Example.com"), "{s}"); + assert!(!s.to_lowercase().contains("alice@example.com"), "{s}"); + assert!(s.contains("[email:"), "{s}"); + assert!(s.starts_with("contact me at [email:"), "{s}"); + assert!(s.ends_with("] please"), "{s}"); + } + + #[test] + fn hashes_phone_numbers() { + for raw in ["555-0142", "(415) 555-0142", "+1 415-555-0142"] { + let s = scrub(&format!("call {raw} today"), "org-1"); + assert!(s.contains("[phone:"), "{raw} -> {s}"); + assert!(!s.contains("0142"), "{raw} -> {s}"); + } + } + + #[test] + fn hashes_street_addresses() { + let s = scrub("ship to 1600 Pennsylvania Ave, Washington", "org-1"); + assert!(s.contains("[address:"), "{s}"); + assert!(!s.contains("Pennsylvania"), "{s}"); + } + + #[test] + fn same_value_same_org_is_stable() { + let a = scrub("a@b.com", "org-1"); + let b = scrub("a@b.com", "org-1"); + assert_eq!(a, b); + // …and correlation survives formatting differences in phones. + assert_eq!( + scrub("(415) 555-0142", "org-1"), + scrub("415-555-0142", "org-1") + ); + } + + #[test] + fn same_value_different_org_hashes_differently() { + let a = scrub("a@b.com", "org-1"); + let b = scrub("a@b.com", "org-2"); + assert_ne!(a, b, "per-org salt missing: {a} == {b}"); + assert!(a.starts_with("[email:") && b.starts_with("[email:")); + } + + #[test] + fn different_key_hashes_differently() { + let a = scrub_with_key("a@b.com", "org-1", Some(KEY)); + let b = scrub_with_key("a@b.com", "org-1", Some(OTHER_KEY)); + assert_ne!(a, b, "hash is not keyed: {a} == {b}"); + } + + #[test] + fn no_key_redacts_rather_than_hashing() { + // Fail safe: an unkeyed digest of an email is rainbow-tabled instantly. + let s = scrub_with_key("a@b.com and 555-0142", "org-1", None); + assert!(s.contains("[email:redacted]"), "{s}"); + assert!(s.contains("[phone:redacted]"), "{s}"); + assert!(!s.contains("a@b.com"), "{s}"); + } + + #[test] + fn hash_is_short_hex() { + let t = token_with_key(PiiKind::Email, "a@b.com", "org-1", Some(KEY)); + let hex = t + .trim_start_matches("[email:") + .trim_end_matches(']') + .to_string(); + assert_eq!(hex.len(), HASH_HEX_LEN, "{t}"); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit()), "{t}"); + } + + #[test] + fn pii_token_matches_what_scrubbing_wrote() { + // The searchability contract: hashing a typed query term must produce + // exactly the token stored in the span. + let scrubbed = scrub("mail a@b.com now", "org-7"); + let term = token_with_key(PiiKind::Email, "A@B.com ", "org-7", Some(KEY)); + assert!(scrubbed.contains(&term), "{scrubbed} vs {term}"); + } + + #[test] + fn scrub_string_for_org_salts_by_org() { + // The public org-aware entry point behaves like the tested core even + // with no key installed (both orgs redact, neither leaks). + let a = scrub_string_for_org("a@b.com", "org-1"); + assert!(!a.contains("a@b.com"), "{a}"); + assert!(a.contains("[email:"), "{a}"); + } + + // ---- headers + non-matches ------------------------------------------- + #[test] fn redacts_sensitive_headers() { let mut h = BTreeMap::new(); @@ -162,4 +465,27 @@ mod tests { "nothing sensitive here" ); } + + #[test] + fn ordinary_numbers_are_not_mistaken_for_phones() { + // The phone pattern requires a separator before the last 4 digits; + // ids, versions, dates and amounts must survive intact. + for s in [ + "took 1234 ms", + "version 1.2.3", + "2026-08-15T12:00:00Z", + "total 1234.5678", + "trace 0af7651916cd43dd8448eb211c80319c", + ] { + assert_eq!(scrub(s, "org-1"), s, "false positive on {s}"); + } + } + + #[test] + fn tool_result_shape_from_traced_tool_is_scrubbed() { + // The exact string the sibling repo's `traced_tool.rs` test passes + // through untouched today. + let s = scrub("did the thing; email=a@b.com", "org-1"); + assert!(!s.contains("a@b.com"), "{s}"); + } }