返回 CodeWhale
structcopy.rs
根目录 / crates / tui / src / commands / groups / session / structcopy.rs
1 //! `/structcopy` command — human-only structural copy (#2033).
2 //!
3 //! Copies exactly one bounded, human-selected session object (one transcript
4 //! item, one tool call+result pair, the current plan snapshot, or one
5 //! existing Workflow run projection) as deterministic, versioned canonical
6 //! JSON with a top-level receipt. The default target is the clipboard; an
7 //! explicit `stdout` argument is the only text-view path.
8 //!
9 //! Contract:
10 //! - Human-only. This is a slash command, never a model-visible tool, event,
11 //! or authority, and it writes nothing back into App/session/plan/workflow
12 //! state (see the registry/catalog contract test).
13 //! - Read-only projection over existing state. Redaction reuses the shared
14 //! sanitizer seams in `codewhale_sanitize::sanitize` (`redact_json` for
15 //! values, `sanitize_text` for keys and status labels, which
16 //! `redact_json` does not reach) plus a strict pass that strips URL
17 //! userinfo/query/fragment entirely and folds the workspace and home
18 //! prefixes to labels, removes other absolute paths, and handles generic
19 //! authority URLs. The workflow object reuses the bounded
20 //! `WorkflowRunSummary` projection.
21 //! - Hard caps on final encoded bytes, array items, string bytes, object key
22 //! bytes, and nesting depth; grapheme-safe truncation; recursively sorted
23 //! keys; exact full-tree original counts and exact retained counts in the
24 //! receipt. If receipt metadata alone cannot fit the byte cap, the command
25 //! fails closed and emits nothing.
26 //!
27 //! What this deliberately does **not** claim:
28 //! - It is not a general PII scrubber. Workspace/home paths retain a useful
29 //! labelled suffix; other absolute POSIX, drive-letter, and UNC paths are
30 //! replaced outright.
31 //! - Redaction is pattern-based (the shared sanitizer's private-key/bearer/
32 //! JWT/URL/secret regexes plus this module's strict URL pass). A secret that
33 //! matches none of those patterns and sits under a non-sensitive key is
34 //! copied as-is.
35 //! - Delivery to the clipboard is not confirmed. Terminal-client transports
36 //! (tmux / OSC 52) are queued on a background writer; the receipt says
37 //! "queued", not "delivered".
38
39 use std::collections::{BTreeMap, BTreeSet};
40 use std::fmt::Write as FmtWrite;
41
42 use serde_json::{Value, json};
43 use unicode_segmentation::UnicodeSegmentation;
44
45 use codewhale_command_contract::facets::*;
46 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
47 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
48 use codewhale_command_contract::outcome::StructcopyCommandResult as CommandResult;
49 use codewhale_sanitize::sanitize::{is_sensitive_key, redact_json, sanitize_text};
50
51 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
52 name: "structcopy",
53 aliases: &[],
54 usage: "/structcopy <turn <n>|tool <call-id>|plan|workflow <run-id>> [stdout]",
55 description_key: "cmd_structcopy_description",
56 };
57 pub(in crate::commands) const CAPABILITIES: CommandCapabilities =
58 CommandCapabilities::SESSION_STRUCTCOPY.union(CommandCapabilities::PRESENTATION);
59
60 pub(in crate::commands) struct StructcopyCmd;
61 impl RegisterCommand<CommandResult> for StructcopyCmd {
62 fn info() -> &'static CommandInfo {
63 &COMMAND_INFO
64 }
65 fn handler() -> CommandHandler<CommandResult> {
66 CommandHandler::Contextual {
67 capabilities: CAPABILITIES,
68 handler: execute_structcopy,
69 }
70 }
71 }
72
73 /// Versioned envelope identity carried in every receipt.
74 pub(in crate::commands) const SCHEMA_ID: &str = "codewhale/structcopy/v1";
75 /// Redaction contract label so consumers can tell which seams ran.
76 pub(in crate::commands) const REDACTION_CONTRACT: &str =
77 "export-sanitize/v1+typed-markers/v1+strict-url/v2+path-redact/v2";
78 /// Marker substituted for subtrees cut by the depth cap. Structural markers
79 /// are inserted after bounding and are intentionally exempt from
80 /// `max_string_bytes`; they are still counted as retained bytes.
81 pub(in crate::commands) const DEPTH_OMISSION_MARKER: &str = "omitted:depth_cap";
82 /// Marker substituted for a URL token that cannot be parsed and therefore
83 /// cannot be proven free of userinfo/query/fragment. Fail closed.
84 pub(in crate::commands) const URL_OMISSION_MARKER: &str = "redacted:url";
85 /// Marker substituted for an absolute filesystem path outside the labelled
86 /// workspace/home roots. Paths are privacy-bearing even when they contain no
87 /// conventional secret token.
88 pub(in crate::commands) const PATH_OMISSION_MARKER: &str = "redacted:absolute_path";
89 pub(in crate::commands) const BEARER_REDACTION_MARKER: &str = "redacted:bearer";
90 pub(in crate::commands) const SENSITIVE_VALUE_REDACTION_MARKER: &str = "redacted:sensitive_value";
91
92 /// Selectors are echoed into the receipt and into status messages, so they
93 /// get their own tight cap independent of the payload string cap.
94 pub(in crate::commands) const MAX_SELECTOR_BYTES: usize = 256;
95 /// Hard caps enforced on every emitted artifact. The byte cap stays well
96 /// under the OSC 52 clipboard ceiling (100 KiB) so the default clipboard
97 /// target always fits its weakest transport.
98 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
99 pub(in crate::commands) struct Caps {
100 pub(in crate::commands) max_output_bytes: usize,
101 pub(in crate::commands) max_array_items: usize,
102 pub(in crate::commands) max_string_bytes: usize,
103 pub(in crate::commands) max_depth: usize,
104 }
105
106 pub(in crate::commands) const DEFAULT_CAPS: Caps = Caps {
107 max_output_bytes: 48 * 1024,
108 max_array_items: 64,
109 max_string_bytes: 2 * 1024,
110 max_depth: 12,
111 };
112
113 /// Object keys are bounded separately from values: they are short by nature,
114 /// they participate in collision handling, and they are rewritten once during
115 /// redaction rather than per byte-cap retry.
116 pub(in crate::commands) const MAX_KEY_BYTES: usize = 256;
117
118 #[derive(Debug, Clone, PartialEq, Eq)]
119 pub(in crate::commands) enum CopyKind {
120 Turn(usize),
121 Tool(String),
122 Plan,
123 Workflow(String),
124 }
125
126 impl CopyKind {
127 fn display_label(
128 &self,
129 presentation: &dyn CommandPresentationContext,
130 ) -> Result<String, String> {
131 let key = match self {
132 CopyKind::Turn(_) => "cmd_structcopy_kind_turn",
133 CopyKind::Tool(_) => "cmd_structcopy_kind_tool",
134 CopyKind::Plan => "cmd_structcopy_kind_plan",
135 CopyKind::Workflow(_) => "cmd_structcopy_kind_workflow",
136 };
137 presentation.translate(key, &[])
138 }
139 }
140
141 #[derive(Debug, Clone, PartialEq, Eq)]
142 struct CopyRequest {
143 kind: CopyKind,
144 stdout: bool,
145 }
146
147 pub(in crate::commands) fn execute_structcopy(
148 contexts: CommandContexts<'_>,
149 arg: Option<&str>,
150 ) -> CommandResult {
151 let parts = contexts.into_parts();
152 let Some(copy) = parts.structcopy else {
153 return CommandResult::error("Command capability unavailable: session_structcopy");
154 };
155 let Some(presentation) = parts.presentation else {
156 return CommandResult::error("Command capability unavailable: presentation");
157 };
158 execute_portable(copy, presentation, arg, &DEFAULT_CAPS).unwrap_or_else(CommandResult::error)
159 }
160
161 fn execute_portable(
162 copy: &dyn CommandSessionStructcopyContext,
163 presentation: &dyn CommandPresentationContext,
164 arg: Option<&str>,
165 caps: &Caps,
166 ) -> Result<CommandResult, String> {
167 let request = match parse_request(arg) {
168 Ok(request) => request,
169 Err(()) => {
170 return Err(presentation.translate(
171 "cmd_structcopy_usage_error",
172 &[("usage", COMMAND_INFO.usage)],
173 )?);
174 }
175 };
176 let label = request.kind.display_label(presentation)?;
177 let json = render_copy(copy, presentation, &request.kind, caps)?;
178 if request.stdout {
179 return Ok(CommandResult::message(json));
180 }
181 let bytes = json.len().to_string();
182 match copy.write_clipboard(&json) {
183 Ok(transport) => {
184 let key = match transport {
185 StructcopyTransport::Native => "cmd_structcopy_clipboard_accepted",
186 StructcopyTransport::TerminalQueued => "cmd_structcopy_clipboard_queued",
187 };
188 Ok(CommandResult::message(
189 presentation.translate(key, &[("kind", &label), ("bytes", &bytes)])?,
190 ))
191 }
192 Err(error) => {
193 Err(presentation.translate("cmd_structcopy_clipboard_failed", &[("error", &error)])?)
194 }
195 }
196 }
197
198 fn parse_request(arg: Option<&str>) -> Result<CopyRequest, ()> {
199 let raw = arg.unwrap_or("").trim();
200 if raw.is_empty() {
201 return Err(());
202 }
203 let mut tokens: Vec<&str> = raw.split_whitespace().collect();
204 let mut stdout = false;
205 if tokens
206 .last()
207 .is_some_and(|last| last.eq_ignore_ascii_case("stdout"))
208 {
209 stdout = true;
210 tokens.pop();
211 }
212 let kind = match tokens.as_slice() {
213 ["plan"] => CopyKind::Plan,
214 ["turn", index] => {
215 let index = index
216 .parse::<usize>()
217 .ok()
218 .filter(|index| *index >= 1)
219 .ok_or(())?;
220 CopyKind::Turn(index)
221 }
222 ["tool", call_id] => CopyKind::Tool((*call_id).to_string()),
223 ["workflow", run_id] => CopyKind::Workflow((*run_id).to_string()),
224 _ => return Err(()),
225 };
226 Ok(CopyRequest { kind, stdout })
227 }
228
229 // === Object selection (read-only; unavailable objects are reported, never
230 // fabricated) ===
231
232 fn build_payload(
233 copy: &dyn CommandSessionStructcopyContext,
234 kind: &CopyKind,
235 ) -> Result<(&'static str, Value, Value), StructcopyError> {
236 match kind {
237 CopyKind::Turn(index) => Ok((
238 "turn",
239 json!(index),
240 message_payload(&copy.transcript_item(*index)?),
241 )),
242 CopyKind::Tool(call_id) => {
243 let pair = copy.tool_pair(call_id)?;
244 let result = match pair.result {
245 Some(result) => {
246 json!({"found":true,"is_error":optional_bool(result.is_error),"content":result.content,"content_blocks":result.content_blocks})
247 }
248 None => json!({"found":false}),
249 };
250 Ok((
251 "tool",
252 json!(call_id),
253 json!({"call_id":call_id,"name":pair.name,"input":pair.input,"result":result}),
254 ))
255 }
256 CopyKind::Plan => Ok((
257 "plan",
258 Value::Null,
259 serde_json::to_value(copy.plan_snapshot()?)
260 .map_err(|error| StructcopyError::Preparation(error.to_string()))?,
261 )),
262 CopyKind::Workflow(run_id) => Ok((
263 "workflow",
264 json!(run_id),
265 serde_json::to_value(copy.workflow_projection(run_id)?)
266 .map_err(|error| StructcopyError::Preparation(error.to_string()))?,
267 )),
268 }
269 }
270
271 fn message_payload(message: &StructcopyTranscript) -> Value {
272 match &message.content {
273 StructcopyContent::InternalContext => {
274 json!({"index":message.index,"role":message.role,"omission_code":"internal_context"})
275 }
276 StructcopyContent::Visible(blocks) => {
277 json!({"index":message.index,"role":message.role,"content":blocks.iter().map(block_payload).collect::<Vec<_>>()})
278 }
279 }
280 }
281
282 pub(in crate::commands) fn optional_bool(value: Option<bool>) -> Value {
283 value.map_or(Value::Null, Value::Bool)
284 }
285
286 fn block_payload(block: &StructcopyBlock) -> Value {
287 match block {
288 StructcopyBlock::Text(text) => json!({"type":"text","text":text}),
289 StructcopyBlock::ThinkingOmitted => {
290 json!({"type":"thinking","omission_code":"internal_reasoning_and_signature"})
291 }
292 StructcopyBlock::ToolUse {
293 id,
294 name,
295 input,
296 caller_type,
297 } => json!({"type":"tool_use","id":id,"caller_type":caller_type,"name":name,"input":input}),
298 StructcopyBlock::ToolResult {
299 tool_use_id,
300 result,
301 } => {
302 json!({"type":"tool_result","tool_use_id":tool_use_id,"is_error":optional_bool(result.is_error),"content":result.content,"content_blocks":result.content_blocks})
303 }
304 StructcopyBlock::ImageUrl(url) => json!({"type":"image","url":url}),
305 StructcopyBlock::ImageOmitted => {
306 json!({"type":"image","omission_code":"inline_or_local_image_payload"})
307 }
308 StructcopyBlock::ServerToolUse { id, name, input } => {
309 json!({"type":"server_tool_use","id":id,"name":name,"input":input})
310 }
311 StructcopyBlock::ToolSearchToolResult {
312 tool_use_id,
313 content,
314 } => json!({"type":"tool_search_tool_result","tool_use_id":tool_use_id,"content":content}),
315 StructcopyBlock::CodeExecutionToolResult {
316 tool_use_id,
317 content,
318 } => {
319 json!({"type":"code_execution_tool_result","tool_use_id":tool_use_id,"content":content})
320 }
321 }
322 }
323
324 fn selection_error(
325 presentation: &dyn CommandPresentationContext,
326 kind: &CopyKind,
327 error: StructcopyError,
328 ) -> Result<String, String> {
329 let label = kind.display_label(presentation)?;
330 match error {
331 StructcopyError::Unavailable => {
332 presentation.translate("cmd_structcopy_unavailable", &[("kind", &label)])
333 }
334 StructcopyError::Busy => presentation.translate("cmd_structcopy_busy", &[("kind", &label)]),
335 StructcopyError::Preparation(error) => presentation.translate(
336 "cmd_structcopy_prepare_failed",
337 &[("kind", &label), ("error", &error)],
338 ),
339 }
340 }
341
342 // === Redaction (composed from existing central seams) ===
343
344 /// The strongest existing central redaction, applied before any bounding or
345 /// serialization and after key normalization.
346 ///
347 /// [`redact_json`] replaces values under secret-shaped keys, and runs
348 /// [`sanitize_text`] over every string *value* — stripping ANSI/control
349 /// bytes and masking PEM blocks, `Bearer` tokens, JWTs, credential-bearing
350 /// URLs, and the config layer's known secret patterns. It does **not** touch
351 /// object *keys*, so this pass runs [`sanitize_text`] over keys as well,
352 /// then folds workspace/home prefixes to labels and strips URL
353 /// userinfo/query/fragment outright.
354 ///
355 /// Keys are also sorted, bounded, and de-collided here. Original and retained
356 /// key counts are kept separately so omitted subtrees cannot inflate claims
357 /// about the emitted object.
358 fn redact_payload(value: &mut Value, labels: &PathLabels, keys: &mut KeyStats) {
359 // Normalize keys first so ANSI/control obfuscation cannot hide a
360 // sensitive-key hint from classification. `strict_strings` classifies
361 // both the original and normalized key; the shared export pass then runs
362 // over the normalized tree as defense in depth.
363 let mut path = Vec::new();
364 strict_strings(value, labels, keys, &mut path);
365 redact_json(value, None);
366 normalize_redaction_codes(value);
367 }
368
369 /// Prefix folding for useful filesystem paths. These prefixes are recognised:
370 /// the workspace root (both as configured and as canonicalized, which differ
371 /// on macOS where `/var` symlinks to `/private/var`) and `$HOME` /
372 /// `%USERPROFILE%`. The later strict pass removes every remaining absolute
373 /// POSIX, drive-letter, or UNC path.
374 pub(in crate::commands) struct PathLabels {
375 /// `(prefix, label)` sorted longest-first so that a workspace nested
376 /// inside `$HOME` folds to `<workspace>` rather than `<home>/…`.
377 pub(in crate::commands) labels: Vec<(String, &'static str)>,
378 }
379
380 impl PathLabels {
381 pub(in crate::commands) fn new(roots: &StructcopyPathRoots) -> Self {
382 let mut workspace_forms: Vec<String> = Vec::new();
383 for form in std::iter::once(&roots.workspace).chain(roots.canonical_workspace.iter()) {
384 if form.len() > 1 && !workspace_forms.contains(form) {
385 workspace_forms.push(form.clone());
386 }
387 }
388 let mut labels: Vec<(String, &'static str)> = workspace_forms
389 .iter()
390 .map(|form| (form.clone(), "<workspace>"))
391 .collect();
392 if let Some(home) = &roots.home
393 && home.len() > 3
394 && !workspace_forms.contains(home)
395 {
396 labels.push((home.clone(), "<home>"));
397 }
398 labels.sort_by(|left, right| {
399 right
400 .0
401 .len()
402 .cmp(&left.0.len())
403 .then_with(|| left.0.cmp(&right.0))
404 });
405 Self { labels }
406 }
407
408 pub(in crate::commands) fn apply(&self, text: &str) -> String {
409 let mut out = text.to_string();
410 for (prefix, label) in &self.labels {
411 out = replace_path_root(&out, prefix, label);
412 }
413 out
414 }
415 }
416
417 /// Replace a configured root only when it ends on a path-component boundary.
418 /// A lexical prefix such as `/opt/app` must not label `/opt/application`; the
419 /// latter remains foreign and is removed by the absolute-path scrubber.
420 fn replace_path_root(text: &str, root: &str, label: &str) -> String {
421 if root.is_empty() {
422 return text.to_string();
423 }
424 let mut out = String::with_capacity(text.len());
425 let mut cursor = 0usize;
426 while let Some(offset) = text[cursor..].find(root) {
427 let start = cursor + offset;
428 let end = start + root.len();
429 out.push_str(&text[cursor..start]);
430 let component_boundary = text[end..]
431 .chars()
432 .next()
433 .is_none_or(|ch| matches!(ch, '/' | '\\'));
434 if component_boundary {
435 out.push_str(label);
436 } else {
437 out.push_str(root);
438 }
439 cursor = end;
440 }
441 out.push_str(&text[cursor..]);
442 out
443 }
444
445 /// Per-object-key accounting. Computed once during redaction and reported in
446 /// the receipt so a renamed or truncated key is never silent.
447 #[derive(Debug, Default, Clone, PartialEq, Eq)]
448 struct KeyStats {
449 entries: BTreeMap<Vec<String>, KeyFlags>,
450 }
451
452 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
453 struct KeyFlags {
454 truncated: bool,
455 deduped: bool,
456 }
457
458 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
459 struct RetainedKeyStats {
460 total: u64,
461 truncated: u64,
462 deduped: u64,
463 }
464
465 impl KeyStats {
466 fn original_total(&self) -> u64 {
467 u64::try_from(self.entries.len()).unwrap_or(u64::MAX)
468 }
469 }
470
471 fn strict_strings(
472 value: &mut Value,
473 labels: &PathLabels,
474 keys: &mut KeyStats,
475 path: &mut Vec<String>,
476 ) {
477 match value {
478 Value::String(text) => *text = scrub_string(text, labels),
479 Value::Array(items) => {
480 for (index, item) in items.iter_mut().enumerate() {
481 path.push(format!("i:{index}"));
482 strict_strings(item, labels, keys, path);
483 path.pop();
484 }
485 }
486 Value::Object(map) => {
487 // Take the map, rewrite each key, and reinsert. Entries are
488 // processed in sorted original-key order so collision suffixes
489 // are assigned deterministically regardless of insertion order.
490 let mut entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
491 entries.sort_by(|left, right| left.0.cmp(&right.0));
492 for (key, mut item) in entries {
493 let scrubbed = flatten_ws(&scrub_string(&key, labels));
494 let (bounded, was_truncated) =
495 truncate_string_grapheme_safe(&scrubbed, MAX_KEY_BYTES);
496 let (unique, collision_truncated) = unique_object_key(map, &bounded);
497 let sensitive = is_sensitive_key(&key) || is_sensitive_key(&scrubbed);
498 if sensitive {
499 item = Value::String("[redacted]".to_string());
500 } else {
501 path.push(key_path_segment(&unique));
502 strict_strings(&mut item, labels, keys, path);
503 path.pop();
504 }
505 path.push(key_path_segment(&unique));
506 keys.entries.insert(
507 path.clone(),
508 KeyFlags {
509 truncated: was_truncated || collision_truncated,
510 deduped: unique != bounded,
511 },
512 );
513 path.pop();
514 map.insert(unique, item);
515 }
516 }
517 Value::Null | Value::Bool(_) | Value::Number(_) => {}
518 }
519 }
520
521 fn key_path_segment(key: &str) -> String {
522 format!("k:{}:{key}", key.len())
523 }
524
525 fn collect_retained_key_stats(
526 value: &Value,
527 original: &KeyStats,
528 path: &mut Vec<String>,
529 retained: &mut RetainedKeyStats,
530 ) {
531 match value {
532 Value::Array(items) => {
533 for (index, item) in items.iter().enumerate() {
534 path.push(format!("i:{index}"));
535 collect_retained_key_stats(item, original, path, retained);
536 path.pop();
537 }
538 }
539 Value::Object(map) => {
540 for (key, item) in map {
541 path.push(key_path_segment(key));
542 if let Some(flags) = original.entries.get(path) {
543 retained.total += 1;
544 if flags.truncated {
545 retained.truncated += 1;
546 }
547 if flags.deduped {
548 retained.deduped += 1;
549 }
550 }
551 collect_retained_key_stats(item, original, path, retained);
552 path.pop();
553 }
554 }
555 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
556 }
557 }
558
559 /// Deterministic collision handling for keys that collapsed onto each other
560 /// after scrubbing or truncation.
561 ///
562 /// Termination is structural rather than hopeful: the numeric reserve is
563 /// sized for the largest suffix this call can produce, so `base` is fixed and
564 /// the `map.len() + 1` candidates `base~2 … base~(len+2)` are pairwise
565 /// distinct. A map holding `len` keys cannot occupy all of them.
566 ///
567 /// When `MAX_KEY_BYTES` is smaller than the reserve the suffix still wins:
568 /// losing a key to a silent overwrite is worse than exceeding a key cap by a
569 /// few bytes, and the per-key flags record that it happened.
570 fn unique_object_key(map: &serde_json::Map<String, Value>, requested: &str) -> (String, bool) {
571 if !map.contains_key(requested) {
572 return (requested.to_string(), false);
573 }
574 let highest = map.len().saturating_add(2);
575 let reserve = 1 + decimal_width(highest);
576 let base_cap = MAX_KEY_BYTES.saturating_sub(reserve);
577 let (base, collision_truncated) = truncate_string_grapheme_safe(requested, base_cap);
578 for index in 2..=highest {
579 let candidate = format!("{base}~{index}");
580 if !map.contains_key(&candidate) {
581 return (candidate, collision_truncated);
582 }
583 }
584 unreachable!(
585 "map of {} keys cannot occupy {} distinct candidates",
586 map.len(),
587 highest - 1
588 )
589 }
590
591 fn decimal_width(mut value: usize) -> usize {
592 let mut width = 1;
593 while value >= 10 {
594 value /= 10;
595 width += 1;
596 }
597 width
598 }
599
600 /// Collapse every run of whitespace to a single space. Used for object keys,
601 /// where control layout is a structural hazard rather than data.
602 fn flatten_ws(text: &str) -> String {
603 text.split_whitespace().collect::<Vec<_>>().join(" ")
604 }
605
606 pub(in crate::commands) fn scrub_string(text: &str, labels: &PathLabels) -> String {
607 // `sanitize_text` first: it strips ANSI and control bytes, so the URL
608 // scan below cannot be fooled by an escape sequence spliced into a
609 // scheme. It is idempotent, so re-running it over values that
610 // `redact_json` already sanitized is safe.
611 let sanitized = sanitize_text(text);
612 let bearer_safe = redact_loose_bearers(&sanitized);
613 let labelled = labels.apply(&bearer_safe);
614 scrub_paths(&scrub_urls(&labelled))
615 }
616
617 /// Convert the prose placeholders owned by the shared sanitizer into stable
618 /// language-neutral codes. Structural JSON is a machine artifact and must not
619 /// change with the UI locale.
620 fn normalize_redaction_codes(value: &mut Value) {
621 match value {
622 Value::String(text) => {
623 *text = text
624 .replace("[redacted private key]", "redacted:private_key")
625 .replace("Bearer [redacted]", BEARER_REDACTION_MARKER)
626 .replace("[redacted token]", "redacted:token")
627 .replace("[redacted]", SENSITIVE_VALUE_REDACTION_MARKER);
628 }
629 Value::Array(items) => {
630 for item in items {
631 normalize_redaction_codes(item);
632 }
633 }
634 Value::Object(map) => {
635 for item in map.values_mut() {
636 normalize_redaction_codes(item);
637 }
638 }
639 Value::Null | Value::Bool(_) | Value::Number(_) => {}
640 }
641 }
642
643 fn redact_loose_bearers(text: &str) -> String {
644 // Selectors cannot carry the whitespace used by a conventional
645 // `Bearer <token>` header. Delimiter variants are still secret-shaped;
646 // redact their entire line tail so token punctuation cannot terminate a
647 // regex early and expose the remainder.
648 let lowered = text.to_ascii_lowercase();
649 let mut out = String::with_capacity(text.len());
650 let mut cursor = 0usize;
651 while let Some(offset) = ["bearer-", "bearer_", "bearer:", "bearer="]
652 .iter()
653 .filter_map(|prefix| lowered[cursor..].find(prefix))
654 .min()
655 {
656 let start = cursor + offset;
657 out.push_str(&text[cursor..start]);
658 let end = text[start..]
659 .find('\n')
660 .map(|line_end| start + line_end)
661 .unwrap_or(text.len());
662 out.push_str(BEARER_REDACTION_MARKER);
663 cursor = end;
664 }
665 out.push_str(&text[cursor..]);
666 out
667 }
668
669 /// Trailing characters that are punctuation or wrappers around a URL rather
670 /// than part of it. Trimming generously is safe in both directions: the
671 /// trimmed tail is re-appended verbatim and can hold no credential, while a
672 /// tail left attached would be swallowed by the query/fragment strip.
673 const URL_TRAILING_PUNCTUATION: &[char] = &[
674 '.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '`', '*', '_', '\\',
675 ];
676
677 /// Strip URL userinfo, query, and fragment entirely, leaving a
678 /// `scheme://host[:port]/path` label.
679 ///
680 /// The shared sanitizer has already masked credentials in URLs it recognised;
681 /// this pass enforces the stricter structural-copy contract that no
682 /// userinfo, query string, or fragment may survive at all — including for
683 /// URLs that are punctuation-wrapped (`(https://…)`, `<https://…>`,
684 /// `"https://…"`), embedded mid-token, or uppercased. A token that starts
685 /// with a syntactically valid `scheme://` prefix but does not parse is replaced outright rather than
686 /// passed through, because an unparseable URL cannot be proven credential
687 /// free.
688 fn scrub_urls(text: &str) -> String {
689 let mut out = String::with_capacity(text.len());
690 let mut cursor = 0usize;
691 while let Some(offset) = next_url_start(&text[cursor..]) {
692 let start = cursor + offset;
693 out.push_str(&text[cursor..start]);
694 let rest = &text[start..];
695 // A scheme prefix contains no whitespace, so `end` is always > 0 and
696 // the cursor strictly advances.
697 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
698 out.push_str(&scrub_url_token(&rest[..end]));
699 cursor = start + end;
700 }
701 out.push_str(&text[cursor..]);
702 out
703 }
704
705 fn next_url_start(text: &str) -> Option<usize> {
706 for (separator, _) in text.match_indices("://") {
707 let before = &text[..separator];
708 let start = before
709 .char_indices()
710 .rev()
711 .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
712 .map(|(index, _)| index)
713 .last()
714 .unwrap_or(separator);
715 let scheme = &text[start..separator];
716 if scheme
717 .chars()
718 .next()
719 .is_some_and(|ch| ch.is_ascii_alphabetic())
720 {
721 return Some(start);
722 }
723 }
724 None
725 }
726
727 fn scrub_url_token(token: &str) -> String {
728 let trimmed = token.trim_end_matches(URL_TRAILING_PUNCTUATION);
729 let suffix = &token[trimmed.len()..];
730 let Ok(mut parsed) = url::Url::parse(trimmed) else {
731 return format!("{URL_OMISSION_MARKER}{suffix}");
732 };
733 // `set_username`/`set_password` only fail for cannot-be-a-base URLs.
734 // Failing closed keeps the "no userinfo survives" claim literally true.
735 if parsed.set_username("").is_err() || parsed.set_password(None).is_err() {
736 return format!("{URL_OMISSION_MARKER}{suffix}");
737 }
738 parsed.set_query(None);
739 parsed.set_fragment(None);
740 format!("{parsed}{suffix}")
741 }
742
743 fn scrub_paths(text: &str) -> String {
744 let mut out = String::with_capacity(text.len());
745 let mut cursor = 0usize;
746 while let Some(start) = next_absolute_path_start(text, cursor) {
747 out.push_str(&text[cursor..start]);
748 // An unquoted absolute path can legally contain spaces. Stop at the
749 // line boundary rather than risk leaking the tail of such a path;
750 // losing adjacent prose is safer than emitting a customer/user name.
751 let end = text[start..]
752 .find('\n')
753 .map(|offset| start + offset)
754 .unwrap_or(text.len());
755 out.push_str(PATH_OMISSION_MARKER);
756 cursor = end;
757 }
758 out.push_str(&text[cursor..]);
759 out
760 }
761
762 fn next_absolute_path_start(text: &str, from: usize) -> Option<usize> {
763 let bytes = text.as_bytes();
764 let mut index = from;
765 while index < bytes.len() {
766 let boundary = index == 0
767 || text[..index]
768 .chars()
769 .next_back()
770 .is_some_and(|ch| !ch.is_alphanumeric() && !matches!(ch, '_' | '/' | '\\'));
771 if boundary {
772 let labelled_root =
773 text[..index].ends_with("<workspace>") || text[..index].ends_with("<home>");
774 let url_separator = index > 0
775 && index + 1 < bytes.len()
776 && bytes[index - 1] == b':'
777 && bytes[index + 1] == b'/';
778 let previous_is_slash = index > 0 && bytes[index - 1] == b'/';
779 let posix =
780 bytes[index] == b'/' && !previous_is_slash && !url_separator && !labelled_root;
781 let drive = index + 2 < bytes.len()
782 && bytes[index].is_ascii_alphabetic()
783 && bytes[index + 1] == b':'
784 && matches!(bytes[index + 2], b'/' | b'\\');
785 let unc = index + 1 < bytes.len() && bytes[index] == b'\\' && bytes[index + 1] == b'\\';
786 if posix || drive || unc {
787 return Some(index);
788 }
789 }
790 index += text[index..].chars().next()?.len_utf8();
791 }
792 None
793 }
794
795 // === Bounding (hard caps + exact accounting) ===
796
797 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
798 pub(in crate::commands) struct BoundStats {
799 /// Strings present in the full redacted tree, at every depth.
800 pub(in crate::commands) strings_total: u64,
801 /// Strings actually present in the emitted payload, including the
802 /// structural markers substituted for depth-omitted subtrees.
803 pub(in crate::commands) strings_retained: u64,
804 pub(in crate::commands) strings_truncated: u64,
805 pub(in crate::commands) string_bytes_original: u64,
806 pub(in crate::commands) string_bytes_retained: u64,
807 /// Array elements present in the full redacted tree, at every depth —
808 /// including elements inside subtrees that the depth cap later omits.
809 pub(in crate::commands) array_items_original: u64,
810 pub(in crate::commands) array_items_retained: u64,
811 pub(in crate::commands) depth_omissions: u64,
812 }
813
814 /// Exact full-tree original counts. Deliberately depth-unbounded: the
815 /// receipt's `*_original` numbers describe the whole redacted object, so
816 /// that a subtree removed by the depth cap still shows up in the difference
817 /// between original and retained.
818 pub(in crate::commands) fn collect_original_counts(value: &Value, stats: &mut BoundStats) {
819 match value {
820 Value::String(text) => {
821 stats.strings_total += 1;
822 stats.string_bytes_original += text.len() as u64;
823 }
824 Value::Array(items) => {
825 stats.array_items_original += items.len() as u64;
826 for item in items {
827 collect_original_counts(item, stats);
828 }
829 }
830 Value::Object(map) => {
831 for item in map.values() {
832 collect_original_counts(item, stats);
833 }
834 }
835 Value::Null | Value::Bool(_) | Value::Number(_) => {}
836 }
837 }
838
839 fn bound_value(
840 value: &mut Value,
841 caps: &Caps,
842 stats: &mut BoundStats,
843 reasons: &mut BTreeSet<&'static str>,
844 depth: usize,
845 ) {
846 match value {
847 Value::String(text) => {
848 let (truncated, was_truncated) =
849 truncate_string_grapheme_safe(text, caps.max_string_bytes);
850 if was_truncated {
851 *text = truncated;
852 stats.strings_truncated += 1;
853 reasons.insert("string_bytes_cap");
854 }
855 stats.strings_retained += 1;
856 stats.string_bytes_retained += text.len() as u64;
857 }
858 Value::Array(items) => {
859 if depth >= caps.max_depth {
860 omit_for_depth(value, stats, reasons);
861 return;
862 }
863 if items.len() > caps.max_array_items {
864 items.truncate(caps.max_array_items);
865 reasons.insert("array_items_cap");
866 }
867 stats.array_items_retained += items.len() as u64;
868 for item in items {
869 bound_value(item, caps, stats, reasons, depth + 1);
870 }
871 }
872 Value::Object(map) => {
873 if depth >= caps.max_depth {
874 omit_for_depth(value, stats, reasons);
875 return;
876 }
877 for item in map.values_mut() {
878 bound_value(item, caps, stats, reasons, depth + 1);
879 }
880 }
881 Value::Null | Value::Bool(_) | Value::Number(_) => {}
882 }
883 }
884
885 /// Replace a too-deep subtree with the structural marker. The marker is a
886 /// string that really is emitted, so it counts toward the retained totals —
887 /// otherwise `string_bytes_retained` would understate the artifact it
888 /// describes.
889 fn omit_for_depth(value: &mut Value, stats: &mut BoundStats, reasons: &mut BTreeSet<&'static str>) {
890 stats.depth_omissions += 1;
891 reasons.insert("depth_cap");
892 *value = Value::String(DEPTH_OMISSION_MARKER.to_string());
893 stats.strings_retained += 1;
894 stats.string_bytes_retained += DEPTH_OMISSION_MARKER.len() as u64;
895 }
896
897 /// UTF-8/grapheme-safe truncation: never splits a grapheme cluster, and the
898 /// retained bytes (including the ellipsis marker) never exceed the cap.
899 ///
900 /// When `max_bytes` is below the ellipsis's own 3 bytes there is no way to
901 /// emit both content and a truncation marker inside the cap. The honest
902 /// answer is the empty string plus `true`: the caller records a truncation,
903 /// and no partial content escapes under a cap it does not fit.
904 pub(in crate::commands) fn truncate_string_grapheme_safe(
905 text: &str,
906 max_bytes: usize,
907 ) -> (String, bool) {
908 if text.len() <= max_bytes {
909 return (text.to_string(), false);
910 }
911 if max_bytes < '…'.len_utf8() {
912 return (String::new(), true);
913 }
914 let budget = max_bytes - '…'.len_utf8();
915 let mut out = String::new();
916 for grapheme in UnicodeSegmentation::graphemes(text, true) {
917 if out.len() + grapheme.len() > budget {
918 break;
919 }
920 out.push_str(grapheme);
921 }
922 out.push('…');
923 (out, true)
924 }
925
926 // === Canonical serialization (deterministic, recursively sorted keys) ===
927
928 fn canonical_string(value: &Value) -> String {
929 let mut out = String::new();
930 write_canonical(value, &mut out);
931 out
932 }
933
934 fn write_canonical(value: &Value, out: &mut String) {
935 match value {
936 Value::Null => out.push_str("null"),
937 Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
938 Value::Number(number) => {
939 let _ = write!(out, "{number}");
940 }
941 Value::String(text) => {
942 let encoded = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string());
943 out.push_str(&encoded);
944 }
945 Value::Array(items) => {
946 out.push('[');
947 for (index, item) in items.iter().enumerate() {
948 if index > 0 {
949 out.push(',');
950 }
951 write_canonical(item, out);
952 }
953 out.push(']');
954 }
955 Value::Object(map) => {
956 let mut entries: Vec<(&String, &Value)> = map.iter().collect();
957 entries.sort_by(|left, right| left.0.cmp(right.0));
958 out.push('{');
959 for (index, (key, item)) in entries.iter().enumerate() {
960 if index > 0 {
961 out.push(',');
962 }
963 let encoded = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string());
964 out.push_str(&encoded);
965 out.push(':');
966 write_canonical(item, out);
967 }
968 out.push('}');
969 }
970 }
971 }
972
973 // === Envelope assembly ===
974
975 pub(in crate::commands) fn render_copy(
976 copy: &dyn CommandSessionStructcopyContext,
977 presentation: &dyn CommandPresentationContext,
978 kind: &CopyKind,
979 caps: &Caps,
980 ) -> Result<String, String> {
981 let (kind_label, mut selector, mut payload) = match build_payload(copy, kind) {
982 Ok(payload) => payload,
983 Err(error) => return Err(selection_error(presentation, kind, error)?),
984 };
985 let labels = PathLabels::new(&copy.path_roots());
986
987 // The selector is echoed verbatim into the receipt, so it goes through
988 // the same redaction as the payload and gets its own tight byte bound.
989 let mut selector_keys = KeyStats::default();
990 redact_payload(&mut selector, &labels, &mut selector_keys);
991 bound_selector(&mut selector);
992
993 let mut keys = KeyStats::default();
994 redact_payload(&mut payload, &labels, &mut keys);
995
996 // Fit the byte cap by tightening the content caps before ever
997 // considering a payload omission.
998 let mut effective = *caps;
999 for _ in 0..4 {
1000 let encoded = encode_attempt(
1001 kind_label, &selector, &payload, &effective, caps, &keys, false,
1002 );
1003 if encoded.len() <= caps.max_output_bytes {
1004 return Ok(encoded);
1005 }
1006 effective.max_string_bytes = (effective.max_string_bytes / 2).max(64);
1007 effective.max_array_items = (effective.max_array_items / 2).max(1);
1008 effective.max_depth = effective.max_depth.saturating_sub(2).max(2);
1009 }
1010
1011 // Last resort: emit receipt metadata only. If even that exceeds the cap,
1012 // fail closed rather than emit an over-cap artifact.
1013 let encoded = encode_attempt(
1014 kind_label, &selector, &payload, &effective, caps, &keys, true,
1015 );
1016 if encoded.len() <= caps.max_output_bytes {
1017 return Ok(encoded);
1018 }
1019 Err(presentation.translate(
1020 "cmd_structcopy_receipt_too_large",
1021 &[("bytes", &caps.max_output_bytes.to_string())],
1022 )?)
1023 }
1024
1025 /// Bound the selector independently of the payload caps. Selectors are
1026 /// scalars, so this only has to handle the string case.
1027 fn bound_selector(selector: &mut Value) {
1028 if let Value::String(text) = selector {
1029 let (bounded, _) = truncate_string_grapheme_safe(text, MAX_SELECTOR_BYTES);
1030 *text = bounded;
1031 }
1032 }
1033
1034 fn encode_attempt(
1035 kind_label: &str,
1036 selector: &Value,
1037 payload: &Value,
1038 effective: &Caps,
1039 hard: &Caps,
1040 keys: &KeyStats,
1041 omit_payload: bool,
1042 ) -> String {
1043 let mut candidate = payload.clone();
1044 let mut stats = BoundStats::default();
1045 let mut reasons = BTreeSet::new();
1046 collect_original_counts(&candidate, &mut stats);
1047 bound_value(&mut candidate, effective, &mut stats, &mut reasons, 0);
1048 let mut retained_keys = RetainedKeyStats::default();
1049 collect_retained_key_stats(&candidate, keys, &mut Vec::new(), &mut retained_keys);
1050 if effective != hard {
1051 reasons.insert("caps_tightened_output_bytes_cap");
1052 }
1053 if retained_keys.truncated > 0 {
1054 reasons.insert("object_key_bytes_cap");
1055 }
1056 if retained_keys.deduped > 0 {
1057 reasons.insert("object_key_collision");
1058 }
1059 let emitted = if omit_payload {
1060 // Nothing from the bounding pass was emitted, so every retained
1061 // counter and every bounding reason would be a claim about an
1062 // artifact that does not exist. Originals stay; the rest resets.
1063 reasons.clear();
1064 reasons.insert("payload_omitted_output_bytes_cap");
1065 stats.strings_retained = 0;
1066 stats.strings_truncated = 0;
1067 stats.string_bytes_retained = 0;
1068 stats.array_items_retained = 0;
1069 stats.depth_omissions = 0;
1070 retained_keys = RetainedKeyStats::default();
1071 Value::Null
1072 } else {
1073 candidate
1074 };
1075 let envelope = assemble_envelope(
1076 kind_label,
1077 selector,
1078 &emitted,
1079 &stats,
1080 keys,
1081 &retained_keys,
1082 &reasons,
1083 effective,
1084 hard,
1085 );
1086 canonical_string(&envelope)
1087 }
1088
1089 #[allow(clippy::too_many_arguments)]
1090 fn assemble_envelope(
1091 kind: &str,
1092 selector: &Value,
1093 payload: &Value,
1094 stats: &BoundStats,
1095 original_keys: &KeyStats,
1096 retained_keys: &RetainedKeyStats,
1097 reasons: &BTreeSet<&'static str>,
1098 effective: &Caps,
1099 hard: &Caps,
1100 ) -> Value {
1101 json!({
1102 "object": payload,
1103 "receipt": {
1104 "schema": SCHEMA_ID,
1105 "human_only": true,
1106 "kind": kind,
1107 "selector": selector,
1108 "redaction": REDACTION_CONTRACT,
1109 // `caps` is the declared contract; `applied_caps` is what this
1110 // artifact was actually bounded with. They differ whenever the
1111 // output-byte cap forced a tightening pass.
1112 "caps": caps_value(hard),
1113 "applied_caps": caps_value(effective),
1114 "counts": {
1115 "strings_total": stats.strings_total,
1116 "strings_retained": stats.strings_retained,
1117 "strings_truncated": stats.strings_truncated,
1118 "string_bytes_original": stats.string_bytes_original,
1119 "string_bytes_retained": stats.string_bytes_retained,
1120 "array_items_original": stats.array_items_original,
1121 "array_items_retained": stats.array_items_retained,
1122 "depth_omissions": stats.depth_omissions,
1123 "object_keys_original": original_keys.original_total(),
1124 "object_keys_retained": retained_keys.total,
1125 "object_keys_truncated": retained_keys.truncated,
1126 "object_keys_deduped": retained_keys.deduped,
1127 "payload_bytes": canonical_string(payload).len(),
1128 },
1129 "reasons": reasons.iter().copied().collect::<Vec<_>>(),
1130 }
1131 })
1132 }
1133
1134 fn caps_value(caps: &Caps) -> Value {
1135 json!({
1136 "max_output_bytes": caps.max_output_bytes,
1137 "max_array_items": caps.max_array_items,
1138 "max_string_bytes": caps.max_string_bytes,
1139 "max_key_bytes": MAX_KEY_BYTES,
1140 "max_depth": caps.max_depth,
1141 })
1142 }
1143
1144 #[cfg(test)]
1145 mod tests {
1146 use super::*;
1147 fn no_labels() -> PathLabels {
1148 PathLabels { labels: Vec::new() }
1149 }
1150 /// `unique_object_key` must terminate and preserve every value even when
1151 /// the key cap leaves no room at all for a base.
1152 #[test]
1153 fn key_dedup_terminates_under_a_degenerate_cap() {
1154 let mut map = serde_json::Map::new();
1155 for _ in 0..12 {
1156 let (key, _) = unique_object_key(&map, "");
1157 assert!(!map.contains_key(&key), "reused key {key:?}");
1158 map.insert(key, Value::Null);
1159 }
1160 assert_eq!(map.len(), 12, "every insert must survive");
1161
1162 // Deterministic across runs with the same inputs.
1163 let mut replay = serde_json::Map::new();
1164 for _ in 0..12 {
1165 let (key, _) = unique_object_key(&replay, "");
1166 replay.insert(key, Value::Null);
1167 }
1168 let left: Vec<&String> = map.keys().collect();
1169 let right: Vec<&String> = replay.keys().collect();
1170 assert_eq!(left, right);
1171
1172 assert_eq!(decimal_width(0), 1);
1173 assert_eq!(decimal_width(9), 1);
1174 assert_eq!(decimal_width(10), 2);
1175 assert_eq!(decimal_width(999), 3);
1176 assert_eq!(decimal_width(1000), 4);
1177 }
1178
1179 /// URLs do not arrive as tidy whitespace-delimited tokens. Wrapped,
1180 /// embedded, uppercased, and malformed forms must all lose their
1181 /// userinfo, query, and fragment.
1182 #[test]
1183 fn urls_lose_userinfo_query_and_fragment_in_hostile_shapes() {
1184 let labels = no_labels();
1185 let cases = [
1186 "(https://u:p@host.test/a?q=1#f)",
1187 "<https://u:p@host.test/a?q=1#f>",
1188 "\"https://u:p@host.test/a?q=1#f\"",
1189 "'https://u:p@host.test/a?q=1#f'",
1190 "see https://u:p@host.test/a?q=1#f.",
1191 "see https://u:p@host.test/a?q=1#f, then",
1192 "[link](https://u:p@host.test/a?q=1#f)",
1193 "prefixhttps://u:p@host.test/a?q=1#f",
1194 "HTTPS://U:P@HOST.TEST/a?q=1#f",
1195 "ws://u:p@host.test/a?q=1#f",
1196 "ftp://u:p@host.test/a?q=1#f",
1197 "postgres://u:p@host.test/db?sslkey=secret#f",
1198 "mongodb://u:p@host.test/db?authSource=admin#f",
1199 "redis://u:p@host.test/0?token=secret#f",
1200 "amqp://u:p@host.test/vhost?token=secret#f",
1201 "ssh://u:p@host.test/repo?identity=secret#f",
1202 "socks5://u:p@host.test/path?token=secret#f",
1203 "trailing`https://u:p@host.test/a?q=1#f`",
1204 "a=https://u:p@host.test/a?q=1#f&b=2",
1205 ];
1206 for case in cases {
1207 let scrubbed = scrub_string(case, &labels);
1208 for forbidden in [
1209 "u:p@",
1210 "q=1",
1211 "#f",
1212 "P@HOST",
1213 "sslkey=secret",
1214 "authSource=admin",
1215 "token=secret",
1216 "identity=secret",
1217 ] {
1218 assert!(
1219 !scrubbed.contains(forbidden),
1220 "{case:?} kept {forbidden:?}: {scrubbed}"
1221 );
1222 }
1223 assert!(
1224 scrubbed.contains("host.test") || scrubbed.contains(URL_OMISSION_MARKER),
1225 "{case:?} -> {scrubbed}"
1226 );
1227 }
1228
1229 // Two URLs in one string: both are scrubbed, order preserved.
1230 let both = scrub_string(
1231 "first https://a:b@one.test/x?y=1#z then https://c:d@two.test/w?v=2#u end",
1232 &labels,
1233 );
1234 assert!(both.contains("one.test"), "{both}");
1235 assert!(both.contains("two.test"), "{both}");
1236 assert!(both.starts_with("first "), "{both}");
1237 assert!(both.ends_with(" end"), "{both}");
1238 for forbidden in ["a:b@", "c:d@", "y=1", "v=2", "#z", "#u"] {
1239 assert!(!both.contains(forbidden), "kept {forbidden:?}: {both}");
1240 }
1241
1242 // Unparseable but scheme-prefixed: fail closed, do not pass through.
1243 for hostile in [
1244 "https://",
1245 "https://[not-an-ipv6:1]/x?token=leak#f",
1246 "http://user:pw@:99999/x?token=leak",
1247 ] {
1248 let scrubbed = scrub_string(hostile, &labels);
1249 assert!(!scrubbed.contains("token=leak"), "{hostile} -> {scrubbed}");
1250 assert!(!scrubbed.contains("user:pw@"), "{hostile} -> {scrubbed}");
1251 }
1252
1253 // An ANSI escape spliced into a scheme must not hide the URL from
1254 // the scanner: `sanitize_text` runs first.
1255 let hidden = scrub_string("htt\u{1b}[0mps://u:p@host.test/a?q=1#f", &labels);
1256 assert!(!hidden.contains("u:p@"), "{hidden}");
1257 assert!(!hidden.contains("q=1"), "{hidden}");
1258
1259 // Text with no URL is untouched.
1260 assert_eq!(
1261 scrub_string("plain text, no url", &labels),
1262 "plain text, no url"
1263 );
1264 }
1265
1266 #[test]
1267 fn path_labels_require_component_boundaries_and_preserve_repeated_roots() {
1268 let labels = PathLabels {
1269 labels: vec![
1270 ("/opt/app".to_string(), "<workspace>"),
1271 ("/Users/alice".to_string(), "<home>"),
1272 ],
1273 };
1274
1275 assert_eq!(labels.apply("/opt/app"), "<workspace>");
1276 assert_eq!(labels.apply("/opt/app/src"), "<workspace>/src");
1277 assert_eq!(labels.apply(r"/opt/app\src"), r"<workspace>\src");
1278 assert_eq!(
1279 labels.apply("/opt/app/a and /opt/app/b"),
1280 "<workspace>/a and <workspace>/b"
1281 );
1282 assert_eq!(labels.apply("/Users/alice"), "<home>");
1283 assert_eq!(
1284 labels.apply("/Users/alice/project and /Users/alice/other"),
1285 "<home>/project and <home>/other"
1286 );
1287
1288 for collision in [
1289 "/opt/application/customer",
1290 "/opt/app-old/customer",
1291 "/Users/alice-old/private",
1292 "/Users/alice2/private",
1293 ] {
1294 assert_eq!(
1295 labels.apply(collision),
1296 collision,
1297 "near-prefix path must not receive a trusted label"
1298 );
1299 assert_eq!(
1300 scrub_string(collision, &labels),
1301 PATH_OMISSION_MARKER,
1302 "near-prefix path must remain foreign and be redacted"
1303 );
1304 }
1305 }
1306
1307 struct RecordingCopy {
1308 events: std::cell::RefCell<Vec<String>>,
1309 error: Option<StructcopyError>,
1310 transport: Result<StructcopyTransport, String>,
1311 }
1312 impl RecordingCopy {
1313 fn new() -> Self {
1314 Self {
1315 events: Default::default(),
1316 error: None,
1317 transport: Ok(StructcopyTransport::Native),
1318 }
1319 }
1320 fn observe(&self, event: String) -> Result<(), StructcopyError> {
1321 self.events.borrow_mut().push(event);
1322 self.error.clone().map_or(Ok(()), Err)
1323 }
1324 }
1325 impl CommandSessionStructcopyContext for RecordingCopy {
1326 fn transcript_item(&self, index: usize) -> Result<StructcopyTranscript, StructcopyError> {
1327 self.observe(format!("turn:{index}"))?;
1328 Ok(StructcopyTranscript {
1329 index,
1330 role: "user".into(),
1331 content: StructcopyContent::Visible(vec![StructcopyBlock::Text("visible".into())]),
1332 })
1333 }
1334 fn tool_pair(&self, id: &str) -> Result<StructcopyToolPair, StructcopyError> {
1335 self.observe(format!("tool:{id}"))?;
1336 Ok(StructcopyToolPair {
1337 name: "fetch".into(),
1338 input: json!({"api_key":"private"}),
1339 result: None,
1340 })
1341 }
1342 fn plan_snapshot(&self) -> Result<StructcopyPlan, StructcopyError> {
1343 self.observe("plan".into())?;
1344 Ok(StructcopyPlan {
1345 title: Some("plan".into()),
1346 ..Default::default()
1347 })
1348 }
1349 fn workflow_projection(&self, id: &str) -> Result<StructcopyWorkflow, StructcopyError> {
1350 self.observe(format!("workflow:{id}"))?;
1351 Err(StructcopyError::Unavailable)
1352 }
1353 fn path_roots(&self) -> StructcopyPathRoots {
1354 self.events.borrow_mut().push("roots".into());
1355 StructcopyPathRoots {
1356 workspace: "/work".into(),
1357 canonical_workspace: None,
1358 home: None,
1359 }
1360 }
1361 fn write_clipboard(&self, text: &str) -> Result<StructcopyTransport, String> {
1362 let payload: Value =
1363 serde_json::from_str(text).expect("only rendered JSON may reach clipboard");
1364 assert_eq!(payload["receipt"]["schema"], SCHEMA_ID);
1365 assert!(!text.contains("private"));
1366 self.events.borrow_mut().push("clipboard".into());
1367 self.transport.clone()
1368 }
1369 }
1370 struct Labels;
1371 impl CommandPresentationContext for Labels {
1372 fn translate(&self, key: &str, _: &[(&str, &str)]) -> Result<String, String> {
1373 Ok(key.into())
1374 }
1375 }
1376
1377 #[test]
1378 fn structcopy_portable_selects_one_observation_and_writes_only_after_rendering() {
1379 for (args, selected) in [
1380 ("turn +1", "turn:1"),
1381 ("tool call-id", "tool:call-id"),
1382 ("plan", "plan"),
1383 ] {
1384 for stdout in [false, true] {
1385 let copy = RecordingCopy::new();
1386 let args = format!("{args}{}", if stdout { " STDOUT" } else { "" });
1387 let result = execute_portable(&copy, &Labels, Some(&args), &DEFAULT_CAPS).unwrap();
1388 assert!(!result.is_error);
1389 assert!(result.action.is_none());
1390 let mut expected = vec![selected, "roots"];
1391 if stdout {
1392 assert!(serde_json::from_str::<Value>(&result.message.unwrap()).is_ok());
1393 } else {
1394 expected.push("clipboard");
1395 assert_eq!(
1396 result.message.as_deref(),
1397 Some("cmd_structcopy_clipboard_accepted")
1398 );
1399 }
1400 assert_eq!(*copy.events.borrow(), expected);
1401 }
1402 }
1403 }
1404
1405 #[test]
1406 fn structcopy_portable_rejections_never_write_clipboard() {
1407 for args in [
1408 None,
1409 Some(""),
1410 Some("turn 0"),
1411 Some("TURN 1"),
1412 Some("plan extra"),
1413 Some("tool"),
1414 Some("workflow"),
1415 Some("stdout plan"),
1416 ] {
1417 let copy = RecordingCopy::new();
1418 assert!(execute_portable(&copy, &Labels, args, &DEFAULT_CAPS).is_err());
1419 assert!(copy.events.borrow().is_empty());
1420 }
1421 for error in [
1422 StructcopyError::Unavailable,
1423 StructcopyError::Busy,
1424 StructcopyError::Preparation("broken".into()),
1425 ] {
1426 let mut copy = RecordingCopy::new();
1427 copy.error = Some(error);
1428 assert!(execute_portable(&copy, &Labels, Some("plan"), &DEFAULT_CAPS).is_err());
1429 assert_eq!(*copy.events.borrow(), ["plan"]);
1430 }
1431 let copy = RecordingCopy::new();
1432 assert!(execute_portable(&copy, &Labels, Some("workflow missing"), &DEFAULT_CAPS).is_err());
1433 assert_eq!(*copy.events.borrow(), ["workflow:missing"]);
1434 let copy = RecordingCopy::new();
1435 assert!(
1436 execute_portable(
1437 &copy,
1438 &Labels,
1439 Some("turn 1"),
1440 &Caps {
1441 max_output_bytes: 1,
1442 ..DEFAULT_CAPS
1443 }
1444 )
1445 .is_err()
1446 );
1447 assert_eq!(*copy.events.borrow(), ["turn:1", "roots"]);
1448 }
1449
1450 #[test]
1451 fn structcopy_portable_transport_receipts_and_missing_facets_are_honest() {
1452 for (transport, key, error) in [
1453 (
1454 Ok(StructcopyTransport::TerminalQueued),
1455 "cmd_structcopy_clipboard_queued",
1456 false,
1457 ),
1458 (
1459 Err("clipboard unavailable".into()),
1460 "cmd_structcopy_clipboard_failed",
1461 true,
1462 ),
1463 ] {
1464 let mut copy = RecordingCopy::new();
1465 copy.transport = transport;
1466 let result = execute_structcopy(
1467 CommandContexts::empty()
1468 .with_structcopy(&mut copy)
1469 .with_presentation(&mut Labels),
1470 Some("plan"),
1471 );
1472 assert_eq!(result.is_error, error);
1473 assert!(result.action.is_none());
1474 assert_eq!(
1475 result.message,
1476 Some(if error {
1477 format!("Error: {key}")
1478 } else {
1479 key.into()
1480 })
1481 );
1482 assert_eq!(*copy.events.borrow(), ["plan", "roots", "clipboard"]);
1483 }
1484 let mut copy = RecordingCopy::new();
1485 assert!(execute_structcopy(CommandContexts::empty(), Some("plan")).is_error);
1486 assert!(
1487 execute_structcopy(
1488 CommandContexts::empty().with_presentation(&mut Labels),
1489 Some("plan")
1490 )
1491 .is_error
1492 );
1493 assert!(
1494 execute_structcopy(
1495 CommandContexts::empty().with_structcopy(&mut copy),
1496 Some("plan")
1497 )
1498 .is_error
1499 );
1500 assert!(copy.events.borrow().is_empty());
1501 }
1502 }
1503
1503 lines RUST