返回 CodeWhale
structcopy.rs
根目录 / crates / tui / src / commands / groups / session / structcopy.rs
1 //! `/structcopy` command — human-only structural copy (#2033).
2 //!
3 //! Copies exactly one bounded, human-selected session object (one transcript
4 //! item, one tool call+result pair, the current plan snapshot, or one
5 //! existing Workflow run projection) as deterministic, versioned canonical
6 //! JSON with a top-level receipt. The default target is the clipboard; an
7 //! explicit `stdout` argument is the only text-view path.
8 //!
9 //! Contract:
10 //! - Human-only. This is a slash command, never a model-visible tool, event,
11 //! or authority, and it writes nothing back into App/session/plan/workflow
12 //! state (see the registry/catalog contract test).
13 //! - Read-only projection over existing state. Redaction reuses the shared
14 //! sanitizer seams in `codewhale_secrets::sanitize` (`redact_json` for
15 //! values, `sanitize_text` for keys and status labels, which
16 //! `redact_json` does not reach) plus a strict pass that strips URL
17 //! userinfo/query/fragment entirely and folds the workspace and home
18 //! prefixes to labels, removes other absolute paths, and handles generic
19 //! authority URLs. The workflow object reuses the bounded
20 //! `WorkflowRunSummary` projection.
21 //! - Hard caps on final encoded bytes, array items, string bytes, object key
22 //! bytes, and nesting depth; grapheme-safe truncation; recursively sorted
23 //! keys; exact full-tree original counts and exact retained counts in the
24 //! receipt. If receipt metadata alone cannot fit the byte cap, the command
25 //! fails closed and emits nothing.
26 //!
27 //! What this deliberately does **not** claim:
28 //! - It is not a general PII scrubber. Workspace/home paths retain a useful
29 //! labelled suffix; other absolute POSIX, drive-letter, and UNC paths are
30 //! replaced outright.
31 //! - Redaction is pattern-based (the shared sanitizer's private-key/bearer/
32 //! JWT/URL/secret regexes plus this module's strict URL pass). A secret that
33 //! matches none of those patterns and sits under a non-sensitive key is
34 //! copied as-is.
35 //! - Delivery to the clipboard is not confirmed. Terminal-client transports
36 //! (tmux / OSC 52) are queued on a background writer; the receipt says
37 //! "queued", not "delivered".
38
39 use std::collections::{BTreeMap, BTreeSet};
40 use std::fmt::Write as FmtWrite;
41 use std::path::Path;
42
43 use serde_json::{Value, json};
44 use unicode_segmentation::UnicodeSegmentation;
45
46 use crate::commands::traits::{CommandInfo, RegisterCommand};
47 use crate::tui::app::App;
48 use codewhale_localization::{Locale, MessageId, tr};
49 use codewhale_models::{ContentBlock, Message};
50
51 use super::CommandResult;
52 // FEAT-025 D4: the sanitizer helpers moved to the single shared portable
53 // implementation in `codewhale-secrets`; `/structcopy` stays legacy until
54 // FEAT-026 and only rewires its import.
55 use codewhale_secrets::sanitize::{is_internal_role, is_sensitive_key, redact_json, sanitize_text};
56
57 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
58 name: "structcopy",
59 aliases: &[],
60 usage: "/structcopy <turn <n>|tool <call-id>|plan|workflow <run-id>> [stdout]",
61 description_id: MessageId::CmdStructcopyDescription,
62 };
63
64 pub(in crate::commands) struct StructcopyCmd;
65
66 impl RegisterCommand for StructcopyCmd {
67 fn info() -> &'static CommandInfo {
68 &COMMAND_INFO
69 }
70
71 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
72 execute_structcopy(app, arg)
73 }
74 }
75
76 /// Versioned envelope identity carried in every receipt.
77 const SCHEMA_ID: &str = "codewhale/structcopy/v1";
78 /// Redaction contract label so consumers can tell which seams ran.
79 const REDACTION_CONTRACT: &str = "export-sanitize/v1+typed-markers/v1+strict-url/v2+path-redact/v2";
80 /// Marker substituted for subtrees cut by the depth cap. Structural markers
81 /// are inserted after bounding and are intentionally exempt from
82 /// `max_string_bytes`; they are still counted as retained bytes.
83 const DEPTH_OMISSION_MARKER: &str = "omitted:depth_cap";
84 /// Marker substituted for a URL token that cannot be parsed and therefore
85 /// cannot be proven free of userinfo/query/fragment. Fail closed.
86 const URL_OMISSION_MARKER: &str = "redacted:url";
87 /// Marker substituted for an absolute filesystem path outside the labelled
88 /// workspace/home roots. Paths are privacy-bearing even when they contain no
89 /// conventional secret token.
90 const PATH_OMISSION_MARKER: &str = "redacted:absolute_path";
91 const BEARER_REDACTION_MARKER: &str = "redacted:bearer";
92 const SENSITIVE_VALUE_REDACTION_MARKER: &str = "redacted:sensitive_value";
93
94 /// Selectors are echoed into the receipt and into status messages, so they
95 /// get their own tight cap independent of the payload string cap.
96 const MAX_SELECTOR_BYTES: usize = 256;
97 /// Hard caps enforced on every emitted artifact. The byte cap stays well
98 /// under the OSC 52 clipboard ceiling (100 KiB) so the default clipboard
99 /// target always fits its weakest transport.
100 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 struct Caps {
102 max_output_bytes: usize,
103 max_array_items: usize,
104 max_string_bytes: usize,
105 max_depth: usize,
106 }
107
108 const DEFAULT_CAPS: Caps = Caps {
109 max_output_bytes: 48 * 1024,
110 max_array_items: 64,
111 max_string_bytes: 2 * 1024,
112 max_depth: 12,
113 };
114
115 /// Object keys are bounded separately from values: they are short by nature,
116 /// they participate in collision handling, and they are rewritten once during
117 /// redaction rather than per byte-cap retry.
118 const MAX_KEY_BYTES: usize = 256;
119
120 #[derive(Debug, Clone, PartialEq, Eq)]
121 enum CopyKind {
122 Turn(usize),
123 Tool(String),
124 Plan,
125 Workflow(String),
126 }
127
128 impl CopyKind {
129 fn display_label(&self, locale: Locale) -> String {
130 let id = match self {
131 CopyKind::Turn(_) => MessageId::CmdStructcopyKindTurn,
132 CopyKind::Tool(_) => MessageId::CmdStructcopyKindTool,
133 CopyKind::Plan => MessageId::CmdStructcopyKindPlan,
134 CopyKind::Workflow(_) => MessageId::CmdStructcopyKindWorkflow,
135 };
136 tr(locale, id).into_owned()
137 }
138 }
139
140 #[derive(Debug, Clone, PartialEq, Eq)]
141 struct CopyRequest {
142 kind: CopyKind,
143 stdout: bool,
144 }
145
146 fn execute_structcopy(app: &mut App, arg: Option<&str>) -> CommandResult {
147 let request = match parse_request(arg) {
148 Ok(request) => request,
149 Err(()) => {
150 return CommandResult::error(
151 tr(app.ui_locale, MessageId::CmdStructcopyUsageError)
152 .replace("{usage}", COMMAND_INFO.usage),
153 );
154 }
155 };
156 let label = request.kind.display_label(app.ui_locale);
157 let json = match render_copy(app, &request.kind, &DEFAULT_CAPS) {
158 Ok(json) => json,
159 Err(err) => return CommandResult::error(err),
160 };
161 if request.stdout {
162 // The text view exists only because a human explicitly asked for it;
163 // the default clipboard path never prints the payload.
164 return CommandResult::message(json);
165 }
166 // `requires_terminal_paste()` is true only for an SSH session with no
167 // forwarded display, where the sole transport is the terminal client
168 // itself. That write is queued on a background writer, so a successful
169 // return means "accepted for transport", not "in the clipboard".
170 let terminal_client = app.clipboard.requires_terminal_paste();
171 let bytes = json.len();
172 match app.clipboard.write_text(&json) {
173 Ok(()) if terminal_client => CommandResult::message(
174 tr(app.ui_locale, MessageId::CmdStructcopyClipboardQueued)
175 .replace("{kind}", &label)
176 .replace("{bytes}", &bytes.to_string()),
177 ),
178 Ok(()) => CommandResult::message(
179 tr(app.ui_locale, MessageId::CmdStructcopyClipboardAccepted)
180 .replace("{kind}", &label)
181 .replace("{bytes}", &bytes.to_string()),
182 ),
183 Err(err) => CommandResult::error(
184 tr(app.ui_locale, MessageId::CmdStructcopyClipboardFailed)
185 .replace("{error}", &err.to_string()),
186 ),
187 }
188 }
189
190 fn parse_request(arg: Option<&str>) -> Result<CopyRequest, ()> {
191 let raw = arg.unwrap_or("").trim();
192 if raw.is_empty() {
193 return Err(());
194 }
195 let mut tokens: Vec<&str> = raw.split_whitespace().collect();
196 let mut stdout = false;
197 if tokens
198 .last()
199 .is_some_and(|last| last.eq_ignore_ascii_case("stdout"))
200 {
201 stdout = true;
202 tokens.pop();
203 }
204 let kind = match tokens.as_slice() {
205 ["plan"] => CopyKind::Plan,
206 ["turn", index] => {
207 let index = index
208 .parse::<usize>()
209 .ok()
210 .filter(|index| *index >= 1)
211 .ok_or(())?;
212 CopyKind::Turn(index)
213 }
214 ["tool", call_id] => CopyKind::Tool((*call_id).to_string()),
215 ["workflow", run_id] => CopyKind::Workflow((*run_id).to_string()),
216 _ => return Err(()),
217 };
218 Ok(CopyRequest { kind, stdout })
219 }
220
221 // === Object selection (read-only; unavailable objects are reported, never
222 // fabricated) ===
223
224 fn build_payload(app: &App, kind: &CopyKind) -> Result<(&'static str, Value, Value), String> {
225 match kind {
226 CopyKind::Turn(index) => turn_payload(app, *index),
227 CopyKind::Tool(call_id) => tool_payload(app, call_id),
228 CopyKind::Plan => plan_payload(app),
229 CopyKind::Workflow(run_id) => workflow_payload(app, run_id),
230 }
231 }
232
233 fn turn_payload(app: &App, index: usize) -> Result<(&'static str, Value, Value), String> {
234 if app.api_messages.is_empty() {
235 return Err(unavailable_message(app, &CopyKind::Turn(index)));
236 }
237 let Some(message) = app.api_messages.get(index - 1) else {
238 return Err(unavailable_message(app, &CopyKind::Turn(index)));
239 };
240 Ok(("turn", json!(index), message_payload(message, index)))
241 }
242
243 fn message_payload(message: &Message, index: usize) -> Value {
244 if is_internal_role(message.role.as_str()) {
245 return json!({
246 "index": index,
247 "role": message.role,
248 "omission_code": "internal_context",
249 });
250 }
251 let content: Vec<Value> = message.content.iter().map(block_payload).collect();
252 json!({
253 "index": index,
254 "role": message.role,
255 "content": content,
256 })
257 }
258
259 /// JSON `null` is the only truthful encoding for an unknown tri-state flag.
260 /// Collapsing `None` to `false` would assert an outcome the session never
261 /// observed, so every optional boolean in this projection goes through here.
262 fn optional_bool(value: Option<bool>) -> Value {
263 match value {
264 Some(flag) => Value::Bool(flag),
265 None => Value::Null,
266 }
267 }
268
269 fn block_payload(block: &ContentBlock) -> Value {
270 match block {
271 ContentBlock::Text { text, .. } => json!({
272 "type": "text",
273 "text": text,
274 }),
275 ContentBlock::Thinking { .. } => json!({
276 "type": "thinking",
277 "omission_code": "internal_reasoning_and_signature",
278 }),
279 ContentBlock::ToolUse {
280 id,
281 name,
282 input,
283 caller,
284 ..
285 } => json!({
286 "type": "tool_use",
287 "id": id,
288 // `null` here means "no caller recorded", not "no caller".
289 "caller_type": caller.as_ref().map(|caller| caller.caller_type.as_str()),
290 "name": name,
291 "input": input,
292 }),
293 ContentBlock::ToolResult {
294 tool_use_id,
295 content,
296 is_error,
297 content_blocks,
298 } => json!({
299 "type": "tool_result",
300 "tool_use_id": tool_use_id,
301 "is_error": optional_bool(*is_error),
302 "content": content,
303 "content_blocks": crate::image_attach::safe_tool_result_content_blocks(content_blocks.as_deref()),
304 }),
305 ContentBlock::ImageUrl { image_url } => {
306 if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") {
307 json!({
308 "type": "image",
309 "url": image_url.url,
310 })
311 } else {
312 json!({
313 "type": "image",
314 "omission_code": "inline_or_local_image_payload",
315 })
316 }
317 }
318 ContentBlock::ServerToolUse { id, name, input } => json!({
319 "type": "server_tool_use",
320 "id": id,
321 "name": name,
322 "input": input,
323 }),
324 ContentBlock::ToolSearchToolResult {
325 tool_use_id,
326 content,
327 } => json!({
328 "type": "tool_search_tool_result",
329 "tool_use_id": tool_use_id,
330 "content": content,
331 }),
332 ContentBlock::CodeExecutionToolResult {
333 tool_use_id,
334 content,
335 } => json!({
336 "type": "code_execution_tool_result",
337 "tool_use_id": tool_use_id,
338 "content": content,
339 }),
340 }
341 }
342
343 fn tool_payload(app: &App, call_id: &str) -> Result<(&'static str, Value, Value), String> {
344 let mut found_call: Option<(String, Value)> = None;
345 let mut found_result: Option<(Option<bool>, String, Option<Vec<Value>>)> = None;
346 for message in app.api_messages.iter() {
347 for block in &message.content {
348 match block {
349 ContentBlock::ToolUse {
350 id, name, input, ..
351 } => {
352 if id.as_str() == call_id {
353 found_call = Some((name.clone(), input.clone()));
354 }
355 }
356 ContentBlock::ToolResult {
357 tool_use_id,
358 content,
359 is_error,
360 content_blocks,
361 } if tool_use_id.as_str() == call_id => {
362 found_result = Some((*is_error, content.clone(), content_blocks.clone()));
363 }
364 _ => {}
365 }
366 }
367 }
368 let Some((name, input)) = found_call else {
369 return Err(unavailable_message(
370 app,
371 &CopyKind::Tool(call_id.to_string()),
372 ));
373 };
374 let result = match found_result {
375 Some((is_error, content, content_blocks)) => json!({
376 "found": true,
377 // `null` = the result carried no error flag, which is distinct
378 // from `false` (an explicitly successful result).
379 "is_error": optional_bool(is_error),
380 "content": content,
381 "content_blocks": crate::image_attach::safe_tool_result_content_blocks(content_blocks.as_deref()),
382 }),
383 None => json!({
384 "found": false,
385 }),
386 };
387 Ok((
388 "tool",
389 json!(call_id),
390 json!({
391 "call_id": call_id,
392 "name": name,
393 "input": input,
394 "result": result,
395 }),
396 ))
397 }
398
399 fn plan_payload(app: &App) -> Result<(&'static str, Value, Value), String> {
400 let snapshot = {
401 let state = app
402 .plan_state
403 .try_lock()
404 .map_err(|_| busy_message(app, &CopyKind::Plan))?;
405 state.snapshot()
406 };
407 if snapshot.is_empty() {
408 return Err(unavailable_message(app, &CopyKind::Plan));
409 }
410 let value = serde_json::to_value(&snapshot)
411 .map_err(|err| prepare_failed_message(app, &CopyKind::Plan, &err.to_string()))?;
412 Ok(("plan", Value::Null, value))
413 }
414
415 fn workflow_payload(app: &App, run_id: &str) -> Result<(&'static str, Value, Value), String> {
416 match crate::tools::workflow::structcopy_run_projection(
417 &app.workspace,
418 run_id,
419 app.current_session_id.as_deref(),
420 ) {
421 Some(value) => Ok(("workflow", json!(run_id), value)),
422 None => Err(unavailable_message(
423 app,
424 &CopyKind::Workflow(run_id.to_string()),
425 )),
426 }
427 }
428
429 fn unavailable_message(app: &App, kind: &CopyKind) -> String {
430 tr(app.ui_locale, MessageId::CmdStructcopyUnavailable)
431 .replace("{kind}", &kind.display_label(app.ui_locale))
432 }
433
434 fn busy_message(app: &App, kind: &CopyKind) -> String {
435 tr(app.ui_locale, MessageId::CmdStructcopyBusy)
436 .replace("{kind}", &kind.display_label(app.ui_locale))
437 }
438
439 fn prepare_failed_message(app: &App, kind: &CopyKind, error: &str) -> String {
440 tr(app.ui_locale, MessageId::CmdStructcopyPrepareFailed)
441 .replace("{kind}", &kind.display_label(app.ui_locale))
442 .replace("{error}", error)
443 }
444
445 // === Redaction (composed from existing central seams) ===
446
447 /// The strongest existing central redaction, applied before any bounding or
448 /// serialization and after key normalization.
449 ///
450 /// [`redact_json`] replaces values under secret-shaped keys, and runs
451 /// [`sanitize_text`] over every string *value* — stripping ANSI/control
452 /// bytes and masking PEM blocks, `Bearer` tokens, JWTs, credential-bearing
453 /// URLs, and the config layer's known secret patterns. It does **not** touch
454 /// object *keys*, so this pass runs [`sanitize_text`] over keys as well,
455 /// then folds workspace/home prefixes to labels and strips URL
456 /// userinfo/query/fragment outright.
457 ///
458 /// Keys are also sorted, bounded, and de-collided here. Original and retained
459 /// key counts are kept separately so omitted subtrees cannot inflate claims
460 /// about the emitted object.
461 fn redact_payload(value: &mut Value, labels: &PathLabels, keys: &mut KeyStats) {
462 // Normalize keys first so ANSI/control obfuscation cannot hide a
463 // sensitive-key hint from classification. `strict_strings` classifies
464 // both the original and normalized key; the shared export pass then runs
465 // over the normalized tree as defense in depth.
466 let mut path = Vec::new();
467 strict_strings(value, labels, keys, &mut path);
468 redact_json(value, None);
469 normalize_redaction_codes(value);
470 }
471
472 /// Prefix folding for useful filesystem paths. These prefixes are recognised:
473 /// the workspace root (both as configured and as canonicalized, which differ
474 /// on macOS where `/var` symlinks to `/private/var`) and `$HOME` /
475 /// `%USERPROFILE%`. The later strict pass removes every remaining absolute
476 /// POSIX, drive-letter, or UNC path.
477 struct PathLabels {
478 /// `(prefix, label)` sorted longest-first so that a workspace nested
479 /// inside `$HOME` folds to `<workspace>` rather than `<home>/…`.
480 labels: Vec<(String, &'static str)>,
481 }
482
483 impl PathLabels {
484 fn new(workspace: &Path) -> Self {
485 let mut workspace_forms: Vec<String> = Vec::new();
486 let literal = workspace.to_string_lossy().into_owned();
487 if literal.len() > 1 {
488 workspace_forms.push(literal);
489 }
490 // Read-only; `canonicalize` never creates state.
491 if let Ok(canonical) = workspace.canonicalize() {
492 let canonical = canonical.to_string_lossy().into_owned();
493 if canonical.len() > 1 && !workspace_forms.contains(&canonical) {
494 workspace_forms.push(canonical);
495 }
496 }
497 let mut labels: Vec<(String, &'static str)> = workspace_forms
498 .iter()
499 .map(|form| (form.clone(), "<workspace>"))
500 .collect();
501 if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
502 let home = home.to_string_lossy().into_owned();
503 if home.len() > 3 && !workspace_forms.contains(&home) {
504 labels.push((home, "<home>"));
505 }
506 }
507 labels.sort_by(|left, right| {
508 right
509 .0
510 .len()
511 .cmp(&left.0.len())
512 .then_with(|| left.0.cmp(&right.0))
513 });
514 Self { labels }
515 }
516
517 fn apply(&self, text: &str) -> String {
518 let mut out = text.to_string();
519 for (prefix, label) in &self.labels {
520 out = replace_path_root(&out, prefix, label);
521 }
522 out
523 }
524 }
525
526 /// Replace a configured root only when it ends on a path-component boundary.
527 /// A lexical prefix such as `/opt/app` must not label `/opt/application`; the
528 /// latter remains foreign and is removed by the absolute-path scrubber.
529 fn replace_path_root(text: &str, root: &str, label: &str) -> String {
530 if root.is_empty() {
531 return text.to_string();
532 }
533 let mut out = String::with_capacity(text.len());
534 let mut cursor = 0usize;
535 while let Some(offset) = text[cursor..].find(root) {
536 let start = cursor + offset;
537 let end = start + root.len();
538 out.push_str(&text[cursor..start]);
539 let component_boundary = text[end..]
540 .chars()
541 .next()
542 .is_none_or(|ch| matches!(ch, '/' | '\\'));
543 if component_boundary {
544 out.push_str(label);
545 } else {
546 out.push_str(root);
547 }
548 cursor = end;
549 }
550 out.push_str(&text[cursor..]);
551 out
552 }
553
554 /// Per-object-key accounting. Computed once during redaction and reported in
555 /// the receipt so a renamed or truncated key is never silent.
556 #[derive(Debug, Default, Clone, PartialEq, Eq)]
557 struct KeyStats {
558 entries: BTreeMap<Vec<String>, KeyFlags>,
559 }
560
561 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
562 struct KeyFlags {
563 truncated: bool,
564 deduped: bool,
565 }
566
567 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
568 struct RetainedKeyStats {
569 total: u64,
570 truncated: u64,
571 deduped: u64,
572 }
573
574 impl KeyStats {
575 fn original_total(&self) -> u64 {
576 u64::try_from(self.entries.len()).unwrap_or(u64::MAX)
577 }
578 }
579
580 fn strict_strings(
581 value: &mut Value,
582 labels: &PathLabels,
583 keys: &mut KeyStats,
584 path: &mut Vec<String>,
585 ) {
586 match value {
587 Value::String(text) => *text = scrub_string(text, labels),
588 Value::Array(items) => {
589 for (index, item) in items.iter_mut().enumerate() {
590 path.push(format!("i:{index}"));
591 strict_strings(item, labels, keys, path);
592 path.pop();
593 }
594 }
595 Value::Object(map) => {
596 // Take the map, rewrite each key, and reinsert. Entries are
597 // processed in sorted original-key order so collision suffixes
598 // are assigned deterministically regardless of insertion order.
599 let mut entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
600 entries.sort_by(|left, right| left.0.cmp(&right.0));
601 for (key, mut item) in entries {
602 let scrubbed = flatten_ws(&scrub_string(&key, labels));
603 let (bounded, was_truncated) =
604 truncate_string_grapheme_safe(&scrubbed, MAX_KEY_BYTES);
605 let (unique, collision_truncated) = unique_object_key(map, &bounded);
606 let sensitive = is_sensitive_key(&key) || is_sensitive_key(&scrubbed);
607 if sensitive {
608 item = Value::String("[redacted]".to_string());
609 } else {
610 path.push(key_path_segment(&unique));
611 strict_strings(&mut item, labels, keys, path);
612 path.pop();
613 }
614 path.push(key_path_segment(&unique));
615 keys.entries.insert(
616 path.clone(),
617 KeyFlags {
618 truncated: was_truncated || collision_truncated,
619 deduped: unique != bounded,
620 },
621 );
622 path.pop();
623 map.insert(unique, item);
624 }
625 }
626 Value::Null | Value::Bool(_) | Value::Number(_) => {}
627 }
628 }
629
630 fn key_path_segment(key: &str) -> String {
631 format!("k:{}:{key}", key.len())
632 }
633
634 fn collect_retained_key_stats(
635 value: &Value,
636 original: &KeyStats,
637 path: &mut Vec<String>,
638 retained: &mut RetainedKeyStats,
639 ) {
640 match value {
641 Value::Array(items) => {
642 for (index, item) in items.iter().enumerate() {
643 path.push(format!("i:{index}"));
644 collect_retained_key_stats(item, original, path, retained);
645 path.pop();
646 }
647 }
648 Value::Object(map) => {
649 for (key, item) in map {
650 path.push(key_path_segment(key));
651 if let Some(flags) = original.entries.get(path) {
652 retained.total += 1;
653 if flags.truncated {
654 retained.truncated += 1;
655 }
656 if flags.deduped {
657 retained.deduped += 1;
658 }
659 }
660 collect_retained_key_stats(item, original, path, retained);
661 path.pop();
662 }
663 }
664 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
665 }
666 }
667
668 /// Deterministic collision handling for keys that collapsed onto each other
669 /// after scrubbing or truncation.
670 ///
671 /// Termination is structural rather than hopeful: the numeric reserve is
672 /// sized for the largest suffix this call can produce, so `base` is fixed and
673 /// the `map.len() + 1` candidates `base~2 … base~(len+2)` are pairwise
674 /// distinct. A map holding `len` keys cannot occupy all of them.
675 ///
676 /// When `MAX_KEY_BYTES` is smaller than the reserve the suffix still wins:
677 /// losing a key to a silent overwrite is worse than exceeding a key cap by a
678 /// few bytes, and the per-key flags record that it happened.
679 fn unique_object_key(map: &serde_json::Map<String, Value>, requested: &str) -> (String, bool) {
680 if !map.contains_key(requested) {
681 return (requested.to_string(), false);
682 }
683 let highest = map.len().saturating_add(2);
684 let reserve = 1 + decimal_width(highest);
685 let base_cap = MAX_KEY_BYTES.saturating_sub(reserve);
686 let (base, collision_truncated) = truncate_string_grapheme_safe(requested, base_cap);
687 for index in 2..=highest {
688 let candidate = format!("{base}~{index}");
689 if !map.contains_key(&candidate) {
690 return (candidate, collision_truncated);
691 }
692 }
693 unreachable!(
694 "map of {} keys cannot occupy {} distinct candidates",
695 map.len(),
696 highest - 1
697 )
698 }
699
700 fn decimal_width(mut value: usize) -> usize {
701 let mut width = 1;
702 while value >= 10 {
703 value /= 10;
704 width += 1;
705 }
706 width
707 }
708
709 /// Collapse every run of whitespace to a single space. Used for object keys,
710 /// where control layout is a structural hazard rather than data.
711 fn flatten_ws(text: &str) -> String {
712 text.split_whitespace().collect::<Vec<_>>().join(" ")
713 }
714
715 fn scrub_string(text: &str, labels: &PathLabels) -> String {
716 // `sanitize_text` first: it strips ANSI and control bytes, so the URL
717 // scan below cannot be fooled by an escape sequence spliced into a
718 // scheme. It is idempotent, so re-running it over values that
719 // `redact_json` already sanitized is safe.
720 let sanitized = sanitize_text(text);
721 let bearer_safe = redact_loose_bearers(&sanitized);
722 let labelled = labels.apply(&bearer_safe);
723 scrub_paths(&scrub_urls(&labelled))
724 }
725
726 /// Convert the prose placeholders owned by the shared sanitizer into stable
727 /// language-neutral codes. Structural JSON is a machine artifact and must not
728 /// change with the UI locale.
729 fn normalize_redaction_codes(value: &mut Value) {
730 match value {
731 Value::String(text) => {
732 *text = text
733 .replace("[redacted private key]", "redacted:private_key")
734 .replace("Bearer [redacted]", BEARER_REDACTION_MARKER)
735 .replace("[redacted token]", "redacted:token")
736 .replace("[redacted]", SENSITIVE_VALUE_REDACTION_MARKER);
737 }
738 Value::Array(items) => {
739 for item in items {
740 normalize_redaction_codes(item);
741 }
742 }
743 Value::Object(map) => {
744 for item in map.values_mut() {
745 normalize_redaction_codes(item);
746 }
747 }
748 Value::Null | Value::Bool(_) | Value::Number(_) => {}
749 }
750 }
751
752 fn redact_loose_bearers(text: &str) -> String {
753 // Selectors cannot carry the whitespace used by a conventional
754 // `Bearer <token>` header. Delimiter variants are still secret-shaped;
755 // redact their entire line tail so token punctuation cannot terminate a
756 // regex early and expose the remainder.
757 let lowered = text.to_ascii_lowercase();
758 let mut out = String::with_capacity(text.len());
759 let mut cursor = 0usize;
760 while let Some(offset) = ["bearer-", "bearer_", "bearer:", "bearer="]
761 .iter()
762 .filter_map(|prefix| lowered[cursor..].find(prefix))
763 .min()
764 {
765 let start = cursor + offset;
766 out.push_str(&text[cursor..start]);
767 let end = text[start..]
768 .find('\n')
769 .map(|line_end| start + line_end)
770 .unwrap_or(text.len());
771 out.push_str(BEARER_REDACTION_MARKER);
772 cursor = end;
773 }
774 out.push_str(&text[cursor..]);
775 out
776 }
777
778 /// Trailing characters that are punctuation or wrappers around a URL rather
779 /// than part of it. Trimming generously is safe in both directions: the
780 /// trimmed tail is re-appended verbatim and can hold no credential, while a
781 /// tail left attached would be swallowed by the query/fragment strip.
782 const URL_TRAILING_PUNCTUATION: &[char] = &[
783 '.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '`', '*', '_', '\\',
784 ];
785
786 /// Strip URL userinfo, query, and fragment entirely, leaving a
787 /// `scheme://host[:port]/path` label.
788 ///
789 /// The shared sanitizer has already masked credentials in URLs it recognised;
790 /// this pass enforces the stricter structural-copy contract that no
791 /// userinfo, query string, or fragment may survive at all — including for
792 /// URLs that are punctuation-wrapped (`(https://…)`, `<https://…>`,
793 /// `"https://…"`), embedded mid-token, or uppercased. A token that starts
794 /// with a syntactically valid `scheme://` prefix but does not parse is replaced outright rather than
795 /// passed through, because an unparseable URL cannot be proven credential
796 /// free.
797 fn scrub_urls(text: &str) -> String {
798 let mut out = String::with_capacity(text.len());
799 let mut cursor = 0usize;
800 while let Some(offset) = next_url_start(&text[cursor..]) {
801 let start = cursor + offset;
802 out.push_str(&text[cursor..start]);
803 let rest = &text[start..];
804 // A scheme prefix contains no whitespace, so `end` is always > 0 and
805 // the cursor strictly advances.
806 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
807 out.push_str(&scrub_url_token(&rest[..end]));
808 cursor = start + end;
809 }
810 out.push_str(&text[cursor..]);
811 out
812 }
813
814 fn next_url_start(text: &str) -> Option<usize> {
815 for (separator, _) in text.match_indices("://") {
816 let before = &text[..separator];
817 let start = before
818 .char_indices()
819 .rev()
820 .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
821 .map(|(index, _)| index)
822 .last()
823 .unwrap_or(separator);
824 let scheme = &text[start..separator];
825 if scheme
826 .chars()
827 .next()
828 .is_some_and(|ch| ch.is_ascii_alphabetic())
829 {
830 return Some(start);
831 }
832 }
833 None
834 }
835
836 fn scrub_url_token(token: &str) -> String {
837 let trimmed = token.trim_end_matches(URL_TRAILING_PUNCTUATION);
838 let suffix = &token[trimmed.len()..];
839 let Ok(mut parsed) = reqwest::Url::parse(trimmed) else {
840 return format!("{URL_OMISSION_MARKER}{suffix}");
841 };
842 // `set_username`/`set_password` only fail for cannot-be-a-base URLs.
843 // Failing closed keeps the "no userinfo survives" claim literally true.
844 if parsed.set_username("").is_err() || parsed.set_password(None).is_err() {
845 return format!("{URL_OMISSION_MARKER}{suffix}");
846 }
847 parsed.set_query(None);
848 parsed.set_fragment(None);
849 format!("{parsed}{suffix}")
850 }
851
852 fn scrub_paths(text: &str) -> String {
853 let mut out = String::with_capacity(text.len());
854 let mut cursor = 0usize;
855 while let Some(start) = next_absolute_path_start(text, cursor) {
856 out.push_str(&text[cursor..start]);
857 // An unquoted absolute path can legally contain spaces. Stop at the
858 // line boundary rather than risk leaking the tail of such a path;
859 // losing adjacent prose is safer than emitting a customer/user name.
860 let end = text[start..]
861 .find('\n')
862 .map(|offset| start + offset)
863 .unwrap_or(text.len());
864 out.push_str(PATH_OMISSION_MARKER);
865 cursor = end;
866 }
867 out.push_str(&text[cursor..]);
868 out
869 }
870
871 fn next_absolute_path_start(text: &str, from: usize) -> Option<usize> {
872 let bytes = text.as_bytes();
873 let mut index = from;
874 while index < bytes.len() {
875 let boundary = index == 0
876 || text[..index]
877 .chars()
878 .next_back()
879 .is_some_and(|ch| !ch.is_alphanumeric() && !matches!(ch, '_' | '/' | '\\'));
880 if boundary {
881 let labelled_root =
882 text[..index].ends_with("<workspace>") || text[..index].ends_with("<home>");
883 let url_separator = index > 0
884 && index + 1 < bytes.len()
885 && bytes[index - 1] == b':'
886 && bytes[index + 1] == b'/';
887 let previous_is_slash = index > 0 && bytes[index - 1] == b'/';
888 let posix =
889 bytes[index] == b'/' && !previous_is_slash && !url_separator && !labelled_root;
890 let drive = index + 2 < bytes.len()
891 && bytes[index].is_ascii_alphabetic()
892 && bytes[index + 1] == b':'
893 && matches!(bytes[index + 2], b'/' | b'\\');
894 let unc = index + 1 < bytes.len() && bytes[index] == b'\\' && bytes[index + 1] == b'\\';
895 if posix || drive || unc {
896 return Some(index);
897 }
898 }
899 index += text[index..].chars().next()?.len_utf8();
900 }
901 None
902 }
903
904 // === Bounding (hard caps + exact accounting) ===
905
906 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
907 struct BoundStats {
908 /// Strings present in the full redacted tree, at every depth.
909 strings_total: u64,
910 /// Strings actually present in the emitted payload, including the
911 /// structural markers substituted for depth-omitted subtrees.
912 strings_retained: u64,
913 strings_truncated: u64,
914 string_bytes_original: u64,
915 string_bytes_retained: u64,
916 /// Array elements present in the full redacted tree, at every depth —
917 /// including elements inside subtrees that the depth cap later omits.
918 array_items_original: u64,
919 array_items_retained: u64,
920 depth_omissions: u64,
921 }
922
923 /// Exact full-tree original counts. Deliberately depth-unbounded: the
924 /// receipt's `*_original` numbers describe the whole redacted object, so
925 /// that a subtree removed by the depth cap still shows up in the difference
926 /// between original and retained.
927 fn collect_original_counts(value: &Value, stats: &mut BoundStats) {
928 match value {
929 Value::String(text) => {
930 stats.strings_total += 1;
931 stats.string_bytes_original += text.len() as u64;
932 }
933 Value::Array(items) => {
934 stats.array_items_original += items.len() as u64;
935 for item in items {
936 collect_original_counts(item, stats);
937 }
938 }
939 Value::Object(map) => {
940 for item in map.values() {
941 collect_original_counts(item, stats);
942 }
943 }
944 Value::Null | Value::Bool(_) | Value::Number(_) => {}
945 }
946 }
947
948 fn bound_value(
949 value: &mut Value,
950 caps: &Caps,
951 stats: &mut BoundStats,
952 reasons: &mut BTreeSet<&'static str>,
953 depth: usize,
954 ) {
955 match value {
956 Value::String(text) => {
957 let (truncated, was_truncated) =
958 truncate_string_grapheme_safe(text, caps.max_string_bytes);
959 if was_truncated {
960 *text = truncated;
961 stats.strings_truncated += 1;
962 reasons.insert("string_bytes_cap");
963 }
964 stats.strings_retained += 1;
965 stats.string_bytes_retained += text.len() as u64;
966 }
967 Value::Array(items) => {
968 if depth >= caps.max_depth {
969 omit_for_depth(value, stats, reasons);
970 return;
971 }
972 if items.len() > caps.max_array_items {
973 items.truncate(caps.max_array_items);
974 reasons.insert("array_items_cap");
975 }
976 stats.array_items_retained += items.len() as u64;
977 for item in items {
978 bound_value(item, caps, stats, reasons, depth + 1);
979 }
980 }
981 Value::Object(map) => {
982 if depth >= caps.max_depth {
983 omit_for_depth(value, stats, reasons);
984 return;
985 }
986 for item in map.values_mut() {
987 bound_value(item, caps, stats, reasons, depth + 1);
988 }
989 }
990 Value::Null | Value::Bool(_) | Value::Number(_) => {}
991 }
992 }
993
994 /// Replace a too-deep subtree with the structural marker. The marker is a
995 /// string that really is emitted, so it counts toward the retained totals —
996 /// otherwise `string_bytes_retained` would understate the artifact it
997 /// describes.
998 fn omit_for_depth(value: &mut Value, stats: &mut BoundStats, reasons: &mut BTreeSet<&'static str>) {
999 stats.depth_omissions += 1;
1000 reasons.insert("depth_cap");
1001 *value = Value::String(DEPTH_OMISSION_MARKER.to_string());
1002 stats.strings_retained += 1;
1003 stats.string_bytes_retained += DEPTH_OMISSION_MARKER.len() as u64;
1004 }
1005
1006 /// UTF-8/grapheme-safe truncation: never splits a grapheme cluster, and the
1007 /// retained bytes (including the ellipsis marker) never exceed the cap.
1008 ///
1009 /// When `max_bytes` is below the ellipsis's own 3 bytes there is no way to
1010 /// emit both content and a truncation marker inside the cap. The honest
1011 /// answer is the empty string plus `true`: the caller records a truncation,
1012 /// and no partial content escapes under a cap it does not fit.
1013 fn truncate_string_grapheme_safe(text: &str, max_bytes: usize) -> (String, bool) {
1014 if text.len() <= max_bytes {
1015 return (text.to_string(), false);
1016 }
1017 if max_bytes < '…'.len_utf8() {
1018 return (String::new(), true);
1019 }
1020 let budget = max_bytes - '…'.len_utf8();
1021 let mut out = String::new();
1022 for grapheme in UnicodeSegmentation::graphemes(text, true) {
1023 if out.len() + grapheme.len() > budget {
1024 break;
1025 }
1026 out.push_str(grapheme);
1027 }
1028 out.push('…');
1029 (out, true)
1030 }
1031
1032 // === Canonical serialization (deterministic, recursively sorted keys) ===
1033
1034 fn canonical_string(value: &Value) -> String {
1035 let mut out = String::new();
1036 write_canonical(value, &mut out);
1037 out
1038 }
1039
1040 fn write_canonical(value: &Value, out: &mut String) {
1041 match value {
1042 Value::Null => out.push_str("null"),
1043 Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
1044 Value::Number(number) => {
1045 let _ = write!(out, "{number}");
1046 }
1047 Value::String(text) => {
1048 let encoded = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string());
1049 out.push_str(&encoded);
1050 }
1051 Value::Array(items) => {
1052 out.push('[');
1053 for (index, item) in items.iter().enumerate() {
1054 if index > 0 {
1055 out.push(',');
1056 }
1057 write_canonical(item, out);
1058 }
1059 out.push(']');
1060 }
1061 Value::Object(map) => {
1062 let mut entries: Vec<(&String, &Value)> = map.iter().collect();
1063 entries.sort_by(|left, right| left.0.cmp(right.0));
1064 out.push('{');
1065 for (index, (key, item)) in entries.iter().enumerate() {
1066 if index > 0 {
1067 out.push(',');
1068 }
1069 let encoded = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string());
1070 out.push_str(&encoded);
1071 out.push(':');
1072 write_canonical(item, out);
1073 }
1074 out.push('}');
1075 }
1076 }
1077 }
1078
1079 // === Envelope assembly ===
1080
1081 fn render_copy(app: &App, kind: &CopyKind, caps: &Caps) -> Result<String, String> {
1082 let (kind_label, mut selector, mut payload) = build_payload(app, kind)?;
1083 let labels = PathLabels::new(&app.workspace);
1084
1085 // The selector is echoed verbatim into the receipt, so it goes through
1086 // the same redaction as the payload and gets its own tight byte bound.
1087 let mut selector_keys = KeyStats::default();
1088 redact_payload(&mut selector, &labels, &mut selector_keys);
1089 bound_selector(&mut selector);
1090
1091 let mut keys = KeyStats::default();
1092 redact_payload(&mut payload, &labels, &mut keys);
1093
1094 // Fit the byte cap by tightening the content caps before ever
1095 // considering a payload omission.
1096 let mut effective = *caps;
1097 for _ in 0..4 {
1098 let encoded = encode_attempt(
1099 kind_label, &selector, &payload, &effective, caps, &keys, false,
1100 );
1101 if encoded.len() <= caps.max_output_bytes {
1102 return Ok(encoded);
1103 }
1104 effective.max_string_bytes = (effective.max_string_bytes / 2).max(64);
1105 effective.max_array_items = (effective.max_array_items / 2).max(1);
1106 effective.max_depth = effective.max_depth.saturating_sub(2).max(2);
1107 }
1108
1109 // Last resort: emit receipt metadata only. If even that exceeds the cap,
1110 // fail closed rather than emit an over-cap artifact.
1111 let encoded = encode_attempt(
1112 kind_label, &selector, &payload, &effective, caps, &keys, true,
1113 );
1114 if encoded.len() <= caps.max_output_bytes {
1115 return Ok(encoded);
1116 }
1117 Err(tr(app.ui_locale, MessageId::CmdStructcopyReceiptTooLarge)
1118 .replace("{bytes}", &caps.max_output_bytes.to_string()))
1119 }
1120
1121 /// Bound the selector independently of the payload caps. Selectors are
1122 /// scalars, so this only has to handle the string case.
1123 fn bound_selector(selector: &mut Value) {
1124 if let Value::String(text) = selector {
1125 let (bounded, _) = truncate_string_grapheme_safe(text, MAX_SELECTOR_BYTES);
1126 *text = bounded;
1127 }
1128 }
1129
1130 fn encode_attempt(
1131 kind_label: &str,
1132 selector: &Value,
1133 payload: &Value,
1134 effective: &Caps,
1135 hard: &Caps,
1136 keys: &KeyStats,
1137 omit_payload: bool,
1138 ) -> String {
1139 let mut candidate = payload.clone();
1140 let mut stats = BoundStats::default();
1141 let mut reasons = BTreeSet::new();
1142 collect_original_counts(&candidate, &mut stats);
1143 bound_value(&mut candidate, effective, &mut stats, &mut reasons, 0);
1144 let mut retained_keys = RetainedKeyStats::default();
1145 collect_retained_key_stats(&candidate, keys, &mut Vec::new(), &mut retained_keys);
1146 if effective != hard {
1147 reasons.insert("caps_tightened_output_bytes_cap");
1148 }
1149 if retained_keys.truncated > 0 {
1150 reasons.insert("object_key_bytes_cap");
1151 }
1152 if retained_keys.deduped > 0 {
1153 reasons.insert("object_key_collision");
1154 }
1155 let emitted = if omit_payload {
1156 // Nothing from the bounding pass was emitted, so every retained
1157 // counter and every bounding reason would be a claim about an
1158 // artifact that does not exist. Originals stay; the rest resets.
1159 reasons.clear();
1160 reasons.insert("payload_omitted_output_bytes_cap");
1161 stats.strings_retained = 0;
1162 stats.strings_truncated = 0;
1163 stats.string_bytes_retained = 0;
1164 stats.array_items_retained = 0;
1165 stats.depth_omissions = 0;
1166 retained_keys = RetainedKeyStats::default();
1167 Value::Null
1168 } else {
1169 candidate
1170 };
1171 let envelope = assemble_envelope(
1172 kind_label,
1173 selector,
1174 &emitted,
1175 &stats,
1176 keys,
1177 &retained_keys,
1178 &reasons,
1179 effective,
1180 hard,
1181 );
1182 canonical_string(&envelope)
1183 }
1184
1185 #[allow(clippy::too_many_arguments)]
1186 fn assemble_envelope(
1187 kind: &str,
1188 selector: &Value,
1189 payload: &Value,
1190 stats: &BoundStats,
1191 original_keys: &KeyStats,
1192 retained_keys: &RetainedKeyStats,
1193 reasons: &BTreeSet<&'static str>,
1194 effective: &Caps,
1195 hard: &Caps,
1196 ) -> Value {
1197 json!({
1198 "object": payload,
1199 "receipt": {
1200 "schema": SCHEMA_ID,
1201 "human_only": true,
1202 "kind": kind,
1203 "selector": selector,
1204 "redaction": REDACTION_CONTRACT,
1205 // `caps` is the declared contract; `applied_caps` is what this
1206 // artifact was actually bounded with. They differ whenever the
1207 // output-byte cap forced a tightening pass.
1208 "caps": caps_value(hard),
1209 "applied_caps": caps_value(effective),
1210 "counts": {
1211 "strings_total": stats.strings_total,
1212 "strings_retained": stats.strings_retained,
1213 "strings_truncated": stats.strings_truncated,
1214 "string_bytes_original": stats.string_bytes_original,
1215 "string_bytes_retained": stats.string_bytes_retained,
1216 "array_items_original": stats.array_items_original,
1217 "array_items_retained": stats.array_items_retained,
1218 "depth_omissions": stats.depth_omissions,
1219 "object_keys_original": original_keys.original_total(),
1220 "object_keys_retained": retained_keys.total,
1221 "object_keys_truncated": retained_keys.truncated,
1222 "object_keys_deduped": retained_keys.deduped,
1223 "payload_bytes": canonical_string(payload).len(),
1224 },
1225 "reasons": reasons.iter().copied().collect::<Vec<_>>(),
1226 }
1227 })
1228 }
1229
1230 fn caps_value(caps: &Caps) -> Value {
1231 json!({
1232 "max_output_bytes": caps.max_output_bytes,
1233 "max_array_items": caps.max_array_items,
1234 "max_string_bytes": caps.max_string_bytes,
1235 "max_key_bytes": MAX_KEY_BYTES,
1236 "max_depth": caps.max_depth,
1237 })
1238 }
1239
1240 #[cfg(test)]
1241 mod tests {
1242 use super::*;
1243 use crate::config::Config;
1244 use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
1245 use crate::tui::app::TuiOptions;
1246 use crate::tui::clipboard::ClipboardHandler;
1247 use codewhale_models::Role;
1248 use codewhale_models::{ImageUrlContent, ToolCaller};
1249 use tempfile::TempDir;
1250
1251 fn test_app(tmpdir: &TempDir) -> App {
1252 let options = TuiOptions {
1253 skills_dir: tmpdir.path().join("skills"),
1254 memory_path: tmpdir.path().join("memory.md"),
1255 notes_path: tmpdir.path().join("notes.txt"),
1256 mcp_config_path: tmpdir.path().join("mcp.json"),
1257 ..crate::test_support::test_tui_options(tmpdir.path())
1258 };
1259 let mut app = App::new(options, &Config::default());
1260 app.ui_locale = Locale::En;
1261 app
1262 }
1263
1264 fn stdout_json(result: &CommandResult) -> String {
1265 assert!(!result.is_error, "{:?}", result.message);
1266 result.message.clone().expect("stdout payload")
1267 }
1268
1269 fn parsed(json: &str) -> Value {
1270 serde_json::from_str(json).expect("structcopy output must be valid JSON")
1271 }
1272
1273 fn no_labels() -> PathLabels {
1274 PathLabels { labels: Vec::new() }
1275 }
1276
1277 fn seed_transcript(app: &mut App) {
1278 app.api_messages = std::sync::Arc::new(vec![
1279 Message {
1280 role: Role::User,
1281 content: vec![ContentBlock::Text {
1282 text: "please run the fetch".to_string(),
1283 cache_control: None,
1284 }],
1285 },
1286 Message {
1287 role: Role::Assistant,
1288 content: vec![
1289 ContentBlock::Thinking {
1290 thinking: "private chain of thought".to_string(),
1291 signature: Some("signature-secret".to_string()),
1292 state: None,
1293 },
1294 ContentBlock::ToolUse {
1295 id: "call-7".to_string(),
1296 name: "fetch_url".to_string(),
1297 input: json!({
1298 "url": "https://alice:hunter2@example.com/path?token=abc123&ok=1#frag",
1299 "api_key": "literal-api-secret",
1300 }),
1301 caller: Some(ToolCaller {
1302 caller_type: "code_execution_20250825".to_string(),
1303 tool_id: None,
1304 }),
1305 thought_signature: None,
1306 },
1307 ],
1308 },
1309 Message {
1310 role: Role::User,
1311 content: vec![ContentBlock::ToolResult {
1312 tool_use_id: "call-7".to_string(),
1313 content: "Authorization: Bearer result-secret-token\nfetch ok".to_string(),
1314 is_error: Some(false),
1315 content_blocks: None,
1316 }],
1317 },
1318 ]);
1319 }
1320
1321 #[test]
1322 fn turn_copy_projects_one_item_and_redacts() {
1323 let tmpdir = TempDir::new().expect("tempdir");
1324 let mut app = test_app(&tmpdir);
1325 seed_transcript(&mut app);
1326
1327 let json = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout")));
1328 let value = parsed(&json);
1329 assert_eq!(value["receipt"]["schema"], json!(SCHEMA_ID));
1330 assert_eq!(value["receipt"]["kind"], json!("turn"));
1331 assert_eq!(value["receipt"]["selector"], json!(2));
1332 assert_eq!(value["object"]["role"], json!("assistant"));
1333 let content = value["object"]["content"].as_array().expect("content");
1334 assert_eq!(content[0]["type"], json!("thinking"));
1335 assert!(content[0].get("thinking").is_none());
1336 assert_eq!(
1337 content[0]["omission_code"],
1338 json!("internal_reasoning_and_signature")
1339 );
1340 assert_eq!(content[1]["type"], json!("tool_use"));
1341 assert_eq!(content[1]["caller_type"], json!("code_execution_20250825"));
1342 for forbidden in [
1343 "private chain of thought",
1344 "signature-secret",
1345 "literal-api-secret",
1346 "hunter2",
1347 "abc123",
1348 "frag",
1349 ] {
1350 assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}");
1351 }
1352 // URL userinfo/query/fragment are stripped outright.
1353 assert!(json.contains("https://example.com/path"), "{json}");
1354 assert!(json.contains(SENSITIVE_VALUE_REDACTION_MARKER), "{json}");
1355 for prose in [
1356 "internal context omitted",
1357 "internal reasoning and signature omitted",
1358 "inline or local image payload omitted",
1359 "[redacted private key]",
1360 "Bearer [redacted]",
1361 "[redacted token]",
1362 "[redacted]",
1363 ] {
1364 assert!(!json.contains(prose), "prose marker {prose:?}: {json}");
1365 }
1366 }
1367
1368 #[test]
1369 fn generated_omissions_are_language_neutral_codes() {
1370 let tmpdir = TempDir::new().expect("tempdir");
1371 let mut app = test_app(&tmpdir);
1372 app.api_messages = std::sync::Arc::new(vec![
1373 Message {
1374 role: Role::System,
1375 content: vec![ContentBlock::Text {
1376 text: "must not be copied".to_string(),
1377 cache_control: None,
1378 }],
1379 },
1380 Message {
1381 role: Role::Assistant,
1382 content: vec![ContentBlock::ImageUrl {
1383 image_url: ImageUrlContent {
1384 url: "data:image/png;base64,private".to_string(),
1385 },
1386 }],
1387 },
1388 ]);
1389
1390 let internal = parsed(&stdout_json(&execute_structcopy(
1391 &mut app,
1392 Some("turn 1 stdout"),
1393 )));
1394 assert_eq!(
1395 internal["object"]["omission_code"],
1396 json!("internal_context")
1397 );
1398 assert!(internal["object"].get("omitted").is_none());
1399
1400 let image = parsed(&stdout_json(&execute_structcopy(
1401 &mut app,
1402 Some("turn 2 stdout"),
1403 )));
1404 assert_eq!(
1405 image["object"]["content"][0]["omission_code"],
1406 json!("inline_or_local_image_payload")
1407 );
1408 assert!(image["object"]["content"][0].get("omitted").is_none());
1409
1410 let english = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout")));
1411 app.ui_locale = Locale::ZhHans;
1412 let chinese_ui = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout")));
1413 assert_eq!(
1414 english, chinese_ui,
1415 "machine payload must not vary with the UI locale"
1416 );
1417 }
1418
1419 #[test]
1420 fn tool_copy_pairs_call_and_result() {
1421 let tmpdir = TempDir::new().expect("tempdir");
1422 let mut app = test_app(&tmpdir);
1423 seed_transcript(&mut app);
1424
1425 let json = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout")));
1426 let value = parsed(&json);
1427 assert_eq!(value["receipt"]["kind"], json!("tool"));
1428 assert_eq!(value["receipt"]["selector"], json!("call-7"));
1429 assert_eq!(value["object"]["name"], json!("fetch_url"));
1430 assert_eq!(value["object"]["result"]["found"], json!(true));
1431 assert_eq!(value["object"]["result"]["is_error"], json!(false));
1432 assert!(!json.contains("result-secret-token"), "{json}");
1433
1434 // A call without a result is honest, not fabricated.
1435 app.api_messages_mut()[1]
1436 .content
1437 .push(ContentBlock::ToolUse {
1438 id: "call-lonely".to_string(),
1439 name: "view_image".to_string(),
1440 input: json!({}),
1441 caller: None,
1442 thought_signature: None,
1443 });
1444 let json = stdout_json(&execute_structcopy(
1445 &mut app,
1446 Some("tool call-lonely stdout"),
1447 ));
1448 let value = parsed(&json);
1449 assert_eq!(value["object"]["result"]["found"], json!(false));
1450 }
1451
1452 /// An unknown `Option<bool>` must serialize as JSON `null`. Collapsing it
1453 /// to `false` would assert an outcome nothing observed.
1454 #[test]
1455 fn unknown_optional_booleans_stay_null_and_are_not_dropped() {
1456 assert_eq!(optional_bool(None), Value::Null);
1457 assert_eq!(optional_bool(Some(false)), Value::Bool(false));
1458 assert_eq!(optional_bool(Some(true)), Value::Bool(true));
1459
1460 let tmpdir = TempDir::new().expect("tempdir");
1461 let mut app = test_app(&tmpdir);
1462 app.api_messages = std::sync::Arc::new(vec![
1463 Message {
1464 role: Role::Assistant,
1465 content: vec![ContentBlock::ToolUse {
1466 id: "call-unknown".to_string(),
1467 name: "exec_command".to_string(),
1468 input: json!({}),
1469 // No caller recorded: also an unknown, also null.
1470 caller: None,
1471 thought_signature: None,
1472 }],
1473 },
1474 Message {
1475 role: Role::User,
1476 content: vec![ContentBlock::ToolResult {
1477 tool_use_id: "call-unknown".to_string(),
1478 content: "no error flag was recorded".to_string(),
1479 is_error: None,
1480 content_blocks: None,
1481 }],
1482 },
1483 ]);
1484
1485 // Tool-pair projection.
1486 let json = stdout_json(&execute_structcopy(
1487 &mut app,
1488 Some("tool call-unknown stdout"),
1489 ));
1490 let value = parsed(&json);
1491 let result = value["object"]["result"].as_object().expect("result");
1492 assert!(
1493 result.contains_key("is_error"),
1494 "the unknown flag must be present, not dropped: {json}"
1495 );
1496 assert_eq!(result["is_error"], Value::Null);
1497 assert_ne!(result["is_error"], json!(false));
1498
1499 // Turn projection of the same result block, plus the unknown caller.
1500 let json = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout")));
1501 let value = parsed(&json);
1502 let block = &value["object"]["content"][0];
1503 assert!(
1504 block.as_object().expect("block").contains_key("is_error"),
1505 "{json}"
1506 );
1507 assert_eq!(block["is_error"], Value::Null);
1508
1509 let json = stdout_json(&execute_structcopy(&mut app, Some("turn 1 stdout")));
1510 let value = parsed(&json);
1511 let block = &value["object"]["content"][0];
1512 assert!(
1513 block
1514 .as_object()
1515 .expect("block")
1516 .contains_key("caller_type"),
1517 "{json}"
1518 );
1519 assert_eq!(block["caller_type"], Value::Null);
1520 }
1521
1522 #[test]
1523 fn plan_copy_snapshots_current_plan() {
1524 let tmpdir = TempDir::new().expect("tempdir");
1525 let mut app = test_app(&tmpdir);
1526 {
1527 let mut state = app.plan_state.try_lock().expect("plan lock");
1528 state.update(UpdatePlanArgs {
1529 title: Some("Ship structcopy".to_string()),
1530 plan: vec![
1531 PlanItemArg {
1532 step: "Read seams".to_string(),
1533 status: StepStatus::Completed,
1534 },
1535 PlanItemArg {
1536 step: "Copy exactly one object".to_string(),
1537 status: StepStatus::InProgress,
1538 },
1539 ],
1540 ..Default::default()
1541 });
1542 }
1543
1544 let json = stdout_json(&execute_structcopy(&mut app, Some("plan stdout")));
1545 let value = parsed(&json);
1546 assert_eq!(value["receipt"]["kind"], json!("plan"));
1547 assert_eq!(value["object"]["title"], json!("Ship structcopy"));
1548 let items = value["object"]["items"].as_array().expect("items");
1549 assert_eq!(items.len(), 2);
1550 assert_eq!(items[1]["status"], json!("in_progress"));
1551 }
1552
1553 #[test]
1554 fn workflow_copy_projects_existing_run_without_side_effects() {
1555 let tmpdir = TempDir::new().expect("tempdir");
1556 let mut app = test_app(&tmpdir);
1557 app.current_session_id = Some("structcopy-workflow-test-session".to_string());
1558
1559 // Unknown run, no state: honest error, and the read must not create
1560 // the workflow journal on disk.
1561 let missing = execute_structcopy(&mut app, Some("workflow nope stdout"));
1562 assert!(missing.is_error);
1563 assert!(
1564 missing
1565 .message
1566 .as_deref()
1567 .unwrap_or_default()
1568 .contains("unavailable"),
1569 "{:?}",
1570 missing.message
1571 );
1572 assert!(
1573 !tmpdir.path().join(".codewhale").exists(),
1574 "read-only copy must not create the workflow journal"
1575 );
1576
1577 crate::tools::workflow::structcopy_test_seed_run(
1578 tmpdir.path(),
1579 "structcopy-test-run-alpha",
1580 app.current_session_id
1581 .as_deref()
1582 .expect("test session identity"),
1583 );
1584 let json = stdout_json(&execute_structcopy(
1585 &mut app,
1586 Some("workflow structcopy-test-run-alpha stdout"),
1587 ));
1588 let value = parsed(&json);
1589 assert_eq!(value["receipt"]["kind"], json!("workflow"));
1590 assert_eq!(
1591 value["object"]["run_id"],
1592 json!("structcopy-test-run-alpha")
1593 );
1594 assert_eq!(value["object"]["status"], json!("running"));
1595 assert_eq!(value["object"]["leaf_count"], Value::Null);
1596 assert_eq!(value["object"]["branch_count"], Value::Null);
1597 assert_eq!(value["object"]["control_count"], Value::Null);
1598 assert!(
1599 value["object"].get("source_path").is_none(),
1600 "filesystem paths must not leave the projection: {json}"
1601 );
1602
1603 let unknown = execute_structcopy(&mut app, Some("workflow nope stdout"));
1604 assert!(unknown.is_error);
1605 let message = unknown.message.as_deref().unwrap_or_default();
1606 assert!(message.contains("unavailable"), "{message}");
1607 assert!(
1608 !message.contains("structcopy-test-run-alpha"),
1609 "unavailable errors must not enumerate private run ids: {message}"
1610 );
1611 }
1612
1613 #[test]
1614 fn unavailable_selectors_are_reported_not_fabricated() {
1615 let tmpdir = TempDir::new().expect("tempdir");
1616 let mut app = test_app(&tmpdir);
1617
1618 let empty_turn = execute_structcopy(&mut app, Some("turn 1 stdout"));
1619 assert!(empty_turn.is_error);
1620 assert!(
1621 empty_turn
1622 .message
1623 .as_deref()
1624 .unwrap_or_default()
1625 .contains("unavailable"),
1626 "{:?}",
1627 empty_turn.message
1628 );
1629
1630 let empty_plan = execute_structcopy(&mut app, Some("plan stdout"));
1631 assert!(empty_plan.is_error);
1632 assert!(
1633 empty_plan
1634 .message
1635 .as_deref()
1636 .unwrap_or_default()
1637 .contains("unavailable"),
1638 "{:?}",
1639 empty_plan.message
1640 );
1641
1642 seed_transcript(&mut app);
1643 let out_of_range = execute_structcopy(&mut app, Some("turn 99 stdout"));
1644 assert!(out_of_range.is_error);
1645 assert!(
1646 out_of_range
1647 .message
1648 .as_deref()
1649 .unwrap_or_default()
1650 .contains("unavailable"),
1651 "{:?}",
1652 out_of_range.message
1653 );
1654
1655 let missing_tool = execute_structcopy(&mut app, Some("tool call-nope stdout"));
1656 assert!(missing_tool.is_error);
1657 assert!(
1658 missing_tool
1659 .message
1660 .as_deref()
1661 .unwrap_or_default()
1662 .contains("unavailable"),
1663 "{:?}",
1664 missing_tool.message
1665 );
1666
1667 for bad in [
1668 None,
1669 Some(""),
1670 Some("turn 0"),
1671 Some("turn x"),
1672 Some("turn -1"),
1673 Some("turn 99999999999999999999999999"),
1674 Some("plan extra"),
1675 Some("tool"),
1676 Some("workflow"),
1677 Some("stdout"),
1678 Some(" "),
1679 ] {
1680 let result = execute_structcopy(&mut app, bad);
1681 assert!(result.is_error, "{bad:?}: {:?}", result.message);
1682 }
1683 }
1684
1685 #[test]
1686 fn command_feedback_uses_the_active_locale() {
1687 let tmpdir = TempDir::new().expect("tempdir");
1688 let mut app = test_app(&tmpdir);
1689 app.ui_locale = Locale::ZhHans;
1690
1691 let invalid = execute_structcopy(&mut app, Some("unknown"));
1692 assert!(invalid.is_error);
1693 let expected = tr(Locale::ZhHans, MessageId::CmdStructcopyUsageError)
1694 .replace("{usage}", COMMAND_INFO.usage);
1695 assert!(
1696 invalid
1697 .message
1698 .as_deref()
1699 .is_some_and(|message| message.ends_with(&expected)),
1700 "{:?}",
1701 invalid.message
1702 );
1703
1704 let unavailable = execute_structcopy(&mut app, Some("plan stdout"));
1705 assert!(unavailable.is_error);
1706 let expected = tr(Locale::ZhHans, MessageId::CmdStructcopyUnavailable).replace(
1707 "{kind}",
1708 &tr(Locale::ZhHans, MessageId::CmdStructcopyKindPlan),
1709 );
1710 assert!(
1711 unavailable
1712 .message
1713 .as_deref()
1714 .is_some_and(|message| message.ends_with(&expected)),
1715 "{:?}",
1716 unavailable.message
1717 );
1718 }
1719
1720 /// An unavailable selector is never echoed. An available selector is
1721 /// scrubbed and bounded in both the receipt and copied object.
1722 #[test]
1723 fn hostile_selectors_are_redacted_and_bounded_everywhere() {
1724 let tmpdir = TempDir::new().expect("tempdir");
1725 let mut app = test_app(&tmpdir);
1726 let workspace = tmpdir.path().to_string_lossy().into_owned();
1727 seed_transcript(&mut app);
1728
1729 // Unavailable selector: no attacker-influenced bytes are echoed.
1730 let hostile = format!(
1731 "\u{1b}[31mred\u{1b}[0m-Bearer-abcdef1234567890-https://u:p@evil.test/x?k=v#f-{workspace}-{}",
1732 "A".repeat(4096)
1733 );
1734 let result = execute_structcopy(&mut app, Some(&format!("tool {hostile} stdout")));
1735 assert!(result.is_error);
1736 let message = result.message.as_deref().unwrap_or_default();
1737 assert!(message.len() < 400, "status message unbounded: {message}");
1738 for forbidden in [
1739 "\u{1b}[31m",
1740 "abcdef1234567890",
1741 "u:p@evil.test",
1742 "k=v",
1743 workspace.as_str(),
1744 ] {
1745 assert!(
1746 !message.contains(forbidden),
1747 "leaked {forbidden:?}: {message}"
1748 );
1749 }
1750 assert!(!message.contains('\n'), "status label must be one line");
1751 assert!(message.contains("unavailable"), "{message}");
1752
1753 // Receipt path: a long but *available* selector is bounded too.
1754 let long_id = format!("call-{}", "z".repeat(4096));
1755 app.api_messages_mut()[1]
1756 .content
1757 .push(ContentBlock::ToolUse {
1758 id: long_id.clone(),
1759 name: "exec_command".to_string(),
1760 input: json!({}),
1761 caller: None,
1762 thought_signature: None,
1763 });
1764 let json = stdout_json(&execute_structcopy(
1765 &mut app,
1766 Some(&format!("tool {long_id} stdout")),
1767 ));
1768 let value = parsed(&json);
1769 let selector = value["receipt"]["selector"].as_str().expect("selector");
1770 assert!(
1771 selector.len() <= MAX_SELECTOR_BYTES,
1772 "selector {} bytes exceeds the {MAX_SELECTOR_BYTES}-byte cap",
1773 selector.len()
1774 );
1775 assert!(selector.ends_with('…'), "{selector}");
1776
1777 // Composer selectors cannot contain a whitespace-delimited `Bearer`
1778 // header, so delimiter-shaped bearer tokens are scrubbed too.
1779 for bearer_id in [
1780 "call-Bearer-abcdef1234567890",
1781 "call-Bearer=zyxwvutsrqponmlk",
1782 ] {
1783 app.api_messages_mut()[1]
1784 .content
1785 .push(ContentBlock::ToolUse {
1786 id: bearer_id.to_string(),
1787 name: "exec_command".to_string(),
1788 input: json!({}),
1789 caller: None,
1790 thought_signature: None,
1791 });
1792 let json = stdout_json(&execute_structcopy(
1793 &mut app,
1794 Some(&format!("tool {bearer_id} stdout")),
1795 ));
1796 assert!(!json.contains("abcdef1234567890"), "{json}");
1797 assert!(!json.contains("zyxwvutsrqponmlk"), "{json}");
1798 assert!(json.contains(BEARER_REDACTION_MARKER), "{json}");
1799 }
1800 }
1801
1802 /// Object keys are attacker-influenced too (a model can name a tool-input
1803 /// field anything). Keys must be sanitized, bounded, and de-collided
1804 /// deterministically without dropping a value.
1805 #[test]
1806 fn hostile_object_keys_are_scrubbed_bounded_and_deduped_deterministically() {
1807 let tmpdir = TempDir::new().expect("tempdir");
1808 let mut app = test_app(&tmpdir);
1809 let workspace = tmpdir.path().to_string_lossy().into_owned();
1810
1811 // Three keys that collapse onto the same bounded form, one key with
1812 // ANSI + newlines, and one key carrying a workspace path.
1813 let long_a = format!("k{}A", "x".repeat(MAX_KEY_BYTES));
1814 let long_b = format!("k{}B", "x".repeat(MAX_KEY_BYTES));
1815 let long_c = format!("k{}C", "x".repeat(MAX_KEY_BYTES));
1816 let input = json!({
1817 long_a.clone(): 1,
1818 long_b.clone(): 2,
1819 long_c.clone(): 3,
1820 "\u{1b}[31mansi\u{1b}[0m\nkey": 4,
1821 format!("at {workspace}/src"): 5,
1822 });
1823 app.api_messages = std::sync::Arc::new(vec![Message {
1824 role: Role::Assistant,
1825 content: vec![ContentBlock::ToolUse {
1826 id: "call-keys".to_string(),
1827 name: "exec_command".to_string(),
1828 input,
1829 caller: None,
1830 thought_signature: None,
1831 }],
1832 }]);
1833
1834 let first = stdout_json(&execute_structcopy(&mut app, Some("tool call-keys stdout")));
1835 let second = stdout_json(&execute_structcopy(&mut app, Some("tool call-keys stdout")));
1836 assert_eq!(
1837 first, second,
1838 "key collision handling must be deterministic"
1839 );
1840
1841 let value = parsed(&first);
1842 let object = value["object"]["input"].as_object().expect("input");
1843 // No value is lost to a collision.
1844 assert_eq!(object.len(), 5, "{object:?}");
1845 let mut values: Vec<u64> = object
1846 .values()
1847 .map(|item| item.as_u64().expect("number"))
1848 .collect();
1849 values.sort_unstable();
1850 assert_eq!(values, vec![1, 2, 3, 4, 5]);
1851
1852 for key in object.keys() {
1853 assert!(
1854 key.len() <= MAX_KEY_BYTES,
1855 "key {} bytes exceeds the {MAX_KEY_BYTES}-byte cap",
1856 key.len()
1857 );
1858 assert!(!key.contains('\u{1b}'), "ANSI survived in key {key:?}");
1859 assert!(!key.contains('\n'), "newline survived in key {key:?}");
1860 assert!(!key.contains(&workspace), "workspace path in key {key:?}");
1861 }
1862 assert!(
1863 object.keys().any(|key| key.contains("<workspace>")),
1864 "{object:?}"
1865 );
1866
1867 let counts = &value["receipt"]["counts"];
1868 assert_eq!(
1869 counts["object_keys_original"],
1870 counts["object_keys_retained"]
1871 );
1872 assert_eq!(counts["object_keys_truncated"], json!(3));
1873 assert!(
1874 counts["object_keys_deduped"].as_u64().expect("deduped") >= 2,
1875 "{counts}"
1876 );
1877 let reasons = value["receipt"]["reasons"].as_array().expect("reasons");
1878 assert!(
1879 reasons.contains(&json!("object_key_bytes_cap")),
1880 "{reasons:?}"
1881 );
1882 assert!(
1883 reasons.contains(&json!("object_key_collision")),
1884 "{reasons:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn sensitive_keys_are_classified_after_control_and_ansi_normalization() {
1890 let tmpdir = TempDir::new().expect("tempdir");
1891 let mut app = test_app(&tmpdir);
1892 app.api_messages = std::sync::Arc::new(vec![Message {
1893 role: Role::Assistant,
1894 content: vec![ContentBlock::ToolUse {
1895 id: "call-obfuscated-keys".to_string(),
1896 name: "exec_command".to_string(),
1897 input: json!({
1898 "api\u{1b}[31m_key": "plain-value-that-must-not-leak",
1899 "pass\u{7}word": "another-plain-value-that-must-not-leak",
1900 }),
1901 caller: None,
1902 thought_signature: None,
1903 }],
1904 }]);
1905
1906 let json = stdout_json(&execute_structcopy(
1907 &mut app,
1908 Some("tool call-obfuscated-keys stdout"),
1909 ));
1910 assert!(!json.contains("plain-value-that-must-not-leak"), "{json}");
1911 assert!(
1912 !json.contains("another-plain-value-that-must-not-leak"),
1913 "{json}"
1914 );
1915 let value = parsed(&json);
1916 assert_eq!(
1917 value["object"]["input"]["api_key"],
1918 json!(SENSITIVE_VALUE_REDACTION_MARKER)
1919 );
1920 assert_eq!(
1921 value["object"]["input"]["password"],
1922 json!(SENSITIVE_VALUE_REDACTION_MARKER)
1923 );
1924 }
1925
1926 /// `unique_object_key` must terminate and preserve every value even when
1927 /// the key cap leaves no room at all for a base.
1928 #[test]
1929 fn key_dedup_terminates_under_a_degenerate_cap() {
1930 let mut map = serde_json::Map::new();
1931 for _ in 0..12 {
1932 let (key, _) = unique_object_key(&map, "");
1933 assert!(!map.contains_key(&key), "reused key {key:?}");
1934 map.insert(key, Value::Null);
1935 }
1936 assert_eq!(map.len(), 12, "every insert must survive");
1937
1938 // Deterministic across runs with the same inputs.
1939 let mut replay = serde_json::Map::new();
1940 for _ in 0..12 {
1941 let (key, _) = unique_object_key(&replay, "");
1942 replay.insert(key, Value::Null);
1943 }
1944 let left: Vec<&String> = map.keys().collect();
1945 let right: Vec<&String> = replay.keys().collect();
1946 assert_eq!(left, right);
1947
1948 assert_eq!(decimal_width(0), 1);
1949 assert_eq!(decimal_width(9), 1);
1950 assert_eq!(decimal_width(10), 2);
1951 assert_eq!(decimal_width(999), 3);
1952 assert_eq!(decimal_width(1000), 4);
1953 }
1954
1955 #[test]
1956 fn collision_suffix_reserve_reports_its_own_truncation() {
1957 let tmpdir = TempDir::new().expect("tempdir");
1958 let mut app = test_app(&tmpdir);
1959 let exact = "x".repeat(MAX_KEY_BYTES);
1960 let same_after_flatten = format!("{exact}\n");
1961 app.api_messages = std::sync::Arc::new(vec![Message {
1962 role: Role::Assistant,
1963 content: vec![ContentBlock::ToolUse {
1964 id: "call-reserve".to_string(),
1965 name: "exec_command".to_string(),
1966 input: json!({exact: 1, same_after_flatten: 2}),
1967 caller: None,
1968 thought_signature: None,
1969 }],
1970 }]);
1971
1972 let json = stdout_json(&execute_structcopy(
1973 &mut app,
1974 Some("tool call-reserve stdout"),
1975 ));
1976 let value = parsed(&json);
1977 let input = value["object"]["input"].as_object().expect("input");
1978 assert_eq!(input.len(), 2);
1979 assert!(input.keys().all(|key| key.len() <= MAX_KEY_BYTES));
1980 let counts = &value["receipt"]["counts"];
1981 assert_eq!(counts["object_keys_deduped"], json!(1));
1982 assert_eq!(counts["object_keys_truncated"], json!(1));
1983 let reasons = value["receipt"]["reasons"].as_array().expect("reasons");
1984 assert!(
1985 reasons.contains(&json!("object_key_collision")),
1986 "{reasons:?}"
1987 );
1988 assert!(
1989 reasons.contains(&json!("object_key_bytes_cap")),
1990 "{reasons:?}"
1991 );
1992 }
1993
1994 #[test]
1995 fn output_is_deterministic_with_recursively_sorted_keys() {
1996 let tmpdir = TempDir::new().expect("tempdir");
1997 let mut app = test_app(&tmpdir);
1998 seed_transcript(&mut app);
1999
2000 let first = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout")));
2001 let second = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout")));
2002 assert_eq!(first, second, "output must be byte-for-byte deterministic");
2003
2004 let value = parsed(&first);
2005 let top: Vec<&str> = value
2006 .as_object()
2007 .expect("object")
2008 .keys()
2009 .map(String::as_str)
2010 .collect();
2011 assert_eq!(top, ["object", "receipt"]);
2012 let receipt: Vec<&String> = value["receipt"]
2013 .as_object()
2014 .expect("receipt")
2015 .keys()
2016 .collect();
2017 let mut sorted = receipt.clone();
2018 sorted.sort();
2019 assert_eq!(receipt, sorted, "receipt keys must be sorted");
2020 let counts: Vec<&String> = value["receipt"]["counts"]
2021 .as_object()
2022 .expect("counts")
2023 .keys()
2024 .collect();
2025 let mut sorted_counts = counts.clone();
2026 sorted_counts.sort();
2027 assert_eq!(counts, sorted_counts, "counts keys must be sorted");
2028 let object: Vec<&String> = value["object"]
2029 .as_object()
2030 .expect("object")
2031 .keys()
2032 .collect();
2033 let mut sorted_object = object.clone();
2034 sorted_object.sort();
2035 assert_eq!(object, sorted_object, "object keys must be sorted");
2036 }
2037
2038 #[test]
2039 fn hostile_content_is_redacted_before_serialization() {
2040 let tmpdir = TempDir::new().expect("tempdir");
2041 let mut app = test_app(&tmpdir);
2042 let workspace = tmpdir.path().to_string_lossy().into_owned();
2043 app.api_messages = std::sync::Arc::new(vec![Message {
2044 role: Role::User,
2045 content: vec![ContentBlock::Text {
2046 text: format!(
2047 "escaped \\\"api_key\\\": \\\"sk-escapedsecret99\\\"\n\
2048 bearer: Bearer abcdef1234567890\n\
2049 jwt eyJhbGciOiJIUzI1NiIsFAKE.eyJGQUtFIjoiZml4dHVyZSJ9.FAKEFIXTURESIGNATUREnotasecret000\n\
2050 url https://bob:s3cret@example.com/deep?session_token=xyz&ok=1#section\n\
2051 path {workspace}/src/main.rs"
2052 ),
2053 cache_control: None,
2054 }],
2055 }]);
2056
2057 let json = stdout_json(&execute_structcopy(&mut app, Some("turn 1 stdout")));
2058 for forbidden in [
2059 "sk-escapedsecret99",
2060 "abcdef1234567890",
2061 "eyJhbGciOiJIUzI1NiIs",
2062 "s3cret",
2063 "session_token=xyz",
2064 "section",
2065 workspace.as_str(),
2066 ] {
2067 assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}");
2068 }
2069 assert!(json.contains("https://example.com/deep"), "{json}");
2070 assert!(json.contains("<workspace>/src/main.rs"), "{json}");
2071 assert!(parsed(&json).is_object());
2072 }
2073
2074 /// URLs do not arrive as tidy whitespace-delimited tokens. Wrapped,
2075 /// embedded, uppercased, and malformed forms must all lose their
2076 /// userinfo, query, and fragment.
2077 #[test]
2078 fn urls_lose_userinfo_query_and_fragment_in_hostile_shapes() {
2079 let labels = no_labels();
2080 let cases = [
2081 "(https://u:p@host.test/a?q=1#f)",
2082 "<https://u:p@host.test/a?q=1#f>",
2083 "\"https://u:p@host.test/a?q=1#f\"",
2084 "'https://u:p@host.test/a?q=1#f'",
2085 "see https://u:p@host.test/a?q=1#f.",
2086 "see https://u:p@host.test/a?q=1#f, then",
2087 "[link](https://u:p@host.test/a?q=1#f)",
2088 "prefixhttps://u:p@host.test/a?q=1#f",
2089 "HTTPS://U:P@HOST.TEST/a?q=1#f",
2090 "ws://u:p@host.test/a?q=1#f",
2091 "ftp://u:p@host.test/a?q=1#f",
2092 "postgres://u:p@host.test/db?sslkey=secret#f",
2093 "mongodb://u:p@host.test/db?authSource=admin#f",
2094 "redis://u:p@host.test/0?token=secret#f",
2095 "amqp://u:p@host.test/vhost?token=secret#f",
2096 "ssh://u:p@host.test/repo?identity=secret#f",
2097 "socks5://u:p@host.test/path?token=secret#f",
2098 "trailing`https://u:p@host.test/a?q=1#f`",
2099 "a=https://u:p@host.test/a?q=1#f&b=2",
2100 ];
2101 for case in cases {
2102 let scrubbed = scrub_string(case, &labels);
2103 for forbidden in [
2104 "u:p@",
2105 "q=1",
2106 "#f",
2107 "P@HOST",
2108 "sslkey=secret",
2109 "authSource=admin",
2110 "token=secret",
2111 "identity=secret",
2112 ] {
2113 assert!(
2114 !scrubbed.contains(forbidden),
2115 "{case:?} kept {forbidden:?}: {scrubbed}"
2116 );
2117 }
2118 assert!(
2119 scrubbed.contains("host.test") || scrubbed.contains(URL_OMISSION_MARKER),
2120 "{case:?} -> {scrubbed}"
2121 );
2122 }
2123
2124 // Two URLs in one string: both are scrubbed, order preserved.
2125 let both = scrub_string(
2126 "first https://a:b@one.test/x?y=1#z then https://c:d@two.test/w?v=2#u end",
2127 &labels,
2128 );
2129 assert!(both.contains("one.test"), "{both}");
2130 assert!(both.contains("two.test"), "{both}");
2131 assert!(both.starts_with("first "), "{both}");
2132 assert!(both.ends_with(" end"), "{both}");
2133 for forbidden in ["a:b@", "c:d@", "y=1", "v=2", "#z", "#u"] {
2134 assert!(!both.contains(forbidden), "kept {forbidden:?}: {both}");
2135 }
2136
2137 // Unparseable but scheme-prefixed: fail closed, do not pass through.
2138 for hostile in [
2139 "https://",
2140 "https://[not-an-ipv6:1]/x?token=leak#f",
2141 "http://user:pw@:99999/x?token=leak",
2142 ] {
2143 let scrubbed = scrub_string(hostile, &labels);
2144 assert!(!scrubbed.contains("token=leak"), "{hostile} -> {scrubbed}");
2145 assert!(!scrubbed.contains("user:pw@"), "{hostile} -> {scrubbed}");
2146 }
2147
2148 // An ANSI escape spliced into a scheme must not hide the URL from
2149 // the scanner: `sanitize_text` runs first.
2150 let hidden = scrub_string("htt\u{1b}[0mps://u:p@host.test/a?q=1#f", &labels);
2151 assert!(!hidden.contains("u:p@"), "{hidden}");
2152 assert!(!hidden.contains("q=1"), "{hidden}");
2153
2154 // Text with no URL is untouched.
2155 assert_eq!(
2156 scrub_string("plain text, no url", &labels),
2157 "plain text, no url"
2158 );
2159 }
2160
2161 /// Workspace/home paths retain useful labels. Every other absolute POSIX,
2162 /// drive-letter, and UNC path is removed from copied values.
2163 #[test]
2164 fn path_labels_preserve_known_roots_and_scrub_every_other_absolute_path() {
2165 let tmpdir = TempDir::new().expect("tempdir");
2166 let workspace = tmpdir.path().to_path_buf();
2167 let labels = PathLabels::new(&workspace);
2168 let literal = workspace.to_string_lossy().into_owned();
2169
2170 let folded = labels.apply(&format!("open {literal}/src/main.rs now"));
2171 assert_eq!(folded, "open <workspace>/src/main.rs now");
2172 assert!(!folded.contains(&literal));
2173 assert_eq!(
2174 scrub_string(&format!("open {literal}/src/main.rs now"), &labels),
2175 "open <workspace>/src/main.rs now"
2176 );
2177
2178 // The canonical form folds too (macOS /var -> /private/var).
2179 if let Ok(canonical) = workspace.canonicalize() {
2180 let canonical = canonical.to_string_lossy().into_owned();
2181 let folded = labels.apply(&format!("open {canonical}/src/main.rs"));
2182 assert_eq!(folded, "open <workspace>/src/main.rs");
2183 }
2184
2185 // Repeated occurrences all fold, not just the first.
2186 let folded = labels.apply(&format!("{literal}/a and {literal}/b"));
2187 assert_eq!(folded, "<workspace>/a and <workspace>/b");
2188
2189 // Prefix folding itself only handles known roots; the composed scrub
2190 // removes every foreign absolute path before serialization.
2191 let foreign = "/opt/other/place/file.txt";
2192 assert_eq!(labels.apply(foreign), foreign);
2193 assert_eq!(scrub_string(foreign, &labels), PATH_OMISSION_MARKER);
2194 assert_eq!(
2195 scrub_string(r"C:\Users\customer\secret.txt", &labels),
2196 PATH_OMISSION_MARKER
2197 );
2198 assert_eq!(
2199 scrub_string(r"\\server\private\customer.txt", &labels),
2200 PATH_OMISSION_MARKER
2201 );
2202 let spaced = scrub_string(
2203 "open /Volumes/Client Name/private file.txt then continue\nsecond line",
2204 &labels,
2205 );
2206 assert_eq!(spaced, format!("open {PATH_OMISSION_MARKER}\nsecond line"));
2207
2208 // A workspace nested inside $HOME folds to <workspace>, not <home>.
2209 if let Some(home) = std::env::var_os("HOME") {
2210 let home = home.to_string_lossy().into_owned();
2211 if home.len() > 3 {
2212 let nested = PathLabels::new(Path::new(&format!("{home}/nested/ws")));
2213 let folded = nested.apply(&format!("{home}/nested/ws/src"));
2214 assert_eq!(folded, "<workspace>/src");
2215 assert_eq!(
2216 nested.apply(&format!("{home}/elsewhere")),
2217 "<home>/elsewhere"
2218 );
2219 assert_eq!(
2220 scrub_string(&format!("{home}/elsewhere/file.rs"), &nested),
2221 "<home>/elsewhere/file.rs"
2222 );
2223 }
2224 }
2225 }
2226
2227 #[test]
2228 fn path_labels_require_component_boundaries_and_preserve_repeated_roots() {
2229 let labels = PathLabels {
2230 labels: vec![
2231 ("/opt/app".to_string(), "<workspace>"),
2232 ("/Users/alice".to_string(), "<home>"),
2233 ],
2234 };
2235
2236 assert_eq!(labels.apply("/opt/app"), "<workspace>");
2237 assert_eq!(labels.apply("/opt/app/src"), "<workspace>/src");
2238 assert_eq!(labels.apply(r"/opt/app\src"), r"<workspace>\src");
2239 assert_eq!(
2240 labels.apply("/opt/app/a and /opt/app/b"),
2241 "<workspace>/a and <workspace>/b"
2242 );
2243 assert_eq!(labels.apply("/Users/alice"), "<home>");
2244 assert_eq!(
2245 labels.apply("/Users/alice/project and /Users/alice/other"),
2246 "<home>/project and <home>/other"
2247 );
2248
2249 for collision in [
2250 "/opt/application/customer",
2251 "/opt/app-old/customer",
2252 "/Users/alice-old/private",
2253 "/Users/alice2/private",
2254 ] {
2255 assert_eq!(
2256 labels.apply(collision),
2257 collision,
2258 "near-prefix path must not receive a trusted label"
2259 );
2260 assert_eq!(
2261 scrub_string(collision, &labels),
2262 PATH_OMISSION_MARKER,
2263 "near-prefix path must remain foreign and be redacted"
2264 );
2265 }
2266 }
2267
2268 #[test]
2269 fn absolute_paths_are_scrubbed_from_values_keys_and_selectors() {
2270 let tmpdir = TempDir::new().expect("tempdir");
2271 let mut app = test_app(&tmpdir);
2272 let call_id = "call=/opt/customer/private-id";
2273 app.api_messages = std::sync::Arc::new(vec![Message {
2274 role: Role::Assistant,
2275 content: vec![ContentBlock::ToolUse {
2276 id: call_id.to_string(),
2277 name: "exec_command".to_string(),
2278 input: json!({
2279 "/Volumes/ClientSecret/source.rs": "open C:\\Users\\customer\\secret.txt",
2280 "unc": r"\\server\private\customer.txt",
2281 }),
2282 caller: None,
2283 thought_signature: None,
2284 }],
2285 }]);
2286
2287 let json = stdout_json(&execute_structcopy(
2288 &mut app,
2289 Some(&format!("tool {call_id} stdout")),
2290 ));
2291 for forbidden in [
2292 "/opt/customer/private-id",
2293 "/Volumes/ClientSecret/source.rs",
2294 r"C:\Users\customer\secret.txt",
2295 r"\\server\private\customer.txt",
2296 "ClientSecret",
2297 "customer",
2298 ] {
2299 assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}");
2300 }
2301 assert!(json.contains(PATH_OMISSION_MARKER), "{json}");
2302 }
2303
2304 #[test]
2305 fn string_bytes_cap_truncates_grapheme_safely() {
2306 let tmpdir = TempDir::new().expect("tempdir");
2307 let mut app = test_app(&tmpdir);
2308 app.api_messages = std::sync::Arc::new(vec![Message {
2309 role: Role::User,
2310 content: vec![ContentBlock::Text {
2311 text: "emoji cluster test: 👨‍👩‍👧‍👦🏳️‍🌈 repeated many times over".repeat(20),
2312 cache_control: None,
2313 }],
2314 }]);
2315 let caps = Caps {
2316 max_string_bytes: 40,
2317 ..DEFAULT_CAPS
2318 };
2319 let json = render_copy(&app, &CopyKind::Turn(1), &caps).expect("render");
2320 let value = parsed(&json);
2321 let text = value["object"]["content"][0]["text"]
2322 .as_str()
2323 .expect("text");
2324 assert!(text.ends_with('…'), "{text}");
2325 assert!(text.len() <= 40, "{} bytes", text.len());
2326 assert_eq!(value["receipt"]["counts"]["strings_truncated"], json!(1));
2327 assert_eq!(value["receipt"]["reasons"], json!(["string_bytes_cap"]));
2328 let original = value["receipt"]["counts"]["string_bytes_original"]
2329 .as_u64()
2330 .expect("original");
2331 let retained = value["receipt"]["counts"]["string_bytes_retained"]
2332 .as_u64()
2333 .expect("retained");
2334 assert!(original > retained);
2335 }
2336
2337 /// A cap below the ellipsis's own 3 bytes has no representable
2338 /// "truncated" form. It must stay in-bounds and stay honest rather than
2339 /// panic, overflow, or emit partial content.
2340 #[test]
2341 fn string_cap_below_the_ellipsis_is_safe() {
2342 for max_bytes in 0..=4usize {
2343 for text in ["", "a", "ab", "abc", "abcd", "é", "👨‍👩‍👧‍👦", "héllo wörld"]
2344 {
2345 let (out, truncated) = truncate_string_grapheme_safe(text, max_bytes);
2346 assert!(
2347 out.len() <= max_bytes.max(text.len()),
2348 "cap {max_bytes} text {text:?} -> {out:?}"
2349 );
2350 if text.len() <= max_bytes {
2351 assert!(!truncated);
2352 assert_eq!(out, text);
2353 } else {
2354 assert!(truncated, "cap {max_bytes} text {text:?}");
2355 assert!(
2356 out.len() <= max_bytes,
2357 "cap {max_bytes} text {text:?} -> {} bytes",
2358 out.len()
2359 );
2360 if max_bytes < 3 {
2361 assert!(
2362 out.is_empty(),
2363 "no partial content may escape below the marker size: {out:?}"
2364 );
2365 } else {
2366 assert!(out.ends_with('…'), "cap {max_bytes} -> {out:?}");
2367 }
2368 }
2369 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
2370 }
2371 }
2372
2373 // End to end: the whole pipeline survives a sub-ellipsis cap and the
2374 // receipt still reports the truncation.
2375 let tmpdir = TempDir::new().expect("tempdir");
2376 let mut app = test_app(&tmpdir);
2377 app.api_messages = std::sync::Arc::new(vec![Message {
2378 role: Role::User,
2379 content: vec![ContentBlock::Text {
2380 text: "a longer body that cannot fit".to_string(),
2381 cache_control: None,
2382 }],
2383 }]);
2384 let caps = Caps {
2385 max_string_bytes: 1,
2386 ..DEFAULT_CAPS
2387 };
2388 let json = render_copy(&app, &CopyKind::Turn(1), &caps).expect("render");
2389 let value = parsed(&json);
2390 assert_eq!(value["object"]["content"][0]["text"], json!(""));
2391 assert!(
2392 value["receipt"]["counts"]["strings_truncated"]
2393 .as_u64()
2394 .expect("truncated")
2395 >= 1
2396 );
2397 }
2398
2399 #[test]
2400 fn array_items_cap_counts_original_and_retained_exactly() {
2401 let tmpdir = TempDir::new().expect("tempdir");
2402 let app = test_app(&tmpdir);
2403 {
2404 let mut state = app.plan_state.try_lock().expect("plan lock");
2405 state.update(UpdatePlanArgs {
2406 plan: (0..10)
2407 .map(|index| PlanItemArg {
2408 step: format!("step {index}"),
2409 status: StepStatus::Pending,
2410 })
2411 .collect(),
2412 ..Default::default()
2413 });
2414 }
2415 let caps = Caps {
2416 max_array_items: 3,
2417 ..DEFAULT_CAPS
2418 };
2419 let json = render_copy(&app, &CopyKind::Plan, &caps).expect("render");
2420 let value = parsed(&json);
2421 assert_eq!(value["object"]["items"].as_array().expect("items").len(), 3);
2422 assert_eq!(
2423 value["receipt"]["counts"]["array_items_original"],
2424 json!(10)
2425 );
2426 assert_eq!(value["receipt"]["counts"]["array_items_retained"], json!(3));
2427 assert_eq!(value["receipt"]["reasons"], json!(["array_items_cap"]));
2428 }
2429
2430 #[test]
2431 fn depth_cap_omits_deep_subtrees() {
2432 let tmpdir = TempDir::new().expect("tempdir");
2433 let mut app = test_app(&tmpdir);
2434 app.api_messages = std::sync::Arc::new(vec![Message {
2435 role: Role::Assistant,
2436 content: vec![ContentBlock::ToolUse {
2437 id: "call-deep".to_string(),
2438 name: "exec_command".to_string(),
2439 input: json!({"a": {"b": {"c": {"d": {"e": "too deep"}}}}}),
2440 caller: None,
2441 thought_signature: None,
2442 }],
2443 }]);
2444 let caps = Caps {
2445 max_depth: 3,
2446 ..DEFAULT_CAPS
2447 };
2448 let json =
2449 render_copy(&app, &CopyKind::Tool("call-deep".to_string()), &caps).expect("render");
2450 let value = parsed(&json);
2451 assert!(json.contains(DEPTH_OMISSION_MARKER), "{json}");
2452 assert!(!json.contains("too deep"), "{json}");
2453 let omissions = value["receipt"]["counts"]["depth_omissions"]
2454 .as_u64()
2455 .expect("omissions");
2456 assert!(omissions >= 1, "{omissions}");
2457 assert!(
2458 value["receipt"]["reasons"]
2459 .as_array()
2460 .expect("reasons")
2461 .contains(&json!("depth_cap"))
2462 );
2463 }
2464
2465 /// The original counts describe the full redacted tree; the retained
2466 /// counts describe exactly what was emitted, marker strings included.
2467 /// Both must be checkable against the artifact itself.
2468 #[test]
2469 fn counts_stay_exact_across_a_depth_omission() {
2470 let tmpdir = TempDir::new().expect("tempdir");
2471 let mut app = test_app(&tmpdir);
2472 // Two strings and two array items live below the depth cut, plus one
2473 // string and one array item above it.
2474 app.api_messages = std::sync::Arc::new(vec![Message {
2475 role: Role::Assistant,
2476 content: vec![ContentBlock::ToolUse {
2477 id: "call-counts".to_string(),
2478 name: "exec_command".to_string(),
2479 input: json!({
2480 "shallow": ["kept"],
2481 "deep": {"one": {"two": ["cut-a", "cut-b"]}},
2482 }),
2483 caller: None,
2484 thought_signature: None,
2485 }],
2486 }]);
2487 let caps = Caps {
2488 max_depth: 3,
2489 ..DEFAULT_CAPS
2490 };
2491 let json =
2492 render_copy(&app, &CopyKind::Tool("call-counts".to_string()), &caps).expect("render");
2493 let value = parsed(&json);
2494 let counts = &value["receipt"]["counts"];
2495
2496 // Independently recount the emitted object and compare.
2497 let mut emitted = BoundStats::default();
2498 collect_original_counts(&value["object"], &mut emitted);
2499 assert_eq!(
2500 counts["strings_retained"].as_u64().expect("retained"),
2501 emitted.strings_total,
2502 "retained string count must match the emitted artifact: {json}"
2503 );
2504 assert_eq!(
2505 counts["string_bytes_retained"]
2506 .as_u64()
2507 .expect("retained bytes"),
2508 emitted.string_bytes_original,
2509 "retained bytes must include the depth marker: {json}"
2510 );
2511 assert_eq!(
2512 counts["array_items_retained"].as_u64().expect("items"),
2513 emitted.array_items_original,
2514 "{json}"
2515 );
2516
2517 // Originals cover the *whole* tree, including the omitted subtree.
2518 assert!(
2519 counts["strings_total"].as_u64().expect("total")
2520 > counts["strings_retained"].as_u64().expect("retained"),
2521 "originals must count strings under the depth cut: {counts}"
2522 );
2523 assert!(
2524 counts["array_items_original"].as_u64().expect("original")
2525 > counts["array_items_retained"].as_u64().expect("retained"),
2526 "originals must count array items under the depth cut: {counts}"
2527 );
2528 assert_eq!(counts["depth_omissions"], json!(1));
2529 assert!(
2530 counts["object_keys_original"]
2531 .as_u64()
2532 .expect("original keys")
2533 > counts["object_keys_retained"]
2534 .as_u64()
2535 .expect("retained keys"),
2536 "keys under the depth cut must be original-only: {counts}"
2537 );
2538 }
2539
2540 #[test]
2541 fn omitted_key_transformations_do_not_claim_emitted_reasons() {
2542 let tmpdir = TempDir::new().expect("tempdir");
2543 let mut app = test_app(&tmpdir);
2544 let long_a = format!("{}A", "private-key-name-".repeat(32));
2545 let long_b = format!("{}B", "private-key-name-".repeat(32));
2546 app.api_messages = std::sync::Arc::new(vec![Message {
2547 role: Role::Assistant,
2548 content: vec![ContentBlock::ToolUse {
2549 id: "call-deep-keys".to_string(),
2550 name: "exec_command".to_string(),
2551 input: json!({"deep": {"one": {long_a: 1, long_b: 2}}}),
2552 caller: None,
2553 thought_signature: None,
2554 }],
2555 }]);
2556 let caps = Caps {
2557 max_depth: 3,
2558 ..DEFAULT_CAPS
2559 };
2560 let json = render_copy(&app, &CopyKind::Tool("call-deep-keys".to_string()), &caps)
2561 .expect("render");
2562 let value = parsed(&json);
2563 let counts = &value["receipt"]["counts"];
2564 assert!(
2565 counts["object_keys_original"].as_u64().expect("original")
2566 > counts["object_keys_retained"].as_u64().expect("retained"),
2567 "{counts}"
2568 );
2569 assert_eq!(counts["object_keys_truncated"], json!(0));
2570 assert_eq!(counts["object_keys_deduped"], json!(0));
2571 let reasons = value["receipt"]["reasons"].as_array().expect("reasons");
2572 assert!(
2573 !reasons.contains(&json!("object_key_bytes_cap")),
2574 "{reasons:?}"
2575 );
2576 assert!(
2577 !reasons.contains(&json!("object_key_collision")),
2578 "{reasons:?}"
2579 );
2580 }
2581
2582 #[test]
2583 fn output_bytes_cap_omits_payload_then_fails_closed() {
2584 let tmpdir = TempDir::new().expect("tempdir");
2585 let mut app = test_app(&tmpdir);
2586 {
2587 let mut state = app.plan_state.try_lock().expect("plan lock");
2588 state.update(UpdatePlanArgs {
2589 title: Some("large plan".to_string()),
2590 plan: (0..60)
2591 .map(|index| PlanItemArg {
2592 step: format!("step {index}: {}", "padding ".repeat(40)),
2593 status: StepStatus::Pending,
2594 })
2595 .collect(),
2596 ..Default::default()
2597 });
2598 }
2599
2600 // Tight byte cap: payload must be omitted while the receipt survives.
2601 let caps = Caps {
2602 max_output_bytes: 2 * 1024,
2603 ..DEFAULT_CAPS
2604 };
2605 let json = render_copy(&app, &CopyKind::Plan, &caps).expect("render");
2606 assert!(json.len() <= 2 * 1024, "{} bytes", json.len());
2607 let value = parsed(&json);
2608 assert_eq!(value["object"], Value::Null);
2609 let reasons = value["receipt"]["reasons"].as_array().expect("reasons");
2610 assert!(
2611 reasons.contains(&json!("payload_omitted_output_bytes_cap")),
2612 "{reasons:?}"
2613 );
2614 // Nothing was emitted, so no retained counter and no bounding reason
2615 // may claim otherwise.
2616 for retained in [
2617 "array_items_retained",
2618 "string_bytes_retained",
2619 "strings_retained",
2620 "strings_truncated",
2621 "depth_omissions",
2622 "object_keys_retained",
2623 "object_keys_truncated",
2624 "object_keys_deduped",
2625 ] {
2626 assert_eq!(
2627 value["receipt"]["counts"][retained],
2628 json!(0),
2629 "{retained} must be zero when nothing was emitted: {json}"
2630 );
2631 }
2632 assert_eq!(reasons.len(), 1, "{reasons:?}");
2633 assert_eq!(
2634 value["receipt"]["counts"]["array_items_original"],
2635 json!(60)
2636 );
2637 assert!(
2638 value["receipt"]["counts"]["object_keys_original"]
2639 .as_u64()
2640 .expect("original keys")
2641 > 0
2642 );
2643
2644 // Below the metadata floor the command fails closed and emits nothing.
2645 let tiny = Caps {
2646 max_output_bytes: 64,
2647 ..DEFAULT_CAPS
2648 };
2649 let err = render_copy(&app, &CopyKind::Plan, &tiny).expect_err("must fail closed");
2650 assert!(err.contains("refusing to emit"), "{err}");
2651 let result = execute_structcopy(&mut app, Some("plan stdout"));
2652 assert!(!result.is_error, "default caps fit: {:?}", result.message);
2653 }
2654
2655 /// When the byte cap forces tighter caps than the declared contract, the
2656 /// receipt must say so instead of advertising caps that never ran.
2657 #[test]
2658 fn receipt_reports_the_caps_that_actually_ran() {
2659 let tmpdir = TempDir::new().expect("tempdir");
2660 let mut app = test_app(&tmpdir);
2661 {
2662 let mut state = app.plan_state.try_lock().expect("plan lock");
2663 state.update(UpdatePlanArgs {
2664 title: Some("padded plan".to_string()),
2665 plan: (0..40)
2666 .map(|index| PlanItemArg {
2667 step: format!("step {index}: {}", "padding ".repeat(30)),
2668 status: StepStatus::Pending,
2669 })
2670 .collect(),
2671 ..Default::default()
2672 });
2673 }
2674 let caps = Caps {
2675 max_output_bytes: 6 * 1024,
2676 ..DEFAULT_CAPS
2677 };
2678 let json = render_copy(&app, &CopyKind::Plan, &caps).expect("render");
2679 let value = parsed(&json);
2680 assert_eq!(
2681 value["receipt"]["caps"]["max_output_bytes"],
2682 json!(6 * 1024)
2683 );
2684 let applied = &value["receipt"]["applied_caps"];
2685 assert!(
2686 applied["max_array_items"].as_u64().expect("items")
2687 <= DEFAULT_CAPS.max_array_items as u64
2688 );
2689 if applied != &value["receipt"]["caps"] {
2690 assert!(
2691 value["receipt"]["reasons"]
2692 .as_array()
2693 .expect("reasons")
2694 .contains(&json!("caps_tightened_output_bytes_cap")),
2695 "{json}"
2696 );
2697 }
2698
2699 // The unconstrained case declares no tightening.
2700 let json = stdout_json(&execute_structcopy(&mut app, Some("plan stdout")));
2701 let value = parsed(&json);
2702 assert_eq!(value["receipt"]["applied_caps"], value["receipt"]["caps"]);
2703 assert!(
2704 !value["receipt"]["reasons"]
2705 .as_array()
2706 .expect("reasons")
2707 .contains(&json!("caps_tightened_output_bytes_cap"))
2708 );
2709 }
2710
2711 #[test]
2712 fn clipboard_is_default_and_stdout_is_explicit() {
2713 let tmpdir = TempDir::new().expect("tempdir");
2714 let mut app = test_app(&tmpdir);
2715 seed_transcript(&mut app);
2716
2717 // Default: clipboard target; the payload never appears in the message.
2718 let default = execute_structcopy(&mut app, Some("turn 1"));
2719 assert!(!default.is_error, "{:?}", default.message);
2720 let message = default.message.as_deref().unwrap_or_default();
2721 assert!(message.contains("handed to the clipboard"), "{message}");
2722 // The receipt must not overclaim delivery.
2723 assert!(
2724 !message.contains("copied to the local clipboard"),
2725 "{message}"
2726 );
2727 assert!(!message.contains("\"receipt\""), "{message}");
2728 let payload = app
2729 .clipboard
2730 .last_written_text()
2731 .expect("clipboard payload");
2732 assert!(payload.contains("\"receipt\""));
2733
2734 // Explicit stdout: payload in the message, clipboard untouched.
2735 let mut app = test_app(&tmpdir);
2736 seed_transcript(&mut app);
2737 let stdout = execute_structcopy(&mut app, Some("turn 1 stdout"));
2738 assert!(
2739 stdout
2740 .message
2741 .as_deref()
2742 .unwrap_or_default()
2743 .contains("\"receipt\"")
2744 );
2745 assert!(app.clipboard.last_written_text().is_none());
2746 }
2747
2748 /// The terminal-client path queues a background write; the message must
2749 /// not claim the copy landed, and must not claim a transport the session
2750 /// does not have.
2751 #[test]
2752 fn terminal_client_receipt_says_queued_not_delivered() {
2753 let tmpdir = TempDir::new().expect("tempdir");
2754 let mut app = test_app(&tmpdir);
2755 seed_transcript(&mut app);
2756 app.clipboard = ClipboardHandler::for_test(true, false);
2757 assert!(app.clipboard.requires_terminal_paste());
2758
2759 let result = execute_structcopy(&mut app, Some("turn 1"));
2760 assert!(!result.is_error, "{:?}", result.message);
2761 let message = result.message.as_deref().unwrap_or_default();
2762 assert!(message.contains("queued"), "{message}");
2763 assert!(message.contains("not confirmed"), "{message}");
2764 assert!(
2765 !message.contains("copied to"),
2766 "must not claim delivery: {message}"
2767 );
2768 }
2769
2770 #[test]
2771 fn clipboard_failure_is_honest_and_suggests_stdout() {
2772 let tmpdir = TempDir::new().expect("tempdir");
2773 let mut app = test_app(&tmpdir);
2774 seed_transcript(&mut app);
2775 app.clipboard = ClipboardHandler::unavailable_for_test(false);
2776
2777 let failed = execute_structcopy(&mut app, Some("turn 1"));
2778 assert!(failed.is_error);
2779 let message = failed.message.as_deref().unwrap_or_default();
2780 assert!(message.contains("Nothing was written"), "{message}");
2781 assert!(message.contains("stdout"), "{message}");
2782 assert!(app.clipboard.last_written_text().is_none());
2783 }
2784
2785 #[test]
2786 fn copy_does_not_mutate_session_state() {
2787 let tmpdir = TempDir::new().expect("tempdir");
2788 let mut app = test_app(&tmpdir);
2789 seed_transcript(&mut app);
2790 {
2791 let mut state = app.plan_state.try_lock().expect("plan lock");
2792 state.update(UpdatePlanArgs {
2793 title: Some("immutable".to_string()),
2794 ..Default::default()
2795 });
2796 }
2797 let plan_before = app.plan_state.try_lock().expect("plan lock").snapshot();
2798 let messages_before = app.api_messages.clone();
2799 let history_before = app.history.len();
2800 let work_before = app.work_state_snapshot().expect("Work snapshot");
2801
2802 for arg in [
2803 "turn 1 stdout",
2804 "turn 2",
2805 "tool call-7 stdout",
2806 "plan stdout",
2807 "turn 99 stdout",
2808 "tool call-nope stdout",
2809 "workflow nope stdout",
2810 ] {
2811 let _ = execute_structcopy(&mut app, Some(arg));
2812 }
2813
2814 assert_eq!(app.api_messages, messages_before);
2815 assert_eq!(app.history.len(), history_before);
2816 assert_eq!(
2817 app.plan_state.try_lock().expect("plan lock").snapshot(),
2818 plan_before
2819 );
2820 assert_eq!(
2821 app.work_state_snapshot().expect("Work snapshot after copy"),
2822 work_before,
2823 "structcopy must not mutate Work"
2824 );
2825 }
2826
2827 #[test]
2828 fn structcopy_is_registered_human_only_and_absent_from_model_catalog() {
2829 // Registered as a human slash command.
2830 assert!(
2831 crate::commands::command_infos()
2832 .iter()
2833 .any(|info| info.name == "structcopy"),
2834 "structcopy must be a registered slash command"
2835 );
2836
2837 // Never a model-visible tool: neither in the native tool catalog nor
2838 // in the legacy tool registry surface sent to providers.
2839 assert!(
2840 !crate::core::engine::default_active_native_tool_names().contains(&"structcopy"),
2841 "structcopy must not be a native tool"
2842 );
2843 let tmpdir = TempDir::new().expect("tempdir");
2844 let context = crate::tools::spec::ToolContext::new(tmpdir.path().to_path_buf());
2845 let registry = crate::tools::ToolRegistryBuilder::new()
2846 .with_file_tools()
2847 .with_read_only_file_tools()
2848 .with_shell_tools()
2849 .with_search_tools()
2850 .with_git_tools()
2851 .with_git_history_tools()
2852 .with_diagnostics_tool()
2853 .with_skill_tools()
2854 .with_validation_tools()
2855 .with_project_tools()
2856 .with_test_runner_tool()
2857 .with_tool_result_retrieval_tool()
2858 .with_web_tools()
2859 .with_finance_tool()
2860 .build(context);
2861 let names: Vec<String> = registry
2862 .to_api_tools()
2863 .iter()
2864 .map(|tool| tool.name.clone())
2865 .collect();
2866 assert!(
2867 !names.is_empty(),
2868 "builder surface must register model tools for this contract to be meaningful"
2869 );
2870 assert!(
2871 !names.iter().any(|name| name.contains("structcopy")),
2872 "no model tool may reference structcopy: {names:?}"
2873 );
2874 }
2875 }
2876
2876 lines RUST