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
57pub fn redact_payload_value(payload: &mut Value, scope: RedactionLevel) {
59 match scope {
60 RedactionLevel::Full => {}
61 RedactionLevel::Summary => redact_to_summary(payload),
62 RedactionLevel::RefsOnly => redact_to_refs_only(payload),
63 }
64}
65
66fn redact_to_summary(payload: &mut Value) {
68 let Some(obj) = payload.as_object() else {
69 return;
70 };
71
72 let sensitive_keys: &[&str] = &[
73 "content",
74 "file_content",
75 "result",
76 "output",
77 "session_data",
78 "knowledge_value",
79 "arguments",
80 ];
81
82 let mut redacted = obj.clone();
83 for key in sensitive_keys {
84 if redacted.contains_key(*key) {
85 redacted.insert((*key).to_string(), Value::String("[redacted]".to_string()));
86 }
87 }
88
89 *payload = Value::Object(redacted);
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use chrono::Utc;
96 use serde_json::json;
97
98 fn sample_event() -> ContextEventV1 {
99 ContextEventV1 {
100 id: 1,
101 workspace_id: "ws1".to_string(),
102 channel_id: "ch1".to_string(),
103 kind: "tool_call_recorded".to_string(),
104 actor: Some("agent".to_string()),
105 timestamp: Utc::now(),
106 version: 1,
107 parent_id: None,
108 consistency_level: "local".to_string(),
109 target_agents: None,
110 payload: json!({
111 "tool": "ctx_read",
112 "kind": "tool_call_recorded",
113 "content": "full file content here...",
114 "arguments": {"path": "/secret/file.rs"},
115 "workspace_id": "ws1"
116 }),
117 }
118 }
119
120 #[test]
121 fn full_level_preserves_payload() {
122 let mut ev = sample_event();
123 let original = ev.payload.clone();
124 redact_event_payload(&mut ev, RedactionLevel::Full);
125 assert_eq!(ev.payload, original);
126 }
127
128 #[test]
129 fn refs_only_strips_to_identifiers() {
130 let mut ev = sample_event();
131 redact_event_payload(&mut ev, RedactionLevel::RefsOnly);
132 let obj = ev.payload.as_object().unwrap();
133 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
134 assert_eq!(obj.get("redacted").unwrap(), true);
135 assert!(!obj.contains_key("content"));
136 assert!(!obj.contains_key("arguments"));
137 }
138
139 #[test]
140 fn summary_redacts_sensitive_fields() {
141 let mut ev = sample_event();
142 redact_event_payload(&mut ev, RedactionLevel::Summary);
143 let obj = ev.payload.as_object().unwrap();
144 assert_eq!(obj.get("tool").unwrap(), "ctx_read");
145 assert_eq!(obj.get("content").unwrap(), "[redacted]");
146 assert_eq!(obj.get("arguments").unwrap(), "[redacted]");
147 assert_eq!(obj.get("workspace_id").unwrap(), "ws1");
148 }
149}