Skip to main content

lean_ctx/core/context_os/
redaction.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::ContextEventV1;
5
6/// Controls how much of an event payload is exposed to SSE consumers.
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RedactionLevel {
10    /// Only IDs and references (default for SSE).
11    #[default]
12    RefsOnly,
13    /// Include tool names and basic metadata.
14    Summary,
15    /// Full payload (admin only).
16    Full,
17}
18
19/// Redacts sensitive fields from event payloads before SSE delivery.
20/// By default, payloads only contain reference IDs (tool name, event kind),
21/// not full file contents or session data.
22pub 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
30/// Strip payload down to only tool name and event kind references.
31fn 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    // Preserve only reference-type fields (IDs and kind indicators).
40    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
57/// Strip full content but keep tool names and basic metadata.
58fn 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}