use std::sync::OnceLock;
use regex::Regex;
pub const HEADLINE_MAX_CHARS: usize = 80;
pub const DETAIL_MAX_CHARS: usize = 120;
pub const PREVIEW_MAX_CHARS: usize = 200;
const BODY_SEPARATOR: &str = " — ";
pub const REDACTED: &str = "[redacted]";
pub const HIDDEN_DETAILS: &str = "[details hidden]";
const FALLBACK_HEADLINE: &str = "Codewhale";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationKind {
TurnComplete,
SubagentTerminal,
ApprovalNeeded,
InputNeeded,
ElevationNeeded,
ModelNotify,
}
impl NotificationKind {
#[must_use]
pub const fn allows_preview(self) -> bool {
matches!(self, Self::TurnComplete | Self::SubagentTerminal)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotificationPayload {
kind: NotificationKind,
headline: String,
detail: Option<String>,
preview: Option<String>,
}
impl NotificationPayload {
fn new(kind: NotificationKind, headline: &str, detail: Option<&str>) -> Self {
let headline = bounded(headline, HEADLINE_MAX_CHARS);
Self {
kind,
headline: if headline.is_empty() {
FALLBACK_HEADLINE.to_string()
} else {
headline
},
detail: detail
.map(|d| bounded(d, DETAIL_MAX_CHARS))
.filter(|d| !d.is_empty()),
preview: None,
}
}
#[must_use]
pub fn turn_complete(headline: &str) -> Self {
Self::new(NotificationKind::TurnComplete, headline, None)
}
#[must_use]
pub fn subagent_terminal(headline: &str, agent_id: &str) -> Self {
Self::new(NotificationKind::SubagentTerminal, headline, Some(agent_id))
}
#[must_use]
pub fn approval_needed(headline: &str, tool_name: &str) -> Self {
Self::new(NotificationKind::ApprovalNeeded, headline, Some(tool_name))
}
#[must_use]
pub fn input_needed(headline: &str) -> Self {
Self::new(NotificationKind::InputNeeded, headline, None)
}
#[must_use]
pub fn elevation_needed(headline: &str, tool_name: &str, reason: &str) -> Self {
let detail = if reason.trim().is_empty() {
tool_name.to_string()
} else {
format!("{tool_name}{BODY_SEPARATOR}{reason}")
};
Self::new(NotificationKind::ElevationNeeded, headline, Some(&detail))
}
#[must_use]
pub fn model_notify(title: &str, body: Option<&str>) -> Self {
Self::new(NotificationKind::ModelNotify, title, body)
}
#[must_use]
pub fn with_preview(mut self, preview: Option<&str>) -> Self {
if !self.kind.allows_preview() {
return self;
}
self.preview = preview
.map(|p| bounded(p, PREVIEW_MAX_CHARS))
.filter(|p| !p.is_empty());
self
}
#[must_use]
pub const fn kind(&self) -> NotificationKind {
self.kind
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
#[must_use]
pub fn headline(&self) -> &str {
&self.headline
}
#[must_use]
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
#[must_use]
pub fn preview(&self) -> Option<&str> {
self.preview.as_deref()
}
#[must_use]
pub fn body(&self) -> String {
let mut parts: Vec<&str> = Vec::with_capacity(2);
if let Some(detail) = self.detail() {
parts.push(detail);
}
if let Some(preview) = self.preview() {
parts.push(preview);
}
parts.join(BODY_SEPARATOR)
}
#[must_use]
pub fn render_inline(&self) -> String {
let body = self.body();
if body.is_empty() {
self.headline.clone()
} else {
format!("{}: {body}", self.headline)
}
}
}
fn bounded(text: &str, max_chars: usize) -> String {
truncate_chars(&sanitize_field(text), max_chars)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let take = max_chars.saturating_sub(3);
let mut out: String = text.chars().take(take).collect();
out.push_str("...");
out
}
#[must_use]
pub fn sanitize_field(text: &str) -> String {
super::ui::sanitize_stream_chunk(&strip_escape_sequences(text))
.lines()
.map(|line| {
let redacted = redact_structured(line.trim());
let redacted = redact_credentials(&redacted);
let redacted = redact_absolute_paths(&redacted);
redacted.split_whitespace().collect::<Vec<_>>().join(" ")
})
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn regex_cache<const N: usize>(
cell: &'static OnceLock<Vec<Regex>>,
patterns: [&str; N],
) -> &'static [Regex] {
cell.get_or_init(|| {
patterns
.iter()
.map(|p| Regex::new(p).expect("static notification redaction pattern must compile"))
.collect()
})
}
fn strip_escape_sequences(text: &str) -> String {
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
let res = regex_cache(
&PATTERNS,
[
r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?",
r"\x1b\[[0-9;?<>=]*[ -/]*[@-~]?",
r"\x1b.",
],
);
let mut out = text.to_string();
for re in res {
out = re.replace_all(&out, "").into_owned();
}
out
}
fn redact_structured(text: &str) -> String {
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
let res = regex_cache(
&PATTERNS,
[
r#"\{[^{}]*"[^"]*"\s*:[^{}]*\}"#,
r#"\[\s*(?:\{[^\[\]]*\}|"[^"]*"(?:\s*,\s*"[^"]*")*)\s*\]"#,
],
);
let mut out = text.to_string();
for re in res {
out = re.replace_all(&out, HIDDEN_DETAILS).into_owned();
}
let trimmed = out.trim();
if (trimmed.starts_with('{') || trimmed.starts_with('[')) && trimmed.contains('"') {
return HIDDEN_DETAILS.to_string();
}
out
}
fn redact_credentials(text: &str) -> String {
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
let res = regex_cache(
&PATTERNS,
[
r"-----BEGIN[A-Z ]*PRIVATE KEY-----",
r"(?i)\bsk-[A-Za-z0-9_\-]{8,}",
r"\bgh[pousr]_[A-Za-z0-9]{16,}",
r"\bAKIA[0-9A-Z]{12,}",
r"(?i)\bxox[baprse]-[A-Za-z0-9\-]{8,}",
r"\bAIza[0-9A-Za-z_\-]{20,}",
r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9_\-\.=+/]{8,}",
r"(?i)\b[A-Za-z0-9_\-]*(?:api[_\-]?key|secret|token|password|passwd|credential)[A-Za-z0-9_\-]*\s*[:=]\s*\S+",
r"\b[A-Za-z0-9_\-]{40,}\b",
],
);
let mut out = text.to_string();
for re in res {
out = re.replace_all(&out, REDACTED).into_owned();
}
out
}
fn redact_absolute_paths(text: &str) -> String {
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
let res = regex_cache(
&PATTERNS,
[
r"(^|[^A-Za-z0-9_:/\\])((?:/[A-Za-z0-9._~%+@\-]+){2,}/?)",
r"(^|[^A-Za-z0-9_])([A-Za-z]:[\\/](?:[^\\/:*?<>|\s]+[\\/]?)+)",
],
);
let mut out = text.to_string();
for re in res {
out = re
.replace_all(&out, |caps: ®ex::Captures<'_>| {
let lead = caps.get(1).map_or("", |m| m.as_str());
let path = caps.get(2).map_or("", |m| m.as_str());
let basename = path
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.next()
.unwrap_or_default();
if basename.is_empty() {
format!("{lead}…")
} else {
format!("{lead}…/{basename}")
}
})
.into_owned();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn every_kind(text: &str) -> Vec<NotificationPayload> {
vec![
NotificationPayload::turn_complete(text).with_preview(Some(text)),
NotificationPayload::subagent_terminal(text, text).with_preview(Some(text)),
NotificationPayload::approval_needed(text, text),
NotificationPayload::input_needed(text),
NotificationPayload::elevation_needed(text, text, text),
NotificationPayload::model_notify(text, Some(text)),
]
}
#[test]
fn every_kind_renders_within_declared_bounds() {
for payload in every_kind(&"word ".repeat(400)) {
assert!(
payload.headline().chars().count() <= HEADLINE_MAX_CHARS,
"{:?} headline unbounded: {}",
payload.kind(),
payload.headline()
);
assert!(
payload
.detail()
.is_none_or(|d| d.chars().count() <= DETAIL_MAX_CHARS),
"{:?} detail unbounded",
payload.kind()
);
assert!(
payload
.preview()
.is_none_or(|p| p.chars().count() <= PREVIEW_MAX_CHARS),
"{:?} preview unbounded",
payload.kind()
);
assert!(!payload.headline().is_empty());
}
}
#[test]
fn no_kind_leaks_credentials_paths_or_raw_tool_input() {
let hostile = concat!(
"sk-proj-abc123DEF456ghi789jkl012 ",
"wrote /Users/jane/clients/acme/contract.md ",
r#"input {"command":"curl -H 'Authorization: Bearer abcdef123456'","cwd":"/Users/jane"}"#,
);
for payload in every_kind(hostile) {
let rendered = payload.render_inline();
for leak in [
"sk-proj-abc123DEF456ghi789jkl012",
"/Users/jane",
"clients/acme",
"Bearer abcdef123456",
"\"command\"",
] {
assert!(
!rendered.contains(leak),
"{:?} leaked {leak:?}: {rendered}",
payload.kind()
);
}
}
}
#[test]
fn bounds_are_char_based_not_byte_based() {
let payload = NotificationPayload::turn_complete(&"日".repeat(200));
assert_eq!(payload.headline().chars().count(), HEADLINE_MAX_CHARS);
assert!(payload.headline().ends_with("..."));
}
#[test]
fn preview_is_kind_gated_not_caller_gated() {
let on = NotificationPayload::turn_complete("Turn complete")
.with_preview(Some("assistant said something"));
assert_eq!(on.preview(), Some("assistant said something"));
for payload in [
NotificationPayload::approval_needed("Approval needed", "bash"),
NotificationPayload::input_needed("Input needed"),
NotificationPayload::elevation_needed("Elevation needed", "bash", "network blocked"),
NotificationPayload::model_notify("Build done", None),
] {
let kind = payload.kind();
assert_eq!(
payload.with_preview(Some("leaky")).preview(),
None,
"{kind:?} must never carry assistant preview"
);
}
}
#[test]
fn approval_payload_carries_only_the_tool_name() {
let payload = NotificationPayload::approval_needed("Approval needed", "bash");
assert_eq!(payload.detail(), Some("bash"));
assert_eq!(payload.render_inline(), "Approval needed: bash");
}
#[test]
fn input_needed_body_is_empty() {
let payload = NotificationPayload::input_needed("Input needed");
assert_eq!(payload.detail(), None);
assert_eq!(payload.body(), "");
assert_eq!(payload.render_inline(), "Input needed");
}
#[test]
fn api_keys_are_redacted() {
let cases = [
"here is the key sk-proj-abc123DEF456ghi789jkl012",
"token ghp_0123456789abcdefghijABCDEFGHIJ0123",
"aws AKIAIOSFODNN7EXAMPLE",
"slack xoxb-1234567890-abcdefghij",
"google AIzaSyA1234567890abcdefghijklmnopqrstu",
"Authorization: Bearer not-a-real-token-0123456789abcdef",
"DEEPSEEK_API_KEY=sk-livekeyvalue1234567890",
"password: hunter2correctbattery",
"-----BEGIN RSA PRIVATE KEY-----",
];
for case in cases {
let payload = NotificationPayload::model_notify("Heads up", Some(case));
let body = payload.body();
assert!(
body.contains(REDACTED),
"expected redaction marker for {case:?}, got {body:?}"
);
for leak in [
"sk-proj-abc123DEF456ghi789jkl012",
"ghp_0123456789abcdefghijABCDEFGHIJ0123",
"AKIAIOSFODNN7EXAMPLE",
"xoxb-1234567890-abcdefghij",
"AIzaSyA1234567890abcdefghijklmnopqrstu",
"not-a-real-token-0123456789abcdef",
"sk-livekeyvalue1234567890",
"hunter2correctbattery",
"PRIVATE KEY",
] {
assert!(
!body.contains(leak),
"leaked {leak:?} from {case:?}: {body:?}"
);
}
}
}
#[test]
fn long_opaque_runs_are_treated_as_credential_shaped() {
let payload = NotificationPayload::turn_complete("Turn complete")
.with_preview(Some(&"a".repeat(500)));
assert_eq!(payload.preview(), Some(REDACTED));
}
#[test]
fn absolute_paths_are_reduced_to_basename() {
let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
"wrote /Users/jane/clients/acme/contract.md and C:\\Users\\jane\\secret\\plan.docx",
));
let preview = payload.preview().expect("preview should survive");
assert!(!preview.contains("/Users/jane"), "{preview}");
assert!(!preview.contains("clients/acme"), "{preview}");
assert!(!preview.contains("C:\\Users"), "{preview}");
assert!(preview.contains("…/contract.md"), "{preview}");
assert!(preview.contains("…/plan.docx"), "{preview}");
}
#[test]
fn urls_survive_path_redaction() {
let payload = NotificationPayload::model_notify(
"Deployed",
Some("live at https://app.example.com/status/ok"),
);
assert!(
payload.body().contains("https://app.example.com/status/ok"),
"{}",
payload.body()
);
}
#[test]
fn raw_tool_input_json_is_hidden() {
let raw =
r#"{"command":"curl -H 'Authorization: Bearer abc' https://x","cwd":"/Users/jane"}"#;
let payload = NotificationPayload::model_notify("Ran tool", Some(raw));
let body = payload.body();
assert_eq!(body, HIDDEN_DETAILS, "{body}");
}
#[test]
fn embedded_tool_json_is_hidden_inline() {
let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
r#"called write with {"path":"/etc/passwd"} then stopped"#,
));
let preview = payload.preview().expect("preview should survive");
assert!(preview.contains(HIDDEN_DETAILS), "{preview}");
assert!(!preview.contains("/etc/passwd"), "{preview}");
}
#[test]
fn control_bytes_and_newlines_are_collapsed() {
let payload = NotificationPayload::turn_complete("Turn\x1b[31m complete\n\nsecond line");
assert_eq!(payload.headline(), "Turn complete second line");
}
#[test]
fn empty_input_still_yields_a_headline() {
let payload = NotificationPayload::turn_complete(" \n ");
assert_eq!(payload.headline(), FALLBACK_HEADLINE);
}
}