返回 CodeWhale
session_peek.rs
根目录 / crates / tui / src / session_peek.rs
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
440 lines RUST