Skip to main content

ai_agents_observability/
redaction.rs

1use crate::config::PrivacyConfig;
2use crate::event::{EventType, ObservationError, ObservationEvent};
3use serde_json::Value;
4use std::collections::HashSet;
5
6/// Privacy helper that redacts keys, paths, errors, tags, and raw text.
7#[derive(Debug, Clone)]
8pub struct Redactor {
9    config: PrivacyConfig,
10    keys: HashSet<String>,
11}
12
13impl Redactor {
14    /// Creates a redactor with lowercase key lookup for configured redact keys.
15    pub fn new(config: PrivacyConfig) -> Self {
16        let keys = config
17            .redact_keys
18            .iter()
19            .map(|key| key.to_lowercase())
20            .collect();
21        Self { config, keys }
22    }
23
24    /// Redacts a JSON value by configured keys, dotted paths, and text rules.
25    pub fn redact_value(&self, value: &Value) -> Value {
26        let mut value = value.clone();
27        self.redact_recursive(&mut value);
28        for path in &self.config.redact_paths {
29            redact_path(&mut value, path);
30        }
31        value
32    }
33
34    /// Redacts every event surface that can carry user or domain text.
35    pub fn redact_event(&self, mut event: ObservationEvent) -> ObservationEvent {
36        if let Some(payload) = event.payload.take() {
37            event.payload = Some(self.redact_value(&payload));
38        }
39        if let Some(error) = event.error.take() {
40            event.error = Some(self.redact_error(error));
41        }
42        for value in event.tags.values_mut() {
43            *value = self.redact_text_to_string(value);
44        }
45        for (key, value) in event.dimensions.iter_mut() {
46            if !is_safe_dimension(key) {
47                *value = self.redact_text_to_string(value);
48            }
49        }
50        if let EventType::HitlApproval { trigger } = &mut event.event_type {
51            *trigger = self.redact_text_to_string(trigger);
52        }
53        event
54    }
55
56    /// Redacts the error message while preserving the error kind.
57    pub fn redact_error(&self, mut error: ObservationError) -> ObservationError {
58        error.message = self.redact_text_to_string(&error.message);
59        error
60    }
61
62    /// Converts text into a safe string summary for tags and errors.
63    pub fn redact_text_to_string(&self, text: &str) -> String {
64        if self.config.max_text_chars > 0 {
65            truncate_chars(text, self.config.max_text_chars)
66        } else if self.config.hash_inputs {
67            format!("length:{} hash:{}", text.chars().count(), stable_hash(text))
68        } else {
69            format!("length:{}", text.chars().count())
70        }
71    }
72
73    /// Converts text into a structured safe summary with length and optional hash.
74    pub fn redact_text(&self, text: &str) -> Value {
75        let mut map = serde_json::Map::new();
76        map.insert(
77            "length".to_string(),
78            Value::from(text.chars().count() as u64),
79        );
80        if self.config.hash_inputs {
81            map.insert("hash".to_string(), Value::from(stable_hash(text)));
82        }
83        if self.config.max_text_chars > 0 {
84            map.insert(
85                "text".to_string(),
86                Value::from(truncate_chars(text, self.config.max_text_chars)),
87            );
88        }
89        Value::Object(map)
90    }
91
92    fn redact_recursive(&self, value: &mut Value) {
93        match value {
94            Value::Object(map) => {
95                let keys: Vec<String> = map.keys().cloned().collect();
96                for key in keys {
97                    let key_lower = key.to_lowercase();
98                    if self.keys.contains(&key_lower) {
99                        map.insert(key, redacted_marker());
100                    } else if key_lower == "hash"
101                        || key_lower == "length"
102                        || key_lower == "redacted"
103                    {
104                        continue;
105                    } else if let Some(child) = map.get_mut(&key) {
106                        self.redact_recursive(child);
107                    }
108                }
109            }
110            Value::Array(items) => {
111                for item in items {
112                    self.redact_recursive(item);
113                }
114            }
115            Value::String(text) => {
116                if self.config.max_text_chars == 0 {
117                    let mut map = serde_json::Map::new();
118                    map.insert(
119                        "length".to_string(),
120                        Value::from(text.chars().count() as u64),
121                    );
122                    if self.config.hash_inputs {
123                        map.insert("hash".to_string(), Value::from(stable_hash(text)));
124                    }
125                    *value = Value::Object(map);
126                } else {
127                    *text = truncate_chars(text, self.config.max_text_chars);
128                }
129            }
130            _ => {}
131        }
132    }
133}
134
135fn redacted_marker() -> Value {
136    serde_json::json!({"redacted": true})
137}
138
139fn is_safe_dimension(key: &str) -> bool {
140    matches!(
141        key,
142        "agent"
143            | "actor"
144            | "session"
145            | "purpose"
146            | "status"
147            | "provider"
148            | "model"
149            | "alias"
150            | "language"
151            | "state"
152            | "tool"
153            | "skill"
154            | "orchestration_pattern"
155            | "branch_status"
156            | "runtime.branch_status"
157            | "winner"
158            | "runtime.winner"
159            | "optimization"
160            | "runtime.optimization"
161            | "commit_behavior"
162            | "runtime.commit_behavior"
163            | "speculative"
164            | "runtime.speculative"
165            | "background"
166            | "runtime.background"
167            | "maintenance"
168            | "runtime.maintenance"
169            | "maintenance_stage"
170            | "runtime.maintenance_stage"
171            | "await_before_next_turn"
172            | "runtime.await_before_next_turn"
173            | "maintenance_mode"
174            | "runtime.maintenance_mode"
175    )
176}
177
178fn redact_path(value: &mut Value, path: &str) {
179    let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
180    if parts.is_empty() {
181        return;
182    }
183    redact_path_parts(value, &parts);
184}
185
186fn redact_path_parts(value: &mut Value, parts: &[&str]) {
187    if parts.is_empty() {
188        *value = redacted_marker();
189        return;
190    }
191    match value {
192        Value::Object(map) => {
193            if let Some(child) = map.get_mut(parts[0]) {
194                redact_path_parts(child, &parts[1..]);
195            }
196        }
197        Value::Array(items) => {
198            for item in items {
199                redact_path_parts(item, parts);
200            }
201        }
202        _ => {}
203    }
204}
205
206/// Truncates by Unicode scalar values rather than byte offsets.
207pub fn truncate_chars(text: &str, max_chars: usize) -> String {
208    if max_chars == 0 {
209        return String::new();
210    }
211    let mut chars = text.chars();
212    let truncated: String = chars.by_ref().take(max_chars).collect();
213    if chars.next().is_some() {
214        format!("{}...", truncated)
215    } else {
216        text.to_string()
217    }
218}
219
220/// Produces a stable non-cryptographic hash for correlation.
221pub fn stable_hash(text: &str) -> String {
222    let mut hash: u64 = 0xcbf29ce484222325;
223    for byte in text.as_bytes() {
224        hash ^= *byte as u64;
225        hash = hash.wrapping_mul(0x100000001b3);
226    }
227    format!("fnv1a64:{:016x}", hash)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn redacts_nested_keys() {
236        let redactor = Redactor::new(PrivacyConfig::default());
237        let value = serde_json::json!({"headers": {"authorization": "Bearer secret"}});
238        let redacted = redactor.redact_value(&value);
239        assert_eq!(
240            redacted["headers"]["authorization"],
241            serde_json::json!({"redacted": true})
242        );
243    }
244
245    #[test]
246    fn truncates_on_char_boundaries() {
247        let text = "안녕하세요 world";
248        let truncated = truncate_chars(text, 3);
249        assert_eq!(truncated, "안녕하...");
250    }
251
252    #[test]
253    fn hash_is_stable() {
254        assert_eq!(stable_hash("abc"), stable_hash("abc"));
255        assert_ne!(stable_hash("abc"), stable_hash("abd"));
256    }
257
258    #[test]
259    fn redacts_event_tags_errors_and_payloads() {
260        use crate::event::{EventStatus, ObservationPurpose};
261        use chrono::Utc;
262        use std::collections::HashMap;
263
264        let redactor = Redactor::new(PrivacyConfig::default());
265        let mut tags = HashMap::new();
266        tags.insert("reason".to_string(), "사용자 secret token 값".to_string());
267        let event = ObservationEvent {
268            trace_id: "trace".to_string(),
269            span_id: "span".to_string(),
270            parent_span_id: None,
271            turn_id: "turn".to_string(),
272            agent_id: "agent".to_string(),
273            actor_id: None,
274            session_id: None,
275            event_type: EventType::HitlApproval {
276                trigger: "tool with private args".to_string(),
277            },
278            purpose: ObservationPurpose::HitlLocalization,
279            status: EventStatus::Error,
280            timestamp: Utc::now(),
281            duration_ms: 1,
282            tokens: None,
283            cost: None,
284            error: Some(ObservationError::new("tool", "비밀 응답 secret")),
285            dimensions: HashMap::new(),
286            tags,
287            payload: Some(
288                serde_json::json!({"authorization": "Bearer secret", "text": "こんにちはsecret"}),
289            ),
290        };
291
292        let redacted = redactor.redact_event(event);
293        assert!(!redacted.error.unwrap().message.contains("비밀"));
294        assert!(!redacted.tags["reason"].contains("사용자"));
295        assert_eq!(
296            redacted.payload.unwrap()["authorization"],
297            serde_json::json!({"redacted": true})
298        );
299    }
300}