返回 CodeWhale
payload.rs
根目录 / crates / tui / src / notify / payload.rs
1 //! Typed, bounded, redaction-aware desktop notification payloads (#4834).
2 //!
3 //! Before this module every desktop notification was a single free-form
4 //! `String` assembled at the call site and handed straight to the OS.
5 //! Notification Center on macOS (and the equivalent surface behind OSC 9 /
6 //! OSC 99 / OSC 777) is lock-screen capable: whatever happened to be in
7 //! that string — a pasted API key, an absolute path that names the user
8 //! and their client, the full shell command awaiting approval — was
9 //! rendered verbatim to anyone looking at the machine.
10 //!
11 //! [`NotificationPayload`] replaces the string with a closed set of event
12 //! kinds and three bounded fields:
13 //!
14 //! | field | max chars | contents |
15 //! |------------|-----------|-------------------------------------------|
16 //! | `headline` | 80 | localized event label (+ elapsed/cost) |
17 //! | `detail` | 120 | short, event-specific identifier |
18 //! | `preview` | 200 | assistant text — two kinds only |
19 //!
20 //! Every field passes through [`sanitize_field`], which strips control
21 //! bytes, collapses newlines and whitespace runs, and redacts credentials,
22 //! absolute local paths, and structured tool input. There is no
23 //! constructor that bypasses it, and `preview` is gated by
24 //! [`NotificationKind::allows_preview`] rather than by the caller.
25 //!
26 //! ## What each kind is allowed to show
27 //!
28 //! - [`NotificationKind::TurnComplete`] — the localized "Turn complete"
29 //! headline (plus elapsed/cost when `include_summary` is on) and a
30 //! preview of the assistant's own reply. Unchanged in spirit from the
31 //! previous behavior; now bounded and redacted.
32 //! - [`NotificationKind::SubagentTerminal`] — localized status headline,
33 //! the sub-agent's display name as detail, and a preview of the child's summary
34 //! line.
35 //! - [`NotificationKind::BackgroundTerminal`] — shell/task or mixed batch
36 //! headline and labels only. Commands, task prompts and errors stay in the UI;
37 //! this kind never carries a preview, even if an agent is in the batch.
38 //! - [`NotificationKind::ApprovalNeeded`] — headline plus the *tool name*.
39 //! Never the tool description or arguments: an approval prompt fires
40 //! precisely when those arguments are untrusted, and the previous code
41 //! put the full description on the lock screen.
42 //! - [`NotificationKind::InputNeeded`] — headline only. The question text
43 //! stays in the terminal.
44 //! - [`NotificationKind::ElevationNeeded`] — headline plus tool name and
45 //! the sandbox denial reason. The reason is engine-authored but not a
46 //! closed vocabulary, so it is sanitized like everything else.
47 //! - [`NotificationKind::ModelNotify`] — the model-callable `notify` tool.
48 //! Title and body are model-authored, so they are the least trusted
49 //! input here and carry no preview on top.
50
51 use std::sync::OnceLock;
52
53 use regex::Regex;
54
55 /// Maximum characters in the headline (the macOS subtitle line).
56 pub const HEADLINE_MAX_CHARS: usize = 80;
57 /// Maximum characters in the detail line.
58 pub const DETAIL_MAX_CHARS: usize = 120;
59 /// Maximum characters in the assistant preview.
60 pub const PREVIEW_MAX_CHARS: usize = 200;
61
62 /// Separator between the detail and preview segments of a rendered body.
63 const BODY_SEPARATOR: &str = " — ";
64
65 /// Placeholder substituted for anything that must never reach a
66 /// lock-screen-capable surface.
67 pub const REDACTED: &str = "[redacted]";
68 /// Placeholder substituted for structured tool input/output.
69 pub const HIDDEN_DETAILS: &str = "[details hidden]";
70
71 /// Fallback headline when sanitization leaves nothing behind.
72 const FALLBACK_HEADLINE: &str = "codewhale";
73
74 /// The closed set of events that can produce a desktop notification.
75 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
76 pub enum NotificationKind {
77 /// An agent turn finished successfully.
78 TurnComplete,
79 /// A sub-agent reached a terminal status (complete/failed/cancelled/…).
80 SubagentTerminal,
81 /// Shell/task completion, possibly batched with agents; no raw previews.
82 BackgroundTerminal,
83 /// A tool call is blocked waiting for the user to approve it.
84 ApprovalNeeded,
85 /// The agent asked the user a question and is blocked on the answer.
86 InputNeeded,
87 /// The sandbox denied an operation and the user must elevate.
88 ElevationNeeded,
89 /// The model called the `notify` tool.
90 ModelNotify,
91 }
92
93 impl NotificationKind {
94 /// Whether this kind may carry assistant preview text at all.
95 ///
96 /// Interactive prompts (approval/input/elevation) never do: the whole
97 /// point of the prompt is that the pending content is not yet trusted.
98 /// `ModelNotify` does not either — its body *is* model-authored text
99 /// and already occupies the body budget.
100 #[must_use]
101 pub const fn allows_preview(self) -> bool {
102 matches!(self, Self::TurnComplete | Self::SubagentTerminal)
103 }
104 }
105
106 /// A bounded, sanitized notification ready to hand to the OS.
107 ///
108 /// Construct via the per-kind constructors; every one of them sanitizes
109 /// and truncates. There is no way to smuggle raw text through.
110 #[derive(Debug, Clone, PartialEq, Eq)]
111 pub struct NotificationPayload {
112 kind: NotificationKind,
113 headline: String,
114 detail: Option<String>,
115 preview: Option<String>,
116 }
117
118 impl NotificationPayload {
119 fn new(kind: NotificationKind, headline: &str, detail: Option<&str>) -> Self {
120 let headline = bounded(headline, HEADLINE_MAX_CHARS);
121 Self {
122 kind,
123 headline: if headline.is_empty() {
124 FALLBACK_HEADLINE.to_string()
125 } else {
126 headline
127 },
128 detail: detail
129 .map(|d| bounded(d, DETAIL_MAX_CHARS))
130 .filter(|d| !d.is_empty()),
131 preview: None,
132 }
133 }
134
135 /// Turn finished. `headline` is the already-localized status line
136 /// (optionally carrying elapsed/cost when `include_summary` is on).
137 #[must_use]
138 pub fn turn_complete(headline: &str) -> Self {
139 Self::new(NotificationKind::TurnComplete, headline, None)
140 }
141
142 /// Sub-agent reached a terminal status. `detail` is the agent's display
143 /// name, the one every other surface shows.
144 #[must_use]
145 pub fn subagent_terminal(headline: &str, agent_name: &str) -> Self {
146 Self::new(
147 NotificationKind::SubagentTerminal,
148 headline,
149 Some(agent_name),
150 )
151 }
152
153 /// Finished background work. Callers supply only display labels, never
154 /// commands or task prompts; output and errors remain in the terminal.
155 #[must_use]
156 pub fn background_terminal(headline: &str, labels: &str) -> Self {
157 Self::new(NotificationKind::BackgroundTerminal, headline, Some(labels))
158 }
159
160 /// A tool call needs approval. Only the tool *name* is disclosed —
161 /// never the description or the arguments.
162 #[must_use]
163 pub fn approval_needed(headline: &str, tool_name: &str) -> Self {
164 Self::new(NotificationKind::ApprovalNeeded, headline, Some(tool_name))
165 }
166
167 /// The agent is blocked on a user answer. The question stays in the
168 /// terminal; the banner only says "come back".
169 #[must_use]
170 pub fn input_needed(headline: &str) -> Self {
171 Self::new(NotificationKind::InputNeeded, headline, None)
172 }
173
174 /// The sandbox denied an operation and the user must decide whether
175 /// to elevate.
176 #[must_use]
177 pub fn elevation_needed(headline: &str, tool_name: &str, reason: &str) -> Self {
178 let detail = if reason.trim().is_empty() {
179 tool_name.to_string()
180 } else {
181 format!("{tool_name}{BODY_SEPARATOR}{reason}")
182 };
183 Self::new(NotificationKind::ElevationNeeded, headline, Some(&detail))
184 }
185
186 /// The model-callable `notify` tool. Both fields are model-authored
187 /// and therefore fully sanitized like everything else.
188 #[must_use]
189 pub fn model_notify(title: &str, body: Option<&str>) -> Self {
190 Self::new(NotificationKind::ModelNotify, title, body)
191 }
192
193 /// Attach assistant preview text.
194 ///
195 /// A no-op unless the kind permits a preview. Callers cannot override
196 /// the kind policy — routing every preview through this method is
197 /// what makes "approval banners never show the command" a type-level
198 /// property instead of a call-site convention.
199 #[must_use]
200 pub fn with_preview(mut self, preview: Option<&str>) -> Self {
201 if !self.kind.allows_preview() {
202 return self;
203 }
204 self.preview = preview
205 .map(|p| bounded(p, PREVIEW_MAX_CHARS))
206 .filter(|p| !p.is_empty());
207 self
208 }
209
210 /// The event kind.
211 #[must_use]
212 pub const fn kind(&self) -> NotificationKind {
213 self.kind
214 }
215
216 /// Bounded, sanitized headline. Never empty.
217 ///
218 /// Only the macOS path reads this today — `display notification` is the
219 /// one backend that takes a separate subtitle, while the escape-sequence
220 /// backends send a single string via [`Self::body`]. Tests exercise it on
221 /// every platform, but `#[cfg(test)]` uses do not keep it alive in a
222 /// non-macOS release build, so the allow is scoped to exactly that case
223 /// rather than blanket-silencing dead_code on the accessor.
224 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
225 #[must_use]
226 pub fn headline(&self) -> &str {
227 &self.headline
228 }
229
230 /// Bounded, sanitized detail line, if the kind carries one.
231 #[must_use]
232 pub fn detail(&self) -> Option<&str> {
233 self.detail.as_deref()
234 }
235
236 /// Bounded, sanitized assistant preview. `None` unless the kind
237 /// allows one and the caller supplied non-empty text.
238 #[must_use]
239 pub fn preview(&self) -> Option<&str> {
240 self.preview.as_deref()
241 }
242
243 /// The body lines below the headline, joined for surfaces that take a
244 /// single body string (macOS Notification Center).
245 #[must_use]
246 pub fn body(&self) -> String {
247 let mut parts: Vec<&str> = Vec::with_capacity(2);
248 if let Some(detail) = self.detail() {
249 parts.push(detail);
250 }
251 if let Some(preview) = self.preview() {
252 parts.push(preview);
253 }
254 parts.join(BODY_SEPARATOR)
255 }
256
257 /// Single-line rendering for terminal escape protocols (OSC 9 / 99 /
258 /// 777), which cannot express a title/subtitle/body hierarchy.
259 #[must_use]
260 pub fn render_inline(&self) -> String {
261 let body = self.body();
262 if body.is_empty() {
263 self.headline.clone()
264 } else {
265 format!("{}: {body}", self.headline)
266 }
267 }
268 }
269
270 /// Sanitize then truncate to `max_chars`, appending an ellipsis when the
271 /// input was longer. Character-based, not byte-based, so multi-byte text
272 /// is never sliced mid-scalar.
273 fn bounded(text: &str, max_chars: usize) -> String {
274 truncate_chars(&sanitize_field(text), max_chars)
275 }
276
277 /// Truncate to `max_chars` characters *inclusive* of the `...` marker, so
278 /// the result never exceeds the declared bound.
279 fn truncate_chars(text: &str, max_chars: usize) -> String {
280 if text.chars().count() <= max_chars {
281 return text.to_string();
282 }
283 let take = max_chars.saturating_sub(3);
284 let mut out: String = text.chars().take(take).collect();
285 out.push_str("...");
286 out
287 }
288
289 /// Strip control bytes and redact anything that must not reach a
290 /// lock-screen-capable surface.
291 ///
292 /// Redaction runs per line so a credential cannot be hidden by wrapping,
293 /// then the lines are joined into one bounded field.
294 #[must_use]
295 pub fn sanitize_field(text: &str) -> String {
296 // Strip whole escape sequences *before* `sanitize_stream_chunk`, which
297 // drops the ESC byte but leaves the parameter tail behind — good
298 // enough for a terminal that will never re-interpret it, wrong for a
299 // notification banner that would render a literal `[31m`.
300 codewhale_secrets::sanitize::sanitize_text(&strip_escape_sequences(text))
301 .lines()
302 .map(|line| {
303 let redacted = redact_structured(line.trim());
304 let redacted = redact_credentials(&redacted);
305 let redacted = redact_absolute_paths(&redacted);
306 // Collapse whitespace runs so a bounded field cannot be
307 // padded out with invisible filler.
308 redacted.split_whitespace().collect::<Vec<_>>().join(" ")
309 })
310 .filter(|line| !line.is_empty())
311 .collect::<Vec<_>>()
312 .join(" ")
313 }
314
315 fn regex_cache<const N: usize>(
316 cell: &'static OnceLock<Vec<Regex>>,
317 patterns: [&str; N],
318 ) -> &'static [Regex] {
319 cell.get_or_init(|| {
320 patterns
321 .iter()
322 .map(|p| Regex::new(p).expect("static notification redaction pattern must compile"))
323 .collect()
324 })
325 }
326
327 /// Remove complete ANSI escape sequences (CSI, OSC, and single-character
328 /// escapes) so neither the sequence nor its parameter tail survives into a
329 /// notification field.
330 fn strip_escape_sequences(text: &str) -> String {
331 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
332 let res = regex_cache(
333 &PATTERNS,
334 [
335 // OSC: ESC ] … terminated by BEL or ST.
336 r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?",
337 // CSI: ESC [ params intermediates final.
338 r"\x1b\[[0-9;?<>=]*[ -/]*[@-~]?",
339 // Any remaining two-character escape.
340 r"\x1b.",
341 ],
342 );
343 let mut out = text.to_string();
344 for re in res {
345 out = re.replace_all(&out, "").into_owned();
346 }
347 out
348 }
349
350 /// Replace structured tool input/output (JSON objects and arrays) with a
351 /// placeholder. Raw tool arguments are the single most likely place for a
352 /// credential or a private path to appear verbatim.
353 fn redact_structured(text: &str) -> String {
354 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
355 let res = regex_cache(
356 &PATTERNS,
357 [
358 // A JSON-ish object: braces containing a `"key":` pair.
359 r#"\{[^{}]*"[^"]*"\s*:[^{}]*\}"#,
360 // A JSON-ish array of objects or quoted strings.
361 r#"\[\s*(?:\{[^\[\]]*\}|"[^"]*"(?:\s*,\s*"[^"]*")*)\s*\]"#,
362 ],
363 );
364 let mut out = text.to_string();
365 for re in res {
366 out = re.replace_all(&out, HIDDEN_DETAILS).into_owned();
367 }
368 // A field that is *entirely* a structured blob (possibly nested, so
369 // the brace-matching patterns above may not have fired) is dropped
370 // whole rather than partially rewritten.
371 let trimmed = out.trim();
372 if (trimmed.starts_with('{') || trimmed.starts_with('[')) && trimmed.contains('"') {
373 return HIDDEN_DETAILS.to_string();
374 }
375 out
376 }
377
378 /// Replace credential-shaped substrings with [`REDACTED`].
379 ///
380 /// This is deliberately over-eager: a notification banner is a glance
381 /// surface, so losing a long opaque identifier costs almost nothing while
382 /// leaking one is unrecoverable.
383 fn redact_credentials(text: &str) -> String {
384 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
385 let res = regex_cache(
386 &PATTERNS,
387 [
388 // PEM private key headers.
389 r"-----BEGIN[A-Z ]*PRIVATE KEY-----",
390 // Provider-prefixed keys: OpenAI/Anthropic/DeepSeek style
391 // `sk-…`, GitHub `ghp_/gho_/ghu_/ghs_/ghr_`, AWS `AKIA…`,
392 // Slack `xoxb-…`, Google `AIza…`.
393 r"(?i)\bsk-[A-Za-z0-9_\-]{8,}",
394 r"\bgh[pousr]_[A-Za-z0-9]{16,}",
395 r"\bAKIA[0-9A-Z]{12,}",
396 r"(?i)\bxox[baprse]-[A-Za-z0-9\-]{8,}",
397 r"\bAIza[0-9A-Za-z_\-]{20,}",
398 // `Bearer <token>` / `Basic <token>` authorization values.
399 r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9_\-\.=+/]{8,}",
400 // `NAME=value` / `name: value` where the name says secret.
401 r"(?i)\b[A-Za-z0-9_\-]*(?:api[_\-]?key|secret|token|password|passwd|credential)[A-Za-z0-9_\-]*\s*[:=]\s*\S+",
402 // Long opaque blobs with no word structure.
403 r"\b[A-Za-z0-9_\-]{40,}\b",
404 ],
405 );
406 let mut out = text.to_string();
407 for re in res {
408 out = re.replace_all(&out, REDACTED).into_owned();
409 }
410 out
411 }
412
413 /// Replace absolute local filesystem paths with `…/<basename>`.
414 ///
415 /// The identifying information in `/Users/jane/clients/acme/contract.md`
416 /// is the prefix, not the leaf: it names the account, the machine layout,
417 /// and often the customer. Keeping only the basename preserves the "which
418 /// file?" utility of the banner while the identifying prefix never
419 /// reaches the lock screen.
420 ///
421 /// URLs are left alone — the POSIX pattern only fires when the slash run
422 /// is not preceded by `:` or another `/`.
423 fn redact_absolute_paths(text: &str) -> String {
424 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
425 let res = regex_cache(
426 &PATTERNS,
427 [
428 // Windows UNC and extended paths reveal server/share names.
429 r"(^|[^A-Za-z0-9_\\])(\\\\(?:\?\\)?[^\\/\s]+\\[^\\/\s]+(?:\\[^\\/\s]*)*)",
430 // Leave the double slash after a URL scheme alone.
431 r"(^|[^A-Za-z0-9_:/])(//[^/\s]+/[^/\s]+(?:/[^/\s]*)*)",
432 // POSIX: at least two components so a bare `/tmp` or a lone
433 // slash in prose is not mangled.
434 r"(^|[^A-Za-z0-9_:/\\])((?:/[A-Za-z0-9._~%+@\-]+){2,}/?)",
435 // Windows drive-letter paths.
436 r"(^|[^A-Za-z0-9_])([A-Za-z]:[\\/](?:[^\\/:*?<>|\s]+[\\/]?)+)",
437 ],
438 );
439 let mut out = text.to_string();
440 for re in res {
441 out = re
442 .replace_all(&out, |caps: &regex::Captures<'_>| {
443 let lead = caps.get(1).map_or("", |m| m.as_str());
444 let path = caps.get(2).map_or("", |m| m.as_str());
445 let basename = path
446 .trim_end_matches(['/', '\\'])
447 .rsplit(['/', '\\'])
448 .next()
449 .unwrap_or_default();
450 if basename.is_empty() {
451 format!("{lead}…")
452 } else {
453 format!("{lead}…/{basename}")
454 }
455 })
456 .into_owned();
457 }
458 out
459 }
460
461 #[cfg(test)]
462 mod tests {
463 use super::*;
464
465 /// One payload of every kind, fed pathological input. This is the
466 /// enumeration test the issue asks for: if a new kind is added
467 /// without a bound, this array stops compiling or the assertion
468 /// fires.
469 fn every_kind(text: &str) -> Vec<NotificationPayload> {
470 vec![
471 NotificationPayload::turn_complete(text).with_preview(Some(text)),
472 NotificationPayload::subagent_terminal(text, text).with_preview(Some(text)),
473 NotificationPayload::background_terminal(text, text).with_preview(Some(text)),
474 NotificationPayload::approval_needed(text, text),
475 NotificationPayload::input_needed(text),
476 NotificationPayload::elevation_needed(text, text, text),
477 NotificationPayload::model_notify(text, Some(text)),
478 ]
479 }
480
481 #[test]
482 fn every_kind_renders_within_declared_bounds() {
483 for payload in every_kind(&"word ".repeat(400)) {
484 assert!(
485 payload.headline().chars().count() <= HEADLINE_MAX_CHARS,
486 "{:?} headline unbounded: {}",
487 payload.kind(),
488 payload.headline()
489 );
490 assert!(
491 payload
492 .detail()
493 .is_none_or(|d| d.chars().count() <= DETAIL_MAX_CHARS),
494 "{:?} detail unbounded",
495 payload.kind()
496 );
497 assert!(
498 payload
499 .preview()
500 .is_none_or(|p| p.chars().count() <= PREVIEW_MAX_CHARS),
501 "{:?} preview unbounded",
502 payload.kind()
503 );
504 assert!(!payload.headline().is_empty());
505 }
506 }
507
508 /// The redaction guarantee, asserted for *every* event kind rather
509 /// than one convenient constructor: a payload carrying an API key, an
510 /// absolute local path, and raw tool JSON must leak none of them.
511 #[test]
512 fn no_kind_leaks_credentials_paths_or_raw_tool_input() {
513 let hostile = concat!(
514 "sk-proj-abc123DEF456ghi789jkl012 ",
515 "wrote /Users/jane/clients/acme/contract.md ",
516 r#"input {"command":"curl -H 'Authorization: Bearer abcdef123456'","cwd":"/Users/jane"}"#,
517 );
518
519 for payload in every_kind(hostile) {
520 let rendered = payload.render_inline();
521 for leak in [
522 "sk-proj-abc123DEF456ghi789jkl012",
523 "/Users/jane",
524 "clients/acme",
525 "Bearer abcdef123456",
526 "\"command\"",
527 ] {
528 assert!(
529 !rendered.contains(leak),
530 "{:?} leaked {leak:?}: {rendered}",
531 payload.kind()
532 );
533 }
534 }
535 }
536
537 #[test]
538 fn bounds_are_char_based_not_byte_based() {
539 let payload = NotificationPayload::turn_complete(&"日".repeat(200));
540 assert_eq!(payload.headline().chars().count(), HEADLINE_MAX_CHARS);
541 assert!(payload.headline().ends_with("..."));
542 }
543
544 #[test]
545 fn preview_is_kind_gated_not_caller_gated() {
546 let on = NotificationPayload::turn_complete("Turn complete")
547 .with_preview(Some("assistant said something"));
548 assert_eq!(on.preview(), Some("assistant said something"));
549
550 // Prompt kinds refuse a preview no matter what the caller does.
551 for payload in [
552 NotificationPayload::background_terminal("Shell failed", "shell"),
553 NotificationPayload::approval_needed("Approval needed", "bash"),
554 NotificationPayload::input_needed("Input needed"),
555 NotificationPayload::elevation_needed("Elevation needed", "bash", "network blocked"),
556 NotificationPayload::model_notify("Build done", None),
557 ] {
558 let kind = payload.kind();
559 assert_eq!(
560 payload.with_preview(Some("leaky")).preview(),
561 None,
562 "{kind:?} must never carry assistant preview"
563 );
564 }
565 }
566
567 /// #4834: the approval banner used to render
568 /// `Approval needed: {tool} - {description}`, where the description
569 /// is the pending shell command. Only the tool name survives.
570 #[test]
571 fn approval_payload_carries_only_the_tool_name() {
572 let payload = NotificationPayload::approval_needed("Approval needed", "bash");
573 assert_eq!(payload.detail(), Some("bash"));
574 assert_eq!(payload.render_inline(), "Approval needed: bash");
575 }
576
577 #[test]
578 fn input_needed_body_is_empty() {
579 let payload = NotificationPayload::input_needed("Input needed");
580 assert_eq!(payload.detail(), None);
581 assert_eq!(payload.body(), "");
582 assert_eq!(payload.render_inline(), "Input needed");
583 }
584
585 #[test]
586 fn api_keys_are_redacted() {
587 let cases = [
588 "here is the key sk-proj-abc123DEF456ghi789jkl012",
589 "token ghp_0123456789abcdefghijABCDEFGHIJ0123",
590 "aws AKIAIOSFODNN7EXAMPLE",
591 "slack xoxb-1234567890-abcdefghij",
592 "google AIzaSyA1234567890abcdefghijklmnopqrstu",
593 // Deliberately NOT a JWT-shaped literal. The obvious fixture here
594 // is the textbook base64 JWT header, but that is exactly what
595 // secret scanners match: it fired a bearer-token incident on the
596 // first push of this branch and trains people to ignore the
597 // scanner. What this case actually exercises is the
598 // `Bearer <value>` authorization rule, which does not care about
599 // the value's shape.
600 "Authorization: Bearer not-a-real-token-0123456789abcdef",
601 "DEEPSEEK_API_KEY=sk-livekeyvalue1234567890",
602 "password: hunter2correctbattery",
603 "-----BEGIN RSA PRIVATE KEY-----",
604 ];
605 for case in cases {
606 let payload = NotificationPayload::model_notify("Heads up", Some(case));
607 let body = payload.body();
608 assert!(
609 body.contains(REDACTED),
610 "expected redaction marker for {case:?}, got {body:?}"
611 );
612 for leak in [
613 "sk-proj-abc123DEF456ghi789jkl012",
614 "ghp_0123456789abcdefghijABCDEFGHIJ0123",
615 "AKIAIOSFODNN7EXAMPLE",
616 "xoxb-1234567890-abcdefghij",
617 "AIzaSyA1234567890abcdefghijklmnopqrstu",
618 "not-a-real-token-0123456789abcdef",
619 "sk-livekeyvalue1234567890",
620 "hunter2correctbattery",
621 "PRIVATE KEY",
622 ] {
623 assert!(
624 !body.contains(leak),
625 "leaked {leak:?} from {case:?}: {body:?}"
626 );
627 }
628 }
629 }
630
631 /// Deliberate over-eagerness, pinned so it is a decision and not a
632 /// surprise: an unbroken 40+ character run has no word structure, so
633 /// it is treated as credential-shaped even when it is not.
634 #[test]
635 fn long_opaque_runs_are_treated_as_credential_shaped() {
636 let payload = NotificationPayload::turn_complete("Turn complete")
637 .with_preview(Some(&"a".repeat(500)));
638 assert_eq!(payload.preview(), Some(REDACTED));
639 }
640
641 #[test]
642 fn absolute_paths_are_reduced_to_basename() {
643 let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
644 "wrote /Users/jane/clients/acme/contract.md and C:\\Users\\jane\\secret\\plan.docx",
645 ));
646 let preview = payload.preview().expect("preview should survive");
647 assert!(!preview.contains("/Users/jane"), "{preview}");
648 assert!(!preview.contains("clients/acme"), "{preview}");
649 assert!(!preview.contains("C:\\Users"), "{preview}");
650 assert!(preview.contains("…/contract.md"), "{preview}");
651 assert!(preview.contains("…/plan.docx"), "{preview}");
652 }
653
654 #[test]
655 fn urls_survive_path_redaction() {
656 let payload = NotificationPayload::model_notify(
657 "Deployed",
658 Some("live at https://app.example.com/status/ok"),
659 );
660 assert!(
661 payload.body().contains("https://app.example.com/status/ok"),
662 "{}",
663 payload.body()
664 );
665 }
666
667 #[test]
668 fn raw_tool_input_json_is_hidden() {
669 let raw =
670 r#"{"command":"curl -H 'Authorization: Bearer abc' https://x","cwd":"/Users/jane"}"#;
671 let payload = NotificationPayload::model_notify("Ran tool", Some(raw));
672 let body = payload.body();
673 assert_eq!(body, HIDDEN_DETAILS, "{body}");
674 }
675
676 #[test]
677 fn embedded_tool_json_is_hidden_inline() {
678 let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
679 r#"called write with {"path":"/etc/passwd"} then stopped"#,
680 ));
681 let preview = payload.preview().expect("preview should survive");
682 assert!(preview.contains(HIDDEN_DETAILS), "{preview}");
683 assert!(!preview.contains("/etc/passwd"), "{preview}");
684 }
685
686 #[test]
687 fn control_bytes_and_newlines_are_collapsed() {
688 let payload = NotificationPayload::turn_complete("Turn\x1b[31m complete\n\nsecond line");
689 assert_eq!(payload.headline(), "Turn complete second line");
690 }
691
692 #[test]
693 fn empty_input_still_yields_a_headline() {
694 let payload = NotificationPayload::turn_complete(" \n ");
695 assert_eq!(payload.headline(), FALLBACK_HEADLINE);
696 }
697 #[test]
698 fn notification_payload_masks_unc_server_share_and_directories() {
699 for path in [
700 r"\\fileserver\share\clients\case.txt",
701 r"\\?\C:\Users\fixture\clients\case.txt",
702 "//fileserver/share/clients/case.txt",
703 ] {
704 let payload =
705 NotificationPayload::turn_complete("Turn complete").with_preview(Some(path));
706 let preview = payload.preview().expect("preview");
707 assert_eq!(preview, "…/case.txt");
708 assert!(!preview.contains("fileserver"));
709 assert!(!preview.contains("clients"));
710 }
711 let payload = NotificationPayload::turn_complete("Turn complete")
712 .with_preview(Some("see https://example.com/docs/page"));
713 assert_eq!(payload.preview(), Some("see https://example.com/docs/page"));
714 }
715 }
716
716 lines RUST