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