| 1 | //! Bounded, redacted, read-only transcript peek for the dashboard (#4397). |
| 2 | //! |
| 3 | //! The dashboard needs to show *what a saved session was about* without |
| 4 | //! becoming a second transcript viewer. Three constraints shape this module, |
| 5 | //! and all three are enforced here rather than in the client: |
| 6 | //! |
| 7 | //! * **Bounded on the wire.** The peek carries at most |
| 8 | //! [`MAX_PEEK_ENTRIES`] entries of at most [`MAX_ENTRY_CHARS`] characters. |
| 9 | //! Doing this client-side would mean shipping a multi-megabyte transcript to |
| 10 | //! a browser in order to throw most of it away. |
| 11 | //! * **Redacted.** A saved transcript can contain an API key a user pasted, a |
| 12 | //! token echoed by a tool, an `Authorization` header in a curl command. The |
| 13 | //! dashboard is reachable over a LAN; a peek pane is not the place to |
| 14 | //! re-emit those. |
| 15 | //! * **Read-only and non-live.** A peek is a recording. It carries no turn |
| 16 | //! status, no "running" flag, nothing that could be mistaken for live state. |
| 17 | //! Live state comes from a resumed thread and its SSE stream, never from |
| 18 | //! here — see `runtime_web/app.mjs`'s reply-target rules. |
| 19 | //! |
| 20 | //! Tool payloads are summarised to a kind and a size, never inlined: a tool |
| 21 | //! result is the most likely place for both bulk and secrets. |
| 22 | |
| 23 | use serde::Serialize; |
| 24 | |
| 25 | use crate::models::ContentBlock; |
| 26 | use crate::session_manager::SavedSession; |
| 27 | |
| 28 | /// Most entries a peek carries. The dashboard shows a tail, so this is "the |
| 29 | /// last N exchanges", which is what a peek is for. |
| 30 | pub const MAX_PEEK_ENTRIES: usize = 12; |
| 31 | |
| 32 | /// Longest text any single entry carries. |
| 33 | pub const MAX_ENTRY_CHARS: usize = 400; |
| 34 | |
| 35 | /// What produced an entry. Deliberately coarse — the peek is not a |
| 36 | /// reconstruction of the turn structure. |
| 37 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] |
| 38 | #[serde(rename_all = "snake_case")] |
| 39 | pub enum PeekEntryKind { |
| 40 | User, |
| 41 | Assistant, |
| 42 | Reasoning, |
| 43 | /// A tool call or result, summarised. Never the payload itself. |
| 44 | Tool, |
| 45 | } |
| 46 | |
| 47 | /// One bounded line of recorded conversation. |
| 48 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 49 | pub struct PeekEntry { |
| 50 | pub kind: PeekEntryKind, |
| 51 | /// Already bounded and redacted. Safe to render as text — and only as |
| 52 | /// text; the client inserts it with `textContent`, never `innerHTML`. |
| 53 | pub text: String, |
| 54 | /// True when [`Self::text`] was shortened. |
| 55 | pub truncated: bool, |
| 56 | /// True when at least one redaction was applied. |
| 57 | pub redacted: bool, |
| 58 | } |
| 59 | |
| 60 | /// A read-only view of a saved session. |
| 61 | /// |
| 62 | /// Note what is absent: no turn status, no `active`, no `running`. A saved |
| 63 | /// session has none of those, and inventing them is the fabricated-live-state |
| 64 | /// failure this whole slice exists to avoid. |
| 65 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 66 | pub struct SessionPeek { |
| 67 | pub session_id: String, |
| 68 | pub title: String, |
| 69 | pub workspace: std::path::PathBuf, |
| 70 | pub model: String, |
| 71 | pub mode: String, |
| 72 | pub archived: bool, |
| 73 | pub message_count: usize, |
| 74 | pub updated_at: chrono::DateTime<chrono::Utc>, |
| 75 | /// Entries actually carried, oldest-first within the tail. |
| 76 | pub entries: Vec<PeekEntry>, |
| 77 | /// How many messages were dropped from the front to fit the bound. The |
| 78 | /// client shows this rather than implying it has the whole conversation. |
| 79 | pub omitted_before: usize, |
| 80 | /// Always true: a peek is a recording of a saved session, never a live |
| 81 | /// thread. Serialised so a client cannot mistake one payload for the |
| 82 | /// other even by accident. |
| 83 | pub live: bool, |
| 84 | } |
| 85 | |
| 86 | /// Build a bounded, redacted peek from a loaded session. |
| 87 | #[must_use] |
| 88 | pub fn build_peek(session: &SavedSession, max_entries: usize) -> SessionPeek { |
| 89 | let max_entries = max_entries.clamp(1, MAX_PEEK_ENTRIES); |
| 90 | let total = session.messages.len(); |
| 91 | let start = total.saturating_sub(max_entries); |
| 92 | |
| 93 | let entries: Vec<PeekEntry> = session.messages[start..] |
| 94 | .iter() |
| 95 | .map(|message| { |
| 96 | let kind = match message.role.as_str() { |
| 97 | "user" => PeekEntryKind::User, |
| 98 | _ => PeekEntryKind::Assistant, |
| 99 | }; |
| 100 | entry_for_blocks(kind, &message.content) |
| 101 | }) |
| 102 | .collect(); |
| 103 | |
| 104 | SessionPeek { |
| 105 | session_id: session.metadata.id.clone(), |
| 106 | title: session.metadata.title.clone(), |
| 107 | workspace: session.metadata.workspace.clone(), |
| 108 | model: session.metadata.model.clone(), |
| 109 | mode: session |
| 110 | .metadata |
| 111 | .mode |
| 112 | .clone() |
| 113 | .unwrap_or_else(|| "agent".to_string()), |
| 114 | archived: session.metadata.archived, |
| 115 | message_count: total, |
| 116 | updated_at: session.metadata.updated_at, |
| 117 | entries, |
| 118 | omitted_before: start, |
| 119 | live: false, |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | fn entry_for_blocks(default_kind: PeekEntryKind, blocks: &[ContentBlock]) -> PeekEntry { |
| 124 | let mut kind = default_kind; |
| 125 | let mut parts: Vec<String> = Vec::new(); |
| 126 | |
| 127 | for block in blocks { |
| 128 | match block { |
| 129 | ContentBlock::Text { text, .. } => parts.push(text.trim().to_string()), |
| 130 | ContentBlock::Thinking { thinking, .. } => { |
| 131 | kind = PeekEntryKind::Reasoning; |
| 132 | parts.push(thinking.trim().to_string()); |
| 133 | } |
| 134 | // Tool traffic is summarised, never inlined: it is the most |
| 135 | // likely carrier of both bulk output and credentials. |
| 136 | ContentBlock::ToolUse { name, .. } | ContentBlock::ServerToolUse { name, .. } => { |
| 137 | kind = PeekEntryKind::Tool; |
| 138 | parts.push(format!("[tool call: {name}]")); |
| 139 | } |
| 140 | ContentBlock::ToolResult { content, .. } => { |
| 141 | kind = PeekEntryKind::Tool; |
| 142 | parts.push(format!("[tool result: {} chars]", content.chars().count())); |
| 143 | } |
| 144 | // Structured tool results are JSON. Report their serialized size |
| 145 | // rather than their shape: the size is the honest number, and any |
| 146 | // field of the payload could be a credential. |
| 147 | ContentBlock::ToolSearchToolResult { content, .. } |
| 148 | | ContentBlock::CodeExecutionToolResult { content, .. } => { |
| 149 | kind = PeekEntryKind::Tool; |
| 150 | parts.push(format!( |
| 151 | "[tool result: {} chars]", |
| 152 | content.to_string().len() |
| 153 | )); |
| 154 | } |
| 155 | ContentBlock::ImageUrl { .. } => { |
| 156 | kind = PeekEntryKind::Tool; |
| 157 | parts.push("[image]".to_string()); |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | let joined = parts |
| 163 | .into_iter() |
| 164 | .filter(|part| !part.is_empty()) |
| 165 | .collect::<Vec<_>>() |
| 166 | .join(" "); |
| 167 | let (text, redacted) = redact(&joined); |
| 168 | let (text, truncated) = bound(&text, MAX_ENTRY_CHARS); |
| 169 | |
| 170 | PeekEntry { |
| 171 | kind, |
| 172 | text, |
| 173 | truncated, |
| 174 | redacted, |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | fn bound(text: &str, max_chars: usize) -> (String, bool) { |
| 179 | if text.chars().count() <= max_chars { |
| 180 | return (text.to_string(), false); |
| 181 | } |
| 182 | let kept: String = text.chars().take(max_chars.saturating_sub(1)).collect(); |
| 183 | (format!("{kept}…"), true) |
| 184 | } |
| 185 | |
| 186 | /// Placeholder substituted for anything that looks like a credential. |
| 187 | pub const REDACTED_PLACEHOLDER: &str = "[redacted]"; |
| 188 | |
| 189 | /// Mask credential-shaped substrings. |
| 190 | /// |
| 191 | /// Conservative and shape-based: it does not try to understand the text, only |
| 192 | /// to recognise the handful of forms secrets usually take in a transcript. |
| 193 | /// Over-redacting a peek line is cheap; leaking a key over a LAN is not. |
| 194 | #[must_use] |
| 195 | pub fn redact(text: &str) -> (String, bool) { |
| 196 | let mut out = String::with_capacity(text.len()); |
| 197 | let mut redacted = false; |
| 198 | |
| 199 | for token in text.split_inclusive(char::is_whitespace) { |
| 200 | let trimmed = token.trim_end(); |
| 201 | let trailing = &token[trimmed.len()..]; |
| 202 | if looks_like_secret(trimmed) { |
| 203 | out.push_str(REDACTED_PLACEHOLDER); |
| 204 | out.push_str(trailing); |
| 205 | redacted = true; |
| 206 | } else if let Some(masked) = mask_assignment(trimmed) { |
| 207 | out.push_str(&masked); |
| 208 | out.push_str(trailing); |
| 209 | redacted = true; |
| 210 | } else { |
| 211 | out.push_str(token); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | (out, redacted) |
| 216 | } |
| 217 | |
| 218 | /// Known credential prefixes plus long opaque runs. |
| 219 | fn looks_like_secret(token: &str) -> bool { |
| 220 | const PREFIXES: &[&str] = &[ |
| 221 | "sk-", |
| 222 | "sk_", |
| 223 | "pk_", |
| 224 | "ghp_", |
| 225 | "gho_", |
| 226 | "ghu_", |
| 227 | "ghs_", |
| 228 | "github_pat_", |
| 229 | "xoxb-", |
| 230 | "xoxp-", |
| 231 | "AKIA", |
| 232 | "ASIA", |
| 233 | "AIza", |
| 234 | "hf_", |
| 235 | "Bearer", |
| 236 | ]; |
| 237 | if PREFIXES |
| 238 | .iter() |
| 239 | .any(|prefix| token.len() > prefix.len() && token.starts_with(prefix)) |
| 240 | { |
| 241 | return true; |
| 242 | } |
| 243 | // A long unbroken run of base64/hex-ish characters is almost never prose. |
| 244 | token.len() >= 32 |
| 245 | && token |
| 246 | .chars() |
| 247 | .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '_') |
| 248 | && token.chars().any(|c| c.is_ascii_digit()) |
| 249 | && token.chars().any(|c| c.is_ascii_alphabetic()) |
| 250 | } |
| 251 | |
| 252 | /// `key=value`, `token: value`, `--password value` style assignments. |
| 253 | fn mask_assignment(token: &str) -> Option<String> { |
| 254 | const KEYS: &[&str] = &[ |
| 255 | "api_key", |
| 256 | "apikey", |
| 257 | "api-key", |
| 258 | "token", |
| 259 | "secret", |
| 260 | "password", |
| 261 | "passwd", |
| 262 | "authorization", |
| 263 | "auth", |
| 264 | "credential", |
| 265 | ]; |
| 266 | let (name, sep_index) = token |
| 267 | .find('=') |
| 268 | .map(|i| (&token[..i], i)) |
| 269 | .or_else(|| token.find(':').map(|i| (&token[..i], i)))?; |
| 270 | let normalized = name.trim_start_matches('-').to_ascii_lowercase(); |
| 271 | if !KEYS.contains(&normalized.as_str()) { |
| 272 | return None; |
| 273 | } |
| 274 | if token[sep_index + 1..].trim().is_empty() { |
| 275 | return None; |
| 276 | } |
| 277 | Some(format!( |
| 278 | "{}{}{REDACTED_PLACEHOLDER}", |
| 279 | name, |
| 280 | &token[sep_index..=sep_index] |
| 281 | )) |
| 282 | } |
| 283 | |
| 284 | #[cfg(test)] |
| 285 | mod tests { |
| 286 | use super::*; |
| 287 | use crate::models::Message; |
| 288 | use crate::session_manager::create_saved_session_with_id_and_mode; |
| 289 | |
| 290 | fn text_block(text: &str) -> ContentBlock { |
| 291 | ContentBlock::Text { |
| 292 | text: text.to_string(), |
| 293 | cache_control: None, |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | fn session_with(messages: Vec<Message>) -> SavedSession { |
| 298 | create_saved_session_with_id_and_mode( |
| 299 | "peek-session".to_string(), |
| 300 | &messages, |
| 301 | "deepseek-chat", |
| 302 | std::path::Path::new("/repo"), |
| 303 | 10, |
| 304 | None, |
| 305 | Some("agent"), |
| 306 | ) |
| 307 | } |
| 308 | |
| 309 | fn user(text: &str) -> Message { |
| 310 | Message { |
| 311 | role: "user".to_string(), |
| 312 | content: vec![text_block(text)], |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | #[test] |
| 317 | fn peek_is_bounded_in_entries_and_reports_what_it_dropped() { |
| 318 | let messages: Vec<Message> = (0..40).map(|i| user(&format!("message {i}"))).collect(); |
| 319 | let peek = build_peek(&session_with(messages), MAX_PEEK_ENTRIES); |
| 320 | |
| 321 | assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES); |
| 322 | assert_eq!(peek.message_count, 40); |
| 323 | assert_eq!(peek.omitted_before, 40 - MAX_PEEK_ENTRIES); |
| 324 | assert!( |
| 325 | peek.entries.last().expect("tail").text.contains("39"), |
| 326 | "the peek must be the tail, not the head" |
| 327 | ); |
| 328 | } |
| 329 | |
| 330 | #[test] |
| 331 | fn a_request_for_more_than_the_cap_still_gets_the_cap() { |
| 332 | let messages: Vec<Message> = (0..100).map(|i| user(&format!("m{i}"))).collect(); |
| 333 | let peek = build_peek(&session_with(messages), usize::MAX); |
| 334 | assert_eq!(peek.entries.len(), MAX_PEEK_ENTRIES); |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn long_entries_are_truncated_and_flagged() { |
| 339 | let peek = build_peek(&session_with(vec![user(&"x".repeat(5_000))]), 4); |
| 340 | let entry = &peek.entries[0]; |
| 341 | assert!(entry.truncated); |
| 342 | assert!(entry.text.chars().count() <= MAX_ENTRY_CHARS); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn credentials_are_redacted_out_of_peek_text() { |
| 347 | for secret in [ |
| 348 | "sk-abcdefghijklmnopqrstuvwxyz123456", |
| 349 | "ghp_abcdefghijklmnopqrstuvwxyz1234", |
| 350 | "AKIAIOSFODNN7EXAMPLE", |
| 351 | ] { |
| 352 | let peek = build_peek(&session_with(vec![user(&format!("here: {secret}"))]), 4); |
| 353 | let entry = &peek.entries[0]; |
| 354 | assert!(entry.redacted, "{secret} should have been redacted"); |
| 355 | assert!( |
| 356 | !entry.text.contains(secret), |
| 357 | "peek leaked {secret}: {}", |
| 358 | entry.text |
| 359 | ); |
| 360 | assert!(entry.text.contains(REDACTED_PLACEHOLDER)); |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | #[test] |
| 365 | fn assignment_style_secrets_are_masked_but_keep_their_key() { |
| 366 | let (masked, redacted) = redact("api_key=hunter2 and password:swordfish"); |
| 367 | assert!(redacted); |
| 368 | assert!(masked.contains("api_key=")); |
| 369 | assert!(!masked.contains("hunter2")); |
| 370 | assert!(!masked.contains("swordfish")); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn ordinary_prose_is_not_redacted() { |
| 375 | let (out, redacted) = redact("Please refactor the lane registry and update the docs."); |
| 376 | assert!(!redacted); |
| 377 | assert_eq!( |
| 378 | out, |
| 379 | "Please refactor the lane registry and update the docs." |
| 380 | ); |
| 381 | } |
| 382 | |
| 383 | #[test] |
| 384 | fn tool_payloads_are_summarised_never_inlined() { |
| 385 | let message = Message { |
| 386 | role: "assistant".to_string(), |
| 387 | content: vec![ |
| 388 | ContentBlock::ToolUse { |
| 389 | id: "call-1".to_string(), |
| 390 | name: "read_file".to_string(), |
| 391 | input: serde_json::json!({ "path": "/etc/shadow" }), |
| 392 | caller: None, |
| 393 | }, |
| 394 | ContentBlock::ToolResult { |
| 395 | tool_use_id: "call-1".to_string(), |
| 396 | content: "root:$6$verysecrethash".to_string(), |
| 397 | is_error: None, |
| 398 | content_blocks: None, |
| 399 | }, |
| 400 | ], |
| 401 | }; |
| 402 | let peek = build_peek(&session_with(vec![message]), 4); |
| 403 | let entry = &peek.entries[0]; |
| 404 | |
| 405 | assert_eq!(entry.kind, PeekEntryKind::Tool); |
| 406 | assert!(entry.text.contains("[tool call: read_file]")); |
| 407 | assert!(entry.text.contains("[tool result:")); |
| 408 | assert!( |
| 409 | !entry.text.contains("verysecrethash"), |
| 410 | "tool output must never be inlined into a peek: {}", |
| 411 | entry.text |
| 412 | ); |
| 413 | assert!( |
| 414 | !entry.text.contains("/etc/shadow"), |
| 415 | "tool input must not be inlined either: {}", |
| 416 | entry.text |
| 417 | ); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn a_peek_never_claims_to_be_live() { |
| 422 | let peek = build_peek(&session_with(vec![user("hello")]), 4); |
| 423 | assert!(!peek.live, "a saved session is a recording, never live"); |
| 424 | let json = serde_json::to_value(&peek).expect("serialize"); |
| 425 | for forbidden in ["status", "running", "active", "turn"] { |
| 426 | assert!( |
| 427 | json.get(forbidden).is_none(), |
| 428 | "peek payload must not carry a `{forbidden}` field a client could read as live state" |
| 429 | ); |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | #[test] |
| 434 | fn archive_state_rides_along_so_the_dashboard_need_not_guess() { |
| 435 | let mut session = session_with(vec![user("hello")]); |
| 436 | session.metadata.archived = true; |
| 437 | assert!(build_peek(&session, 4).archived); |
| 438 | } |
| 439 | } |
| 440 |