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 target_agents: None,
101 payload: json!({
102 "tool": "ctx_read",
103 "kind": "tool_call_recorded",
104 "content": "full file content here...",
105 "arguments": {"path": "/secret/file.rs"},
106 "workspace_id": "ws1"
107 }),
108 }
109 }
110
111 #[test]
112 fn full_level_preserves_payload() {
113 let mut ev = sample_event();
114 let original = ev.payload.clone();
115 redact_event_payload(&mut ev, RedactionLevel::Full);
116 assert_eq!(ev.payload, original);
117 }
118
119 #[test]
120 fn refs_only_strips_to_identifiers() {
121 let mut ev = sample_event();
122 redact_event_payload(&mut ev, RedactionLevel::RefsOnly);
123 let obj = ev.payload.as_object().unwrap();
124 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
125 assert_eq!(obj.get("redacted").unwrap(), true);
126 assert!(!obj.contains_key("content"));
127 assert!(!obj.contains_key("arguments"));
128 }
129
130 #[test]
131 fn summary_redacts_sensitive_fields() {
132 let mut ev = sample_event();
133 redact_event_payload(&mut ev, RedactionLevel::Summary);
134 let obj = ev.payload.as_object().unwrap();
135 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
136 assert_eq!(obj.get("content").unwrap(), "[redacted]");
137 assert_eq!(obj.get("arguments").unwrap(), "[redacted]");
138 assert_eq!(obj.get("workspace_id").unwrap(), "ws1");
139 }
140}