lean_ctx/core/context_os/
redaction.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::ContextEventV1;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RedactionLevel {
10 #[default]
12 RefsOnly,
13 Summary,
15 Full,
17}
18
19pub fn redact_event_payload(event: &mut ContextEventV1, scope: RedactionLevel) {
23 match scope {
24 RedactionLevel::Full => {}
25 RedactionLevel::Summary => redact_to_summary(&mut event.payload),
26 RedactionLevel::RefsOnly => redact_to_refs_only(&mut event.payload),
27 }
28}
29
30fn redact_to_refs_only(payload: &mut Value) {
32 let Some(obj) = payload.as_object() else {
33 *payload = Value::Object(serde_json::Map::new());
34 return;
35 };
36
37 let mut redacted = serde_json::Map::new();
38
39 for key in [
41 "tool",
42 "kind",
43 "event_kind",
44 "workspace_id",
45 "channel_id",
46 "id",
47 ] {
48 if let Some(v) = obj.get(key) {
49 redacted.insert(key.to_string(), v.clone());
50 }
51 }
52
53 redacted.insert("redacted".to_string(), Value::Bool(true));
54 *payload = Value::Object(redacted);
55}
56
57fn redact_to_summary(payload: &mut Value) {
59 let Some(obj) = payload.as_object() else {
60 return;
61 };
62
63 let sensitive_keys: &[&str] = &[
64 "content",
65 "file_content",
66 "result",
67 "output",
68 "session_data",
69 "knowledge_value",
70 "arguments",
71 ];
72
73 let mut redacted = obj.clone();
74 for key in sensitive_keys {
75 if redacted.contains_key(*key) {
76 redacted.insert((*key).to_string(), Value::String("[redacted]".to_string()));
77 }
78 }
79
80 *payload = Value::Object(redacted);
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use chrono::Utc;
87 use serde_json::json;
88
89 fn sample_event() -> ContextEventV1 {
90 ContextEventV1 {
91 id: 1,
92 workspace_id: "ws1".to_string(),
93 channel_id: "ch1".to_string(),
94 kind: "tool_call_recorded".to_string(),
95 actor: Some("agent".to_string()),
96 timestamp: Utc::now(),
97 version: 1,
98 parent_id: None,
99 consistency_level: "local".to_string(),
100 payload: json!({
101 "tool": "ctx_read",
102 "kind": "tool_call_recorded",
103 "content": "full file content here...",
104 "arguments": {"path": "/secret/file.rs"},
105 "workspace_id": "ws1"
106 }),
107 }
108 }
109
110 #[test]
111 fn full_level_preserves_payload() {
112 let mut ev = sample_event();
113 let original = ev.payload.clone();
114 redact_event_payload(&mut ev, RedactionLevel::Full);
115 assert_eq!(ev.payload, original);
116 }
117
118 #[test]
119 fn refs_only_strips_to_identifiers() {
120 let mut ev = sample_event();
121 redact_event_payload(&mut ev, RedactionLevel::RefsOnly);
122 let obj = ev.payload.as_object().unwrap();
123 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
124 assert_eq!(obj.get("redacted").unwrap(), true);
125 assert!(!obj.contains_key("content"));
126 assert!(!obj.contains_key("arguments"));
127 }
128
129 #[test]
130 fn summary_redacts_sensitive_fields() {
131 let mut ev = sample_event();
132 redact_event_payload(&mut ev, RedactionLevel::Summary);
133 let obj = ev.payload.as_object().unwrap();
134 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
135 assert_eq!(obj.get("content").unwrap(), "[redacted]");
136 assert_eq!(obj.get("arguments").unwrap(), "[redacted]");
137 assert_eq!(obj.get("workspace_id").unwrap(), "ws1");
138 }
139}