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/// Redact a standalone payload Value (without full ContextEventV1).
58pub 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
66/// Strip full content but keep tool names and basic metadata.
67fn 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}