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