1use std::collections::HashMap;
40use std::str::FromStr;
41use std::sync::OnceLock;
42
43use serde_json::Value;
44
45fn path_regex() -> &'static regex::Regex {
51 static RE: OnceLock<regex::Regex> = OnceLock::new();
52 RE.get_or_init(|| {
53 regex::Regex::new(r#"(/Users/[^/\s\\"]+|/home/[^/\s\\"]+|/Volumes/[^/\s\\"]+)"#)
54 .expect("redactor path regex must compile")
55 })
56}
57
58fn profile_regex() -> &'static regex::Regex {
62 static RE: OnceLock<regex::Regex> = OnceLock::new();
63 RE.get_or_init(|| {
64 regex::Regex::new(r"\.claude-[A-Za-z0-9._-]+").expect("redactor profile regex must compile")
65 })
66}
67
68fn slug_regex() -> &'static regex::Regex {
72 static RE: OnceLock<regex::Regex> = OnceLock::new();
73 RE.get_or_init(|| {
74 regex::Regex::new(r#"-Users-([^-/\s\\"]+)-"#).expect("redactor slug regex must compile")
75 })
76}
77
78const PLUGIN_MANIFEST_DIR: &str = ".claude-plugin";
81
82const MIN_OWNER_LEN: usize = 8;
87
88fn needs_scrub(s: &str) -> bool {
90 path_regex().is_match(s) || profile_regex().is_match(s) || slug_regex().is_match(s)
91}
92
93fn scrub_text(s: &str) -> String {
96 let out = path_regex().replace_all(s, "<redacted-home>");
97 let out = profile_regex().replace_all(&out, |caps: ®ex::Captures| {
100 if &caps[0] == PLUGIN_MANIFEST_DIR {
101 PLUGIN_MANIFEST_DIR.to_string()
102 } else {
103 "<redacted-profile>".to_string()
104 }
105 });
106 slug_regex().replace_all(&out, "<redacted-home>-").into_owned()
107}
108
109fn owner_regex() -> &'static regex::Regex {
115 static RE: OnceLock<regex::Regex> = OnceLock::new();
116 RE.get_or_init(|| {
117 regex::Regex::new(r"/(?:Users|home)/([A-Za-z0-9._-]+)")
118 .expect("redactor owner regex must compile")
119 })
120}
121
122const ACCOUNT_CATEGORICAL_KEYS: &[&str] = &["apiProvider", "subscriptionType"];
128
129fn redact_account_recursive(v: &mut Value) {
134 match v {
135 Value::Array(a) => a.iter_mut().for_each(redact_account_recursive),
136 Value::Object(o) => {
137 for (key, val) in o.iter_mut() {
138 if key == "account" || key == "accounts" {
139 redact_account_field(val, key);
140 } else {
141 redact_account_recursive(val);
142 }
143 }
144 }
145 _ => {}
146 }
147}
148
149fn redact_account_field(v: &mut Value, key: &str) {
153 match v {
154 Value::String(s) if !ACCOUNT_CATEGORICAL_KEYS.contains(&key) => {
155 let placeholder = format!("<redacted-{key}>");
156 if *s != placeholder {
157 eprintln!("redact_account_field: collapsed a value under key={key:?}");
162 *s = placeholder;
163 }
164 }
165 Value::Array(a) => a.iter_mut().for_each(|e| redact_account_field(e, key)),
166 Value::Object(o) => {
167 for (k, val) in o.iter_mut() {
168 redact_account_field(val, k);
169 }
170 }
171 _ => {}
172 }
173}
174
175const HOOK_BODY_KEYS: &[&str] = &["output", "stdout", "stderr"];
180
181const HOOK_BODY_PLACEHOLDER: &str = "<redacted-hook-body>";
184
185fn redact_hook_body_recursive(v: &mut Value) {
197 match v {
198 Value::Array(a) => a.iter_mut().for_each(redact_hook_body_recursive),
199 Value::Object(o) => {
200 if o.get("type").and_then(Value::as_str) == Some("system")
201 && o.get("subtype").and_then(Value::as_str) == Some("hook_response")
202 {
203 for key in HOOK_BODY_KEYS {
204 if let Some(Value::String(body)) = o.get_mut(*key)
205 && !body.is_empty()
206 && body != HOOK_BODY_PLACEHOLDER
207 {
208 HOOK_BODY_PLACEHOLDER.clone_into(body);
209 }
210 }
211 }
212 for val in o.values_mut() {
213 redact_hook_body_recursive(val);
214 }
215 }
216 _ => {}
217 }
218}
219
220const COMMAND_ENTRY_KEYS: &[&str] = &["description", "argumentHint"];
225
226fn redact_command_inventory_recursive(v: &mut Value) {
241 match v {
242 Value::Array(a) => a.iter_mut().for_each(redact_command_inventory_recursive),
243 Value::Object(o) => {
244 let is_system = o.get("type").and_then(Value::as_str) == Some("system");
245 let is_init = is_system && o.get("subtype").and_then(Value::as_str) == Some("init");
246 let is_changed =
247 is_system && o.get("subtype").and_then(Value::as_str) == Some("commands_changed");
248 let is_control = o.get("type").and_then(Value::as_str) == Some("control_response");
249 if is_init {
250 for (key, kind) in [("slash_commands", "command"), ("skills", "skill")] {
251 if let Some(val) = o.get_mut(key) {
252 stub_inventory(val, kind);
253 }
254 }
255 }
256 if is_changed && let Some(val) = o.get_mut("commands") {
257 stub_inventory(val, "command");
258 }
259 if is_control {
260 for val in o.values_mut() {
261 stub_nested_commands(val);
262 }
263 return;
264 }
265 for val in o.values_mut() {
266 redact_command_inventory_recursive(val);
267 }
268 }
269 _ => {}
270 }
271}
272
273const CONTROL_INVENTORY_KEYS: &[(&str, &str)] =
279 &[("commands", "command"), ("skillFrontmatter", "skill")];
280
281fn stub_nested_commands(v: &mut Value) {
284 match v {
285 Value::Array(a) => a.iter_mut().for_each(stub_nested_commands),
286 Value::Object(o) => {
287 for (key, val) in o.iter_mut() {
288 match CONTROL_INVENTORY_KEYS.iter().find(|(k, _)| *k == key.as_str()) {
289 Some((_, kind)) => stub_inventory(val, kind),
290 None => stub_nested_commands(val),
291 }
292 }
293 }
294 _ => {}
295 }
296}
297
298fn stub_inventory(arr: &mut Value, kind: &str) {
302 let Value::Array(entries) = arr else { return };
303 for (i, entry) in entries.iter_mut().enumerate() {
304 let name = format!("<redacted-{kind}-{i}>");
305 match entry {
306 Value::String(s) => *s = name,
307 Value::Object(o) => {
308 if let Some(Value::String(s)) = o.get_mut("name") {
309 *s = name;
310 }
311 for key in COMMAND_ENTRY_KEYS {
312 if let Some(Value::String(s)) = o.get_mut(*key)
313 && !s.is_empty()
314 {
315 *s = format!("<redacted-{kind}-{key}>");
316 }
317 }
318 }
319 _ => {}
320 }
321 }
322}
323
324const MCP_CONFIG_CATEGORICAL_KEYS: &[&str] = &["type"];
329
330const MCP_CONFIG_PLACEHOLDER: &str = "<redacted-mcp-config>";
332
333fn redact_mcp_config_recursive(v: &mut Value) {
344 match v {
345 Value::Array(a) => a.iter_mut().for_each(redact_mcp_config_recursive),
346 Value::Object(o) => {
347 if let Some(Value::Array(servers)) = o.get_mut("mcpServers") {
348 for server in servers.iter_mut() {
349 if let Value::Object(fields) = server
350 && let Some(config) = fields.get_mut("config")
351 {
352 stub_mcp_config(config);
353 }
354 }
355 }
356 for val in o.values_mut() {
357 redact_mcp_config_recursive(val);
358 }
359 }
360 _ => {}
361 }
362}
363
364fn stub_mcp_config(v: &mut Value) {
367 match v {
368 Value::String(s) => {
369 if s != MCP_CONFIG_PLACEHOLDER {
370 MCP_CONFIG_PLACEHOLDER.clone_into(s);
371 }
372 }
373 Value::Array(a) => a.iter_mut().for_each(stub_mcp_config),
374 Value::Object(o) => {
375 for (key, val) in o.iter_mut() {
376 if !MCP_CONFIG_CATEGORICAL_KEYS.contains(&key.as_str()) {
377 stub_mcp_config(val);
378 }
379 }
380 }
381 _ => {}
382 }
383}
384
385fn scrub_owner_tokens_recursive(v: &mut Value, owners: &[regex::Regex]) {
389 match v {
390 Value::String(s) => *s = replace_owners(s, owners),
391 Value::Array(a) => a.iter_mut().for_each(|e| scrub_owner_tokens_recursive(e, owners)),
392 Value::Object(o) => {
393 for val in o.values_mut() {
394 scrub_owner_tokens_recursive(val, owners);
395 }
396 }
397 _ => {}
398 }
399}
400
401fn replace_owners(s: &str, owners: &[regex::Regex]) -> String {
403 owners
404 .iter()
405 .fold(s.to_string(), |acc, owner| owner.replace_all(&acc, "<redacted-user>").into_owned())
406}
407
408fn collect_owners(s: &str, out: &mut Vec<String>) -> Result<(), String> {
415 let from_paths = owner_regex().captures_iter(s);
416 let from_slugs = slug_regex().captures_iter(s);
417 for caps in from_paths.chain(from_slugs) {
418 let name = caps[1].to_string();
419 if name.len() < MIN_OWNER_LEN {
420 return Err(format!(
421 "discovered a {}-character home-directory owner, below this \
422 redactor's {MIN_OWNER_LEN}-character floor",
423 name.len()
424 ));
425 }
426 if !out.contains(&name) {
427 out.push(name);
428 }
429 }
430 Ok(())
431}
432
433fn collect_owners_recursive(v: &Value, out: &mut Vec<String>) -> Result<(), String> {
442 match v {
443 Value::String(s) => collect_owners(s, out)?,
444 Value::Array(a) => {
445 for e in a {
446 collect_owners_recursive(e, out)?;
447 }
448 }
449 Value::Object(o) => {
450 for (k, val) in o {
451 collect_owners(k, out)?;
452 collect_owners_recursive(val, out)?;
453 }
454 }
455 _ => {}
456 }
457 Ok(())
458}
459
460pub struct WireRedactor {
465 owners: Vec<regex::Regex>,
466}
467
468impl WireRedactor {
469 pub fn for_trace<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Self, String> {
477 let mut names: Vec<String> = Vec::new();
478 for line in lines {
479 match serde_json::from_str::<Value>(line) {
480 Ok(v) => collect_owners_recursive(&v, &mut names)?,
481 Err(_) => collect_owners(line, &mut names)?,
482 }
483 }
484 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
489 let owners = names
490 .iter()
491 .filter_map(|n| regex::Regex::new(&format!(r"\b{}\b", regex::escape(n))).ok())
492 .collect();
493 Ok(Self { owners })
494 }
495
496 pub fn redact_line(&self, line: &str) -> Result<String, String> {
512 let Ok(parsed) = serde_json::from_str::<Value>(line) else {
513 return Ok(self.scrub_raw(line));
514 };
515 let reencoded = serde_json::to_string(&parsed).map_err(|e| format!("serialise: {e}"))?;
516 if reencoded != line {
517 let mut probe = parsed.clone();
518 redact_account_recursive(&mut probe);
519 redact_hook_body_recursive(&mut probe);
520 redact_command_inventory_recursive(&mut probe);
521 redact_mcp_config_recursive(&mut probe);
522 if probe != parsed {
523 if number_token_counts_match(line, &reencoded) {
532 return self.redact_structurally(parsed);
533 }
534 let ty = parsed.get("type").and_then(Value::as_str).unwrap_or("<no type>");
535 return Err(format!(
536 "a {ty} frame carries account, hook-body, command-inventory or \
537 mcp-config fields but does not survive a value-faithful re-encode, so \
538 they cannot be redacted structurally"
539 ));
540 }
541 return Ok(self.scrub_raw(line));
542 }
543 self.redact_structurally(parsed)
544 }
545
546 fn redact_structurally(&self, mut v: Value) -> Result<String, String> {
549 redact_account_recursive(&mut v);
550 redact_hook_body_recursive(&mut v);
551 redact_command_inventory_recursive(&mut v);
552 redact_mcp_config_recursive(&mut v);
553 scrub_paths_recursive(&mut v);
554 scrub_owner_tokens_recursive(&mut v, &self.owners);
555 serde_json::to_string(&v).map_err(|e| format!("serialise: {e}"))
556 }
557
558 fn scrub_raw(&self, s: &str) -> String {
560 replace_owners(&scrub_text(s), &self.owners)
561 }
562}
563
564fn number_tokens(line: &str) -> Vec<f64> {
572 let mut tokens = Vec::new();
573 let mut start: Option<usize> = None;
574 let mut in_string = false;
575 let mut escaped = false;
576 for (i, c) in line.char_indices() {
577 if in_string {
578 if escaped {
579 escaped = false;
580 } else if c == '\\' {
581 escaped = true;
582 } else if c == '"' {
583 in_string = false;
584 }
585 continue;
586 }
587 let numeric = c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E');
588 if numeric && start.is_none() {
589 start = Some(i);
590 } else if !numeric {
591 if let Some(s) = start.take() {
592 tokens.push(&line[s..i]);
593 }
594 if c == '"' {
595 in_string = true;
596 }
597 }
598 }
599 if let Some(s) = start {
600 tokens.push(&line[s..]);
601 }
602 tokens.into_iter().filter_map(|t| f64::from_str(t).ok()).collect()
603}
604
605fn number_token_counts_match(original: &str, reencoded: &str) -> bool {
610 number_tokens(original).len() == number_tokens(reencoded).len()
611}
612
613fn scrub_paths_recursive(v: &mut Value) {
619 match v {
620 Value::String(s) if needs_scrub(s) => {
621 *s = scrub_text(s);
622 }
623 Value::Array(a) => a.iter_mut().for_each(scrub_paths_recursive),
624 Value::Object(o) => {
625 for val in o.values_mut() {
627 scrub_paths_recursive(val);
628 }
629 let needs_rewrite = o.keys().any(|k| needs_scrub(k));
635 if needs_rewrite {
636 let entries: Vec<(String, Value)> =
637 o.iter_mut().map(|(k, v)| (scrub_text(k), v.take())).collect();
638 o.clear();
639 for (k, v) in entries {
640 if o.contains_key(&k) {
641 eprintln!(
649 "scrub_paths_recursive: key collision after redaction; \
650 distinct source paths collapsed into key={k:?}"
651 );
652 }
653 o.insert(k, v);
654 }
655 }
656 }
657 _ => {}
658 }
659}
660
661#[derive(Default)]
665pub struct RedactState {
666 ids: HashMap<String, String>,
667}
668
669impl RedactState {
670 fn opaque(&mut self, prefix: &str, input: &str) -> String {
673 if let Some(existing) = self.ids.get(input) {
674 return existing.clone();
675 }
676 let n = self.ids.values().filter(|v| v.starts_with(&format!("{prefix}_"))).count();
677 let out = format!("{prefix}_{n}");
678 self.ids.insert(input.to_string(), out.clone());
679 out
680 }
681}
682
683pub fn transform_persistence_line(
693 line: &str,
694 state: &mut RedactState,
695) -> Result<Option<String>, String> {
696 let mut v: Value = serde_json::from_str(line).map_err(|e| format!("json parse: {e}"))?;
697 let Some(ty) = v.get("type").and_then(Value::as_str).map(str::to_string) else {
698 return Ok(None);
699 };
700 if !matches!(
701 ty.as_str(),
702 "assistant" | "user" | "system" | "result" | "rate_limit_event" | "stream_event" | "error"
703 ) {
704 return Ok(None);
705 }
706
707 let obj = v.as_object_mut().ok_or_else(|| "top-level not an object".to_string())?;
708
709 if let Some(sid) = obj.remove("sessionId")
711 && let Some(s) = sid.as_str()
712 {
713 let opaque = state.opaque("session", s);
714 obj.insert("session_id".into(), Value::String(opaque));
715 }
716
717 if let Some(p) = obj.remove("parentUuid") {
719 if let Some(s) = p.as_str() {
720 let opaque = state.opaque("tool_use", s);
721 obj.insert("parent_tool_use_id".into(), Value::String(opaque));
722 } else {
723 obj.insert("parent_tool_use_id".into(), Value::Null);
724 }
725 } else {
726 obj.insert("parent_tool_use_id".into(), Value::Null);
727 }
728
729 if let Some(u) = obj.get("uuid").and_then(Value::as_str).map(str::to_string) {
731 let opaque = state.opaque("uuid", &u);
732 obj.insert("uuid".into(), Value::String(opaque));
733 }
734
735 let synthetic = ty == "user"
742 && ["isMeta", "isVisibleInTranscriptOnly", "isCompactSummary"]
743 .iter()
744 .any(|mark| obj.get(*mark).and_then(Value::as_bool) == Some(true));
745
746 let virtual_row = obj.get("isVirtual").and_then(Value::as_bool) == Some(true);
749
750 for field in [
759 "attachment",
760 "classifierMetaLines",
761 "content",
762 "cwd",
763 "entrypoint",
764 "gitBranch",
765 "imagePasteIds",
766 "isCompactSummary",
767 "isMeta",
768 "isSidechain",
769 "isSnapshotUpdate",
770 "isVirtual",
771 "isVisibleInTranscriptOnly",
772 "lastPrompt",
773 "messageId",
774 "operation",
775 "permissionMode",
776 "promptId",
777 "promptSource",
778 "queueSkipAttachments",
779 "queueTranscriptOnly",
780 "requestId",
781 "slug",
782 "snapshot",
783 "sourceToolAssistantUUID",
784 "sourceToolUseID",
785 "timestamp",
786 "toolDenialKind",
787 "toolUseResult",
788 "turnCompanion",
789 "turnOrigin",
790 "userType",
791 "version",
792 ] {
793 obj.remove(field);
794 }
795
796 if synthetic {
797 obj.insert("isSynthetic".into(), Value::Bool(true));
798 }
799 if virtual_row {
800 obj.insert("is_virtual".into(), Value::Bool(true));
801 }
802
803 if let Some(msg) = obj.get_mut("message").and_then(Value::as_object_mut) {
805 if let Some(Value::Array(content)) = msg.get_mut("content") {
806 for block in content.iter_mut() {
807 redact_content_block(block, state);
808 }
809 } else if let Some(Value::String(_)) = msg.get("content") {
810 msg.insert("content".into(), Value::String("<redacted-text>".into()));
811 }
812 if let Some(mid) = msg.get("id").and_then(Value::as_str).map(str::to_string) {
814 msg.insert("id".into(), Value::String(state.opaque("msg", &mid)));
815 }
816 }
817
818 redact_account_recursive(&mut v);
824 scrub_paths_recursive(&mut v);
825
826 let out = serde_json::to_string(&v).map_err(|e| format!("json serialise: {e}"))?;
827 Ok(Some(out))
828}
829
830fn redact_content_block(block: &mut Value, state: &mut RedactState) {
831 let Some(obj) = block.as_object_mut() else {
832 return;
833 };
834 let ty = obj.get("type").and_then(Value::as_str).unwrap_or("").to_string();
835 match ty.as_str() {
836 "text" => {
837 if let Some(Value::String(t)) = obj.get("text") {
838 let bytes = t.len();
839 obj.insert("text".into(), Value::String(format!("<redacted-text {bytes}b>")));
840 }
841 }
842 "thinking" => {
843 if let Some(Value::String(t)) = obj.get("thinking") {
844 let bytes = t.len();
845 obj.insert(
846 "thinking".into(),
847 Value::String(format!("<redacted-thinking {bytes}b>")),
848 );
849 }
850 if obj.contains_key("signature") {
852 obj.insert("signature".into(), Value::String("<redacted-signature>".into()));
853 }
854 }
855 "tool_use" => {
856 if let Some(id) = obj.get("id").and_then(Value::as_str).map(str::to_string) {
858 obj.insert("id".into(), Value::String(state.opaque("tool_use", &id)));
859 }
860 if obj.contains_key("input") {
861 obj.insert("input".into(), serde_json::json!({"_redacted": true}));
862 }
863 }
864 "tool_result" => {
865 if let Some(id) = obj.get("tool_use_id").and_then(Value::as_str).map(str::to_string) {
866 obj.insert("tool_use_id".into(), Value::String(state.opaque("tool_use", &id)));
867 }
868 if let Some(Value::String(c)) = obj.get("content") {
869 let bytes = c.len();
870 obj.insert(
871 "content".into(),
872 Value::String(format!("<redacted-tool-result {bytes}b>")),
873 );
874 } else if let Some(Value::Array(arr)) = obj.get_mut("content") {
875 for sub in arr.iter_mut() {
877 redact_content_block(sub, state);
878 }
879 }
880 }
881 "document" | "image" => {
882 if let Some(Value::Object(src)) = obj.get_mut("source") {
891 if let Some(Value::String(d)) = src.get_mut("data") {
892 let bytes = d.len();
893 *d = format!("<redacted-{ty}-data {bytes}b>");
894 }
895 for k in ["text", "content", "url"] {
896 if let Some(Value::String(s)) = src.get_mut(k) {
897 let bytes = s.len();
898 *s = format!("<redacted-{ty}-{k} {bytes}b>");
899 }
900 }
901 }
902 }
903 _ => {
904 for (k, val) in obj.iter_mut() {
907 if let Value::String(s) = val
908 && (k == "text" || k == "content" || k == "message")
909 {
910 *val = Value::String(format!("<redacted-{} {}b>", k, s.len()));
911 }
912 }
913 }
914 }
915}
916
917pub fn redact_session_file(body: &str) -> Result<Vec<String>, String> {
926 let mut state = RedactState::default();
927 let mut out = Vec::new();
928 for line in body.lines() {
929 let line = line.trim();
930 if line.is_empty() {
931 continue;
932 }
933 match transform_persistence_line(line, &mut state) {
934 Ok(Some(transformed)) => out.push(transformed),
935 Ok(None) => {}
936 Err(e) => return Err(e),
937 }
938 }
939 Ok(out)
940}
941
942pub fn redact_session_path(path: &std::path::Path) -> Result<(Vec<String>, String), String> {
949 let body = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
950 let lines = redact_session_file(&body)?;
951 let summary = format!(
952 "{}: in={} out={}",
953 path.display(),
954 body.lines().filter(|l| !l.trim().is_empty()).count(),
955 lines.len()
956 );
957 Ok((lines, summary))
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use serde_json::json;
964
965 #[test]
966 fn scrub_paths_recursive_value_only_no_keys() {
967 let mut v = json!({"k": "/Users/alice/foo.txt"});
968 scrub_paths_recursive(&mut v);
969 assert_eq!(v, json!({"k": "<redacted-home>/foo.txt"}));
970 }
971
972 #[test]
973 fn scrub_paths_recursive_object_keys_rewritten() {
974 let mut v = json!({"/Users/alice/foo.txt": 1, "harmless": 2});
975 scrub_paths_recursive(&mut v);
976 let obj = v.as_object().unwrap();
977 assert!(obj.contains_key("<redacted-home>/foo.txt"));
978 assert!(obj.contains_key("harmless"));
979 assert_eq!(obj.len(), 2);
980 }
981
982 #[test]
983 fn scrub_paths_recursive_collisions_collapse_last_wins() {
984 let mut v = json!({
988 "/Users/alice/foo": "a",
989 "/Users/bob/foo": "b",
990 });
991 scrub_paths_recursive(&mut v);
992 let obj = v.as_object().unwrap();
993 assert_eq!(obj.len(), 1);
994 assert!(obj.contains_key("<redacted-home>/foo"));
995 }
996
997 #[test]
998 fn scrub_paths_recursive_nested_object_keys_and_values() {
999 let mut v = json!({
1000 "/Volumes/data/disk": {
1001 "inner_key_/home/x/foo": "/Users/x/y/z",
1002 "ok": 42
1003 }
1004 });
1005 scrub_paths_recursive(&mut v);
1006 let outer = v.as_object().unwrap();
1008 let inner = outer.get("<redacted-home>/disk").unwrap().as_object().unwrap();
1009 assert!(inner.contains_key("inner_key_<redacted-home>/foo"));
1011 assert!(inner.contains_key("ok"));
1012 assert_eq!(inner["inner_key_<redacted-home>/foo"], json!("<redacted-home>/y/z"));
1014 }
1015
1016 #[test]
1017 fn scrub_paths_recursive_array_values() {
1018 let mut v = json!(["/Users/alice/x", "ok", {"k": "/home/bob"}]);
1019 scrub_paths_recursive(&mut v);
1020 let arr = v.as_array().unwrap();
1021 assert_eq!(arr[0], json!("<redacted-home>/x"));
1022 assert_eq!(arr[1], json!("ok"));
1023 assert_eq!(arr[2], json!({"k": "<redacted-home>"}));
1024 }
1025
1026 fn key_count(v: &Value) -> usize {
1028 match v {
1029 Value::Object(o) => o.len() + o.values().map(key_count).sum::<usize>(),
1030 Value::Array(a) => a.iter().map(key_count).sum(),
1031 _ => 0,
1032 }
1033 }
1034
1035 fn redact_one(line: &str) -> Value {
1041 let out =
1042 WireRedactor::for_trace([line]).expect("discovers").redact_line(line).expect("redacts");
1043 let after: Value = serde_json::from_str(&out).expect("redacted line stays valid JSON");
1044 let before: Value = serde_json::from_str(line).expect("fixture is valid JSON");
1045 assert_eq!(
1046 key_count(&before),
1047 key_count(&after),
1048 "redaction changed the key count\n in: {line}\n out: {out}"
1049 );
1050 after
1051 }
1052
1053 #[test]
1054 fn account_fields_are_placeholdered_and_keys_kept() {
1055 let v = redact_one(
1056 &json!({"response": {"response": {"account": {
1057 "email": "someone@example.com",
1058 "organization": "Some Org",
1059 "subscriptionType": "max",
1060 "apiProvider": "anthropic",
1061 }}}})
1062 .to_string(),
1063 );
1064 assert_eq!(
1065 v["response"]["response"]["account"],
1066 json!({
1067 "email": "<redacted-email>",
1068 "organization": "<redacted-organization>",
1069 "subscriptionType": "max",
1070 "apiProvider": "anthropic",
1071 })
1072 );
1073 }
1074
1075 #[test]
1077 fn account_redaction_reaches_bare_nested_and_listed_forms() {
1078 let v = redact_one(
1079 &json!({
1080 "bare": {"account": "someone@example.com"},
1081 "nested": {"account": {"profile": {"email": "someone@example.com"}}},
1082 "listed": {"accounts": [{"email": "someone@example.com"}]},
1083 })
1084 .to_string(),
1085 );
1086 assert_eq!(v["bare"]["account"], json!("<redacted-account>"));
1087 assert_eq!(v["nested"]["account"]["profile"]["email"], json!("<redacted-email>"));
1088 assert_eq!(v["listed"]["accounts"][0]["email"], json!("<redacted-email>"));
1089 }
1090
1091 #[test]
1092 fn profile_dir_is_redacted_home_relative_and_absolute() {
1093 let v = redact_one(
1094 &json!({"text": "see ~/.claude-alt/CLAUDE.md and /Users/alexandra/.claude-gateway1/x"})
1095 .to_string(),
1096 );
1097 assert_eq!(
1098 v["text"],
1099 json!("see ~/<redacted-profile>/CLAUDE.md and <redacted-home>/<redacted-profile>/x")
1100 );
1101 }
1102
1103 #[test]
1104 fn project_slug_form_of_a_home_path_is_redacted() {
1105 let v = redact_one(
1106 &json!({"text": "projects/-Users-alexandra-Projects-forge/memory"}).to_string(),
1107 );
1108 assert_eq!(v["text"], json!("projects/<redacted-home>-Projects-forge/memory"));
1109 }
1110
1111 #[test]
1114 fn owner_name_in_prose_is_redacted_from_a_home_path_elsewhere_in_the_trace() {
1115 let path_line = json!({"cwd": "/Users/alexandra/Projects/forge"}).to_string();
1116 let prose_line =
1117 json!({"text": "bundle id `dev.alexandra.app`, repo `alexandra/proxy`"}).to_string();
1118 let redactor =
1119 WireRedactor::for_trace([path_line.as_str(), prose_line.as_str()]).expect("discovers");
1120 let v: Value = serde_json::from_str(&redactor.redact_line(&prose_line).expect("redacts"))
1121 .expect("valid JSON");
1122 assert_eq!(
1123 v["text"],
1124 json!("bundle id `dev.<redacted-user>.app`, repo `<redacted-user>/proxy`")
1125 );
1126 }
1127
1128 #[test]
1131 fn owner_is_discovered_when_the_home_path_ends_the_json_string() {
1132 let line = json!({"cwd": "/Users/alexandra", "note": "ping alexandra"}).to_string();
1133 assert_eq!(redact_one(&line)["note"], json!("ping <redacted-user>"));
1134 }
1135
1136 #[test]
1139 fn an_owner_is_discovered_from_a_stderr_line_not_a_fragment_of_it() {
1140 let stderr = "EACCES: /Users/alexandra: permission denied";
1141 let prose = json!({"note": "owned by alexandra"}).to_string();
1142 let r = WireRedactor::for_trace([stderr, prose.as_str()]).expect("discovers");
1143 let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1144 assert_eq!(v["note"], json!("owned by <redacted-user>"));
1145 }
1146
1147 #[test]
1150 fn an_owner_is_discovered_from_the_slug_spelling_too() {
1151 let slug = json!({"p": "projects/-Users-alexandra-Projects-forge/x"}).to_string();
1152 let prose = json!({"note": "ping alexandra"}).to_string();
1153 let r = WireRedactor::for_trace([slug.as_str(), prose.as_str()]).expect("discovers");
1154 let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1155 assert_eq!(v["note"], json!("ping <redacted-user>"));
1156 }
1157
1158 #[test]
1161 fn an_owner_below_the_length_floor_is_refused() {
1162 let line = json!({"cwd": "/home/node"}).to_string();
1163 let err = WireRedactor::for_trace([line.as_str()]).err().expect("must refuse");
1164 assert!(err.contains("4-character"), "{err}");
1165 assert!(!err.contains("node"), "error names the owner: {err}");
1166 }
1167
1168 #[test]
1171 fn an_owner_inside_a_longer_word_is_left_alone() {
1172 let line = json!({"cwd": "/Users/alexandra", "note": "alexandras alexandra"}).to_string();
1173 assert_eq!(redact_one(&line)["note"], json!("alexandras <redacted-user>"));
1174 }
1175
1176 #[test]
1180 fn a_name_that_prefixes_another_does_not_strand_its_tail() {
1181 let short = json!({"cwd": "/Users/alexandra"}).to_string();
1182 let long = json!({"cwd": "/Users/alexandra.bell"}).to_string();
1183 let prose = json!({"note": "alexandra.bell"}).to_string();
1184 let r = WireRedactor::for_trace([short.as_str(), long.as_str(), prose.as_str()])
1185 .expect("discovers");
1186 let v: Value = serde_json::from_str(&r.redact_line(&prose).expect("redacts")).unwrap();
1187 assert_eq!(v["note"], json!("<redacted-user>"));
1188 }
1189
1190 #[test]
1193 fn the_plugin_manifest_dir_is_not_taken_for_a_profile() {
1194 let line = json!({"p": "/tmp/x/.claude-plugin/marketplace.json"}).to_string();
1195 assert_eq!(redact_one(&line)["p"], json!("/tmp/x/.claude-plugin/marketplace.json"));
1196 }
1197
1198 #[test]
1200 fn object_keys_get_every_rule_not_just_the_path_one() {
1201 let mut v = json!({"~/.claude-alt/settings.json": 1});
1202 scrub_paths_recursive(&mut v);
1203 assert!(v.as_object().unwrap().contains_key("~/<redacted-profile>/settings.json"));
1204 }
1205
1206 #[test]
1209 fn a_line_that_is_not_json_still_gets_the_text_rules() {
1210 let line = "node:internal/fs: ENOENT /Users/alexandra/.claude-alt/settings.json";
1211 assert_eq!(
1212 WireRedactor::for_trace([line]).expect("discovers").redact_line(line).as_deref(),
1213 Ok("node:internal/fs: ENOENT <redacted-home>/<redacted-profile>/settings.json")
1214 );
1215 }
1216
1217 #[test]
1221 fn a_redacted_line_that_cannot_round_trip_keeps_its_numbers_and_fields() {
1222 let line = r#"{"total_cost_usd":1.3382134999999997,"cwd":"/Users/alexandra","uuid":"a/b"}"#;
1223 let out =
1224 WireRedactor::for_trace([line]).expect("discovers").redact_line(line).expect("redacts");
1225
1226 assert!(out.contains("1.3382134999999997"), "float was re-encoded: {out}");
1227 assert!(!out.contains("/Users/"), "path survived: {out}");
1228 let after: Value = serde_json::from_str(&out).expect("stays valid JSON");
1229 let before: Value = serde_json::from_str(line).expect("fixture is valid JSON");
1230 assert_eq!(key_count(&before), key_count(&after), "a field was dropped: {out}");
1231 assert_eq!(after["uuid"], json!("a/b"), "the field after the path was eaten: {out}");
1232 }
1233
1234 #[test]
1239 fn account_fields_on_a_value_faithful_reencode_redact_structurally() {
1240 let line = r#"{"total_cost_usd":1.3382134999999997,"account":{"email":"a@b.co"}}"#;
1241 let out = WireRedactor::for_trace([line])
1242 .expect("discovers")
1243 .redact_line(line)
1244 .expect("value-faithful re-encode redacts");
1245 assert!(!out.contains("a@b.co"), "email survived: {out}");
1246 assert!(
1247 out.contains("1.3382134999999995"),
1248 "the float re-spells to serde_json's canonical parse: {out}"
1249 );
1250 let after: Value = serde_json::from_str(&out).expect("stays valid JSON");
1251 assert_eq!(
1252 after["account"]["email"],
1253 json!("<redacted-email>"),
1254 "the email collapses to its placeholder: {out}"
1255 );
1256 }
1257
1258 #[test]
1263 fn account_fields_on_a_structure_losing_reencode_fail_closed() {
1264 let line = r#"{"total_cost_usd":1.5,"total_cost_usd":1.3382134999999997,"account":{"email":"a@b.co"}}"#;
1265 let err = WireRedactor::for_trace([line])
1266 .expect("discovers")
1267 .redact_line(line)
1268 .expect_err("must fail closed");
1269 assert!(err.contains("account"), "{err}");
1270 assert!(!err.contains("a@b.co"), "the error prints the value it refused to write: {err}");
1271 }
1272
1273 #[test]
1275 fn volume_name_is_not_discovered_as_an_owner() {
1276 let line = json!({"a": "/Volumes/data/x", "b": "data set"}).to_string();
1277 assert_eq!(redact_one(&line)["b"], json!("data set"));
1278 }
1279
1280 #[test]
1283 fn a_line_with_nothing_to_redact_is_returned_verbatim() {
1284 let line = r#"{"type":"result","total_cost_usd":1.3382134999999997}"#;
1285 let r = WireRedactor::for_trace([line]).expect("discovers");
1286 assert_eq!(r.redact_line(line).as_deref(), Ok(line));
1287 }
1288
1289 #[test]
1290 fn scrub_paths_recursive_no_op_on_clean_input() {
1291 let mut v = json!({"name": "alice", "n": 42, "list": [1, 2, "ok"]});
1292 let original = v.clone();
1293 scrub_paths_recursive(&mut v);
1294 assert_eq!(v, original);
1295 }
1296
1297 fn transform_row(row: &Value) -> Value {
1300 let out = transform_persistence_line(&row.to_string(), &mut RedactState::default())
1301 .expect("transforms")
1302 .expect("maps to a wire frame");
1303 serde_json::from_str(&out).expect("stays valid JSON")
1304 }
1305
1306 #[test]
1313 fn disk_synthetic_marks_fold_into_the_wires_spelling() {
1314 let companion = transform_row(&json!({
1316 "parentUuid": "cef55743-4eec-4553-86f9-68a54e1cb284",
1317 "isSidechain": false,
1318 "promptId": "9b1257b5-1eae-47ce-8ab6-4c80952bca2c",
1319 "type": "user",
1320 "message": {"role": "user", "content": [{"type": "text", "text": "companion notice"}]},
1321 "isMeta": true,
1322 "turnCompanion": true,
1323 "uuid": "a22e6f91-7b16-4586-95e6-88b654b1c928",
1324 "timestamp": "2026-09-27T12:17:30.732Z",
1325 "userType": "external",
1326 "entrypoint": "sdk-cli",
1327 "cwd": "/home/dev/Projects/example-app",
1328 "sessionId": "3f1c0a1e-0000-4000-8000-000000000002",
1329 "version": "2.1.280",
1330 "gitBranch": "main",
1331 }));
1332 assert_eq!(
1333 companion.get("isSynthetic"),
1334 Some(&json!(true)),
1335 "a row the wire stamps must reach the corpus stamped, in the wire's spelling: \
1336 {companion}"
1337 );
1338 for mark in ["isMeta", "turnCompanion", "isCompactSummary", "isVisibleInTranscriptOnly"] {
1339 assert!(
1340 companion.get(mark).is_none(),
1341 "the disk spelling {mark} reached the wire, which has none: {companion}"
1342 );
1343 }
1344
1345 for mark in ["isVisibleInTranscriptOnly", "isCompactSummary"] {
1349 let mut row = json!({
1350 "type": "user",
1351 "message": {"role": "user", "content": "continued from a previous conversation"},
1352 "uuid": "uuid-summary",
1353 });
1354 row[mark] = json!(true);
1355 let summary = transform_row(&row);
1356 assert_eq!(
1357 summary.get("isSynthetic"),
1358 Some(&json!(true)),
1359 "the predicate's arm {mark} stamps the same wire spelling: {summary}"
1360 );
1361 assert!(
1362 summary.get(mark).is_none(),
1363 "the disk spelling {mark} reached the wire: {summary}"
1364 );
1365 }
1366 }
1367
1368 #[test]
1374 fn persistence_only_names_never_reach_the_wire() {
1375 let mut row = json!({
1376 "type": "user",
1377 "message": {"role": "user", "content": "swept row"},
1378 "uuid": "uuid-swept",
1379 "isMeta": true,
1380 });
1381 let disk_only = [
1382 "promptSource",
1383 "turnOrigin",
1384 "imagePasteIds",
1385 "toolDenialKind",
1386 "slug",
1387 "sourceToolUseID",
1388 "queueSkipAttachments",
1389 "queueTranscriptOnly",
1390 "classifierMetaLines",
1391 ];
1392 for name in disk_only {
1393 row[name] = json!("disk value");
1394 }
1395 row["isVirtual"] = json!(true);
1396 let out = transform_row(&row);
1397 for name in disk_only {
1398 assert!(out.get(name).is_none(), "the disk-only name {name} reached the wire: {out}");
1399 }
1400 assert_eq!(
1401 out.get("is_virtual"),
1402 Some(&json!(true)),
1403 "the wire's own spelling carries the virtual mark: {out}"
1404 );
1405 assert!(
1406 out.get("isVirtual").is_none(),
1407 "the disk spelling of the virtual mark reached the wire: {out}"
1408 );
1409 assert_eq!(
1410 out.get("isSynthetic"),
1411 Some(&json!(true)),
1412 "the sweep does not eat the fold: {out}"
1413 );
1414 }
1415
1416 #[test]
1423 fn only_a_user_frame_the_cli_stamps_for_is_marked() {
1424 let unmarked = transform_row(&json!({
1425 "type": "user",
1426 "message": {"role": "user", "content": "typed by hand"},
1427 "uuid": "uuid-typed",
1428 }));
1429 assert_eq!(
1430 unmarked.get("isSynthetic"),
1431 None,
1432 "a row the CLI does not stamp must not grow the key: {unmarked}"
1433 );
1434
1435 let companion_only = transform_row(&json!({
1436 "type": "user",
1437 "message": {"role": "user", "content": "companion text"},
1438 "turnCompanion": true,
1439 "uuid": "uuid-companion",
1440 }));
1441 assert_eq!(
1442 companion_only.get("isSynthetic"),
1443 None,
1444 "turnCompanion is a disk field and stamps nothing: {companion_only}"
1445 );
1446 assert!(
1447 companion_only.get("turnCompanion").is_none(),
1448 "the disk field still reached the wire: {companion_only}"
1449 );
1450
1451 let system = transform_row(&json!({
1452 "type": "system",
1453 "subtype": "turn_duration",
1454 "durationMs": 4,
1455 "isMeta": true,
1456 "messageCount": 2,
1457 "uuid": "uuid-turn",
1458 }));
1459 assert_eq!(
1460 system.get("isSynthetic"),
1461 None,
1462 "a system frame carries no synthetic mark on the wire: {system}"
1463 );
1464 assert!(system.get("isMeta").is_none(), "the disk mark still reached the wire: {system}");
1465 }
1466}