Skip to main content

symbi_runtime/
logging.rs

1//! Encrypted Logging Module for Model I/O
2//!
3//! This module provides secure logging capabilities for all model interactions
4//! including prompts, tool calls, outputs, and latency metrics. All sensitive
5//! data is encrypted using AES-256-GCM before being written to logs.
6//!
7//! # Security Features
8//! - Automatic encryption of all sensitive log data
9//! - PII/PHI detection and masking
10//! - Secure key management integration
11//! - Structured logging with metadata
12//! - Configurable retention policies
13
14use crate::crypto::{Aes256GcmCrypto, EncryptedData, KeyUtils};
15use crate::secrets::SecretStore;
16use crate::types::AgentId;
17use chrono::{DateTime, Utc};
18use futures;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23use thiserror::Error;
24use tracing as log;
25use uuid::Uuid;
26
27/// Schema-level redaction marker.
28///
29/// Wrap a field at the type level to guarantee that `Debug`, `Display`, and
30/// `Serialize` never reveal its contents. The inner value is still usable
31/// via [`Sensitive::expose_secret`], making the `Serialize` emit `"[REDACTED]"`
32/// instead of the plaintext.
33///
34/// This is the "schema-driven" half of PII redaction: call sites that
35/// construct structured log records can opt into unconditional redaction by
36/// typing the field as `Sensitive<String>` instead of plain `String`, rather
37/// than relying on regex-based content inspection downstream.
38///
39/// ```ignore
40/// use symbi_runtime::logging::Sensitive;
41/// #[derive(serde::Serialize)]
42/// struct AuthEvent {
43///     user_id: String,
44///     bearer: Sensitive<String>, // never appears in logs
45/// }
46/// ```
47#[derive(Clone, PartialEq, Eq)]
48pub struct Sensitive<T>(T);
49
50impl<T> Sensitive<T> {
51    /// Wrap a plaintext value.
52    pub fn new(value: T) -> Self {
53        Self(value)
54    }
55
56    /// Escape hatch: return the inner value. Named to make call sites that
57    /// intentionally read the plaintext stand out in code review.
58    pub fn expose_secret(&self) -> &T {
59        &self.0
60    }
61
62    /// Take ownership of the inner value.
63    pub fn into_inner(self) -> T {
64        self.0
65    }
66}
67
68impl<T> From<T> for Sensitive<T> {
69    fn from(value: T) -> Self {
70        Self(value)
71    }
72}
73
74impl<T> std::fmt::Debug for Sensitive<T> {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str("Sensitive([REDACTED])")
77    }
78}
79
80impl<T> std::fmt::Display for Sensitive<T> {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str("[REDACTED]")
83    }
84}
85
86impl<T> Serialize for Sensitive<T> {
87    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
88    where
89        S: serde::Serializer,
90    {
91        serializer.serialize_str("[REDACTED]")
92    }
93}
94
95// Deserialize is intentionally NOT implemented — round-tripping a `Sensitive`
96// through serialisation must return `"[REDACTED]"`, not the original plaintext.
97// Callers that need to populate a `Sensitive` at deserialize time should
98// deserialize into a plain `T` and then wrap with `Sensitive::new`.
99
100/// Errors that can occur during logging operations
101#[derive(Debug, Error)]
102pub enum LoggingError {
103    #[error("Encryption failed: {message}")]
104    EncryptionFailed { message: String },
105
106    #[error("Key management error: {message}")]
107    KeyManagementError { message: String },
108
109    #[error("Serialization error: {source}")]
110    SerializationError {
111        #[from]
112        source: serde_json::Error,
113    },
114
115    #[error("I/O error: {source}")]
116    IoError {
117        #[from]
118        source: std::io::Error,
119    },
120
121    #[error("Configuration error: {message}")]
122    ConfigurationError { message: String },
123}
124
125/// Configuration for the logging module
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct LoggingConfig {
128    /// Enable/disable encrypted logging
129    pub enabled: bool,
130    /// Log file path
131    pub log_file_path: String,
132    /// Secret key name in SecretStore for encryption key
133    pub encryption_key_name: String,
134    /// Environment variable for encryption key (fallback only)
135    pub encryption_key_env: Option<String>,
136    /// Maximum log entry size in bytes
137    pub max_entry_size: usize,
138    /// Log retention period in days
139    pub retention_days: u32,
140    /// Enable PII detection and masking
141    pub enable_pii_masking: bool,
142    /// Batch size for log writes
143    pub batch_size: usize,
144}
145
146impl Default for LoggingConfig {
147    fn default() -> Self {
148        Self {
149            enabled: true,
150            log_file_path: "logs/model_io.encrypted.log".to_string(),
151            encryption_key_name: "symbiont/logging/encryption_key".to_string(),
152            encryption_key_env: Some("SYMBIONT_LOGGING_KEY".to_string()),
153            max_entry_size: 1024 * 1024, // 1MB
154            retention_days: 90,
155            enable_pii_masking: true,
156            batch_size: 100,
157        }
158    }
159}
160
161/// Type of model interaction being logged
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163pub enum ModelInteractionType {
164    /// Direct model prompt/completion
165    Completion,
166    /// Tool call execution
167    ToolCall,
168    /// RAG query processing
169    RagQuery,
170    /// Agent task execution
171    AgentExecution,
172}
173
174/// Log entry for model I/O operations
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ModelLogEntry {
177    /// Unique identifier for this log entry
178    pub id: String,
179    /// Agent that initiated the request
180    pub agent_id: AgentId,
181    /// Type of model interaction
182    pub interaction_type: ModelInteractionType,
183    /// Timestamp when the interaction started
184    pub timestamp: DateTime<Utc>,
185    /// Duration of the interaction
186    pub latency_ms: u64,
187    /// Model/service used
188    pub model_identifier: String,
189    /// Encrypted request data
190    pub request_data: EncryptedData,
191    /// Encrypted response data
192    pub response_data: Option<EncryptedData>,
193    /// Metadata (non-sensitive)
194    pub metadata: HashMap<String, String>,
195    /// Error information if the interaction failed
196    pub error: Option<String>,
197    /// Token usage statistics
198    pub token_usage: Option<TokenUsage>,
199}
200
201/// Raw (unencrypted) request data structure
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RequestData {
204    /// The prompt or query sent to the model
205    pub prompt: String,
206    /// Tool name (if applicable)
207    pub tool_name: Option<String>,
208    /// Tool arguments (if applicable)
209    pub tool_arguments: Option<serde_json::Value>,
210    /// Additional parameters
211    pub parameters: HashMap<String, serde_json::Value>,
212}
213
214/// Raw (unencrypted) response data structure
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct ResponseData {
217    /// Model's response content
218    pub content: String,
219    /// Tool execution result (if applicable)
220    pub tool_result: Option<serde_json::Value>,
221    /// Confidence score (if available)
222    pub confidence: Option<f64>,
223    /// Additional response metadata
224    pub metadata: HashMap<String, serde_json::Value>,
225}
226
227/// Token usage statistics
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct TokenUsage {
230    /// Input tokens consumed
231    pub input_tokens: u32,
232    /// Output tokens generated
233    pub output_tokens: u32,
234    /// Total tokens used
235    pub total_tokens: u32,
236}
237
238/// Encrypted model I/O logger
239pub struct ModelLogger {
240    config: LoggingConfig,
241    #[allow(dead_code)]
242    crypto: Aes256GcmCrypto,
243    #[allow(dead_code)]
244    secret_store: Option<Arc<dyn SecretStore>>,
245    encryption_key: String,
246}
247
248impl std::fmt::Debug for ModelLogger {
249    /// Redact the encryption key from any Debug output so `{:?}` or `dbg!()`
250    /// cannot accidentally leak the symmetric key into logs or crash dumps.
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("ModelLogger")
253            .field("config", &self.config)
254            .field("encryption_key", &"<redacted>")
255            .finish_non_exhaustive()
256    }
257}
258
259impl ModelLogger {
260    /// Create a new model logger with the given configuration and secret store
261    pub fn new(
262        config: LoggingConfig,
263        secret_store: Option<Arc<dyn SecretStore>>,
264    ) -> Result<Self, LoggingError> {
265        let crypto = Aes256GcmCrypto::new();
266
267        // Get encryption key
268        let encryption_key = Self::get_encryption_key(&config, &secret_store)?;
269
270        Ok(Self {
271            config,
272            crypto,
273            secret_store,
274            encryption_key,
275        })
276    }
277
278    /// Create a new logger with default configuration (no secret store)
279    pub fn with_defaults() -> Result<Self, LoggingError> {
280        Self::new(LoggingConfig::default(), None)
281    }
282
283    /// Get encryption key from SecretStore, environment variable, or generate new one
284    fn get_encryption_key(
285        config: &LoggingConfig,
286        secret_store: &Option<Arc<dyn SecretStore>>,
287    ) -> Result<String, LoggingError> {
288        // Try SecretStore first if available
289        if let Some(store) = secret_store {
290            if let Ok(secret) =
291                futures::executor::block_on(store.get_secret(&config.encryption_key_name))
292            {
293                log::debug!("Retrieved logging encryption key from SecretStore");
294                return Ok(secret.value().to_string());
295            } else {
296                log::warn!("Failed to retrieve logging encryption key from SecretStore, falling back to environment variable");
297            }
298        }
299
300        // Try environment variable as fallback
301        if let Some(env_var) = &config.encryption_key_env {
302            if let Ok(key) = KeyUtils::get_key_from_env(env_var) {
303                log::debug!("Retrieved logging encryption key from environment variable");
304                return Ok(key);
305            }
306        }
307
308        // Final fallback: generate or retrieve from keychain
309        let key_utils = KeyUtils::new();
310        key_utils
311            .get_or_create_key()
312            .map_err(|e| LoggingError::KeyManagementError {
313                message: format!("Failed to get encryption key: {}", e),
314            })
315    }
316
317    /// Log a model request (before execution)
318    pub async fn log_request(
319        &self,
320        agent_id: AgentId,
321        interaction_type: ModelInteractionType,
322        model_identifier: &str,
323        request_data: RequestData,
324        metadata: HashMap<String, String>,
325    ) -> Result<String, LoggingError> {
326        if !self.config.enabled {
327            return Ok(String::new());
328        }
329
330        let entry_id = Uuid::new_v4().to_string();
331        let timestamp = Utc::now();
332
333        // Mask PII if enabled
334        let sanitized_request = if self.config.enable_pii_masking {
335            self.mask_pii_in_request(request_data)?
336        } else {
337            request_data
338        };
339
340        // Encrypt request data
341        let encrypted_request = self.encrypt_request_data(&sanitized_request)?;
342
343        // Create log entry (without response data initially)
344        let log_entry = ModelLogEntry {
345            id: entry_id.clone(),
346            agent_id,
347            interaction_type,
348            timestamp,
349            latency_ms: 0, // Will be updated when response is logged
350            model_identifier: model_identifier.to_string(),
351            request_data: encrypted_request,
352            response_data: None,
353            metadata,
354            error: None,
355            token_usage: None,
356        };
357
358        self.write_log_entry(&log_entry).await?;
359
360        log::debug!("Logged model request {} for agent {}", entry_id, agent_id);
361        Ok(entry_id)
362    }
363
364    /// Log a model response (after execution)
365    pub async fn log_response(
366        &self,
367        entry_id: &str,
368        response_data: ResponseData,
369        latency: Duration,
370        token_usage: Option<TokenUsage>,
371        error: Option<String>,
372    ) -> Result<(), LoggingError> {
373        if !self.config.enabled {
374            return Ok(());
375        }
376
377        // Mask PII if enabled
378        let sanitized_response = if self.config.enable_pii_masking {
379            self.mask_pii_in_response(response_data)?
380        } else {
381            response_data
382        };
383
384        // Encrypt response data
385        let encrypted_response = self.encrypt_response_data(&sanitized_response)?;
386
387        // Create update entry
388        let update_entry = serde_json::json!({
389            "id": entry_id,
390            "response_data": encrypted_response,
391            "latency_ms": latency.as_millis() as u64,
392            "token_usage": token_usage,
393            "error": error,
394            "updated_at": Utc::now()
395        });
396
397        self.write_log_update(&update_entry).await?;
398
399        log::debug!("Logged model response for entry {}", entry_id);
400        Ok(())
401    }
402
403    /// Convenience method to log a complete interaction
404    #[allow(clippy::too_many_arguments)]
405    pub async fn log_interaction(
406        &self,
407        agent_id: AgentId,
408        interaction_type: ModelInteractionType,
409        model_identifier: &str,
410        request_data: RequestData,
411        response_data: ResponseData,
412        latency: Duration,
413        metadata: HashMap<String, String>,
414        token_usage: Option<TokenUsage>,
415        error: Option<String>,
416    ) -> Result<(), LoggingError> {
417        if !self.config.enabled {
418            return Ok(());
419        }
420
421        let entry_id = Uuid::new_v4().to_string();
422        let timestamp = Utc::now();
423
424        // Mask PII if enabled
425        let sanitized_request = if self.config.enable_pii_masking {
426            self.mask_pii_in_request(request_data)?
427        } else {
428            request_data
429        };
430
431        let sanitized_response = if self.config.enable_pii_masking {
432            self.mask_pii_in_response(response_data)?
433        } else {
434            response_data
435        };
436
437        // Encrypt data
438        let encrypted_request = self.encrypt_request_data(&sanitized_request)?;
439        let encrypted_response = self.encrypt_response_data(&sanitized_response)?;
440
441        // Create complete log entry
442        let log_entry = ModelLogEntry {
443            id: entry_id,
444            agent_id,
445            interaction_type,
446            timestamp,
447            latency_ms: latency.as_millis() as u64,
448            model_identifier: model_identifier.to_string(),
449            request_data: encrypted_request,
450            response_data: Some(encrypted_response),
451            metadata,
452            error,
453            token_usage,
454        };
455
456        self.write_log_entry(&log_entry).await?;
457
458        log::debug!("Logged complete model interaction for agent {}", agent_id);
459        Ok(())
460    }
461
462    /// Encrypt request data
463    fn encrypt_request_data(&self, data: &RequestData) -> Result<EncryptedData, LoggingError> {
464        let json_data = serde_json::to_string(data)?;
465        let encrypted =
466            Aes256GcmCrypto::encrypt_with_password(json_data.as_bytes(), &self.encryption_key)
467                .map_err(|e| LoggingError::EncryptionFailed {
468                    message: format!("Failed to encrypt request data: {}", e),
469                })?;
470
471        Ok(encrypted)
472    }
473
474    /// Encrypt response data
475    fn encrypt_response_data(&self, data: &ResponseData) -> Result<EncryptedData, LoggingError> {
476        let json_data = serde_json::to_string(data)?;
477        let encrypted =
478            Aes256GcmCrypto::encrypt_with_password(json_data.as_bytes(), &self.encryption_key)
479                .map_err(|e| LoggingError::EncryptionFailed {
480                    message: format!("Failed to encrypt response data: {}", e),
481                })?;
482
483        Ok(encrypted)
484    }
485
486    /// Basic PII masking for request data
487    fn mask_pii_in_request(&self, mut data: RequestData) -> Result<RequestData, LoggingError> {
488        // Basic patterns for common PII
489        data.prompt = self.mask_sensitive_patterns(&data.prompt);
490
491        // Mask tool arguments if they contain sensitive data
492        if let Some(ref mut args) = data.tool_arguments {
493            *args = self.mask_json_values(args.clone());
494        }
495
496        // Mask parameters (check key names for sensitivity)
497        for (key, value) in data.parameters.iter_mut() {
498            if self.is_sensitive_key(key) {
499                *value = serde_json::Value::String("***".to_string());
500            } else {
501                *value = self.mask_json_values(value.clone());
502            }
503        }
504
505        Ok(data)
506    }
507
508    /// Basic PII masking for response data
509    fn mask_pii_in_response(&self, mut data: ResponseData) -> Result<ResponseData, LoggingError> {
510        data.content = self.mask_sensitive_patterns(&data.content);
511
512        // Mask tool results
513        if let Some(ref mut result) = data.tool_result {
514            *result = self.mask_json_values(result.clone());
515        }
516
517        // Mask metadata (check key names for sensitivity)
518        for (key, value) in data.metadata.iter_mut() {
519            if self.is_sensitive_key(key) {
520                *value = serde_json::Value::String("***".to_string());
521            } else {
522                *value = self.mask_json_values(value.clone());
523            }
524        }
525
526        Ok(data)
527    }
528
529    /// Mask common sensitive patterns in text.
530    ///
531    /// The regex list is broader than before to catch shapes that slipped
532    /// through prior versions — Slack `xox[bapr]-…` tokens, GitHub
533    /// `gh[pousr]_…` tokens, OpenAI `sk-…`, AWS access keys, and PEM-armoured
534    /// private keys. Each pattern has a labelled replacement so an operator
535    /// grepping logs for `[REDACTED:<KIND>]` can still see that something
536    /// sensitive was scrubbed without exposing the value.
537    ///
538    /// Regexes are compiled once per call — this is not a hot path (it runs
539    /// on each model interaction) and keeps the code straightforward. For
540    /// schema-driven redaction on *new* code, prefer the [`Sensitive`]
541    /// wrapper which emits `[REDACTED]` unconditionally regardless of content.
542    fn mask_sensitive_patterns(&self, text: &str) -> String {
543        use regex::Regex;
544
545        // Ordered roughly by specificity → generality: more precise patterns
546        // run first so they "claim" a match before a looser one (e.g. a
547        // Slack token shouldn't fall through to the generic TOKEN= rule).
548        let patterns: &[(&str, &str)] = &[
549            // --- credentials we can recognise by prefix shape --------------
550            // OpenAI / Anthropic API keys
551            (r"\bsk-[A-Za-z0-9_\-]{20,}\b", "[REDACTED:API_KEY]"),
552            // Slack legacy tokens
553            (
554                r"\bxox[bapre]-[A-Za-z0-9-]{10,}\b",
555                "[REDACTED:SLACK_TOKEN]",
556            ),
557            // GitHub tokens (classic + fine-grained + user-to-server)
558            (r"\bgh[pousr]_[A-Za-z0-9]{30,}\b", "[REDACTED:GITHUB_TOKEN]"),
559            // AWS access key ID
560            (r"\bAKIA[0-9A-Z]{16}\b", "[REDACTED:AWS_KEY_ID]"),
561            // Google API key
562            (r"\bAIza[0-9A-Za-z_\-]{35}\b", "[REDACTED:GOOGLE_API_KEY]"),
563            // Stripe live/test keys
564            (
565                r"\bsk_(live|test)_[A-Za-z0-9]{20,}\b",
566                "[REDACTED:STRIPE_KEY]",
567            ),
568            // Bearer tokens in HTTP-style text
569            (
570                r"(?i)\bbearer\s+[A-Za-z0-9._\-]{16,}\b",
571                "Bearer [REDACTED]",
572            ),
573            // PEM-armoured private keys (single line match because log entries
574            // are usually flattened)
575            (
576                r"-----BEGIN (RSA |EC |OPENSSH |PGP |)PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |OPENSSH |PGP |)PRIVATE KEY-----",
577                "[REDACTED:PRIVATE_KEY]",
578            ),
579            // JWT-shape tokens (three dot-separated base64url segments)
580            (
581                r"\beyJ[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{5,}\b",
582                "[REDACTED:JWT]",
583            ),
584            // Generic API_KEY= / TOKEN= shapes (fallback)
585            (
586                r"(?i)\bapi[_\s-]*key[\s:=]+[A-Za-z0-9+/_\-]{12,}\b",
587                "api_key=[REDACTED]",
588            ),
589            (
590                r"(?i)\btoken[\s:=]+[A-Za-z0-9+/_\-]{12,}\b",
591                "token=[REDACTED]",
592            ),
593            (
594                r"(?i)\bsecret[\s:=]+[A-Za-z0-9+/_\-]{12,}\b",
595                "secret=[REDACTED]",
596            ),
597            (r"(?i)\bpassword[\s:=]+[^\s]{6,}\b", "password=[REDACTED]"),
598            // --- PII --------------------------------------------------------
599            // SSN
600            (r"\b\d{3}-\d{2}-\d{4}\b", "[REDACTED:SSN]"),
601            // Credit card
602            (
603                r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b",
604                "[REDACTED:CC]",
605            ),
606            // Email
607            (
608                r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
609                "[REDACTED:EMAIL]",
610            ),
611            // US phone
612            (r"\b\d{3}[\s-]?\d{3}[\s-]?\d{4}\b", "[REDACTED:PHONE]"),
613        ];
614
615        let mut masked_text = text.to_string();
616        for (pattern, replacement) in patterns {
617            if let Ok(re) = Regex::new(pattern) {
618                masked_text = re.replace_all(&masked_text, *replacement).to_string();
619            }
620        }
621
622        masked_text
623    }
624
625    /// Mask sensitive values in JSON structures
626    fn mask_json_values(&self, value: serde_json::Value) -> serde_json::Value {
627        match value {
628            serde_json::Value::String(s) => {
629                serde_json::Value::String(self.mask_sensitive_patterns(&s))
630            }
631            serde_json::Value::Object(mut map) => {
632                for (key, val) in map.iter_mut() {
633                    // Mask known sensitive keys completely
634                    if self.is_sensitive_key(key) {
635                        *val = serde_json::Value::String("***".to_string());
636                    } else {
637                        *val = self.mask_json_values(val.clone());
638                    }
639                }
640                serde_json::Value::Object(map)
641            }
642            serde_json::Value::Array(arr) => serde_json::Value::Array(
643                arr.into_iter().map(|v| self.mask_json_values(v)).collect(),
644            ),
645            _ => value,
646        }
647    }
648
649    /// Check if a JSON field name (or equivalent identifier) indicates
650    /// sensitive data. Used as the *schema-side* of the redaction pipeline:
651    /// if a field's key matches any substring here, its value is replaced
652    /// with `"***"` without further inspection. This is the
653    /// "schema-driven" leg of PII redaction — content-shape regex is a
654    /// fallback for text blobs where no key is available.
655    ///
656    /// The list is intentionally broad; false-positive redaction of log
657    /// metadata (e.g. a field literally called `auth_algorithm`) is cheaper
658    /// than a false-negative leak of a credential.
659    fn is_sensitive_key(&self, key: &str) -> bool {
660        const SENSITIVE_FRAGMENTS: &[&str] = &[
661            // Credentials
662            "password",
663            "passwd",
664            "passphrase",
665            "token",
666            "bearer",
667            "jwt",
668            "auth",
669            "authorization",
670            "session",
671            "cookie",
672            "set_cookie",
673            "api_key",
674            "apikey",
675            "access_key",
676            "private_key",
677            "client_secret",
678            "client_id",
679            "refresh_token",
680            "id_token",
681            "csrf",
682            "otp",
683            "totp",
684            "secret",
685            "credential",
686            "signature",
687            "hmac",
688            "hash",
689            "salt",
690            "key", // catches `key`, `pkey`, `signing_key`, `master_key`, …
691            // PII
692            "ssn",
693            "social_security",
694            "credit_card",
695            "card_number",
696            "cvv",
697            "pin",
698            "date_of_birth",
699            "dob",
700            "phone",
701            "address",
702            "email",
703            // Connection strings that routinely embed credentials
704            "dsn",
705            "connection_string",
706            "conn_str",
707            "database_url",
708            "db_url",
709            "redis_url",
710            "amqp_url",
711            "postgres_url",
712            "mongodb_uri",
713            "url", // covers `*_url` fields inherited from config objects
714        ];
715
716        let key_lower = key.to_lowercase();
717        SENSITIVE_FRAGMENTS
718            .iter()
719            .any(|&fragment| key_lower.contains(fragment))
720    }
721
722    /// Write a log entry to storage.
723    ///
724    /// Opens the log file for append and writes one JSONL record, followed
725    /// by `sync_all` so a crash loses only the in-flight entry rather than
726    /// silently truncating the entire history. On Unix the file's
727    /// permissions are tightened to 0o600 on first write — model I/O logs
728    /// contain prompts/responses and must not be world-readable.
729    async fn write_log_entry(&self, entry: &ModelLogEntry) -> Result<(), LoggingError> {
730        use tokio::io::AsyncWriteExt;
731
732        // Ensure log directory exists
733        if let Some(parent) = std::path::Path::new(&self.config.log_file_path).parent() {
734            tokio::fs::create_dir_all(parent).await?;
735        }
736
737        let json_line = serde_json::to_string(entry)?;
738        let log_line = format!("{}\n", json_line);
739
740        let mut file = tokio::fs::OpenOptions::new()
741            .create(true)
742            .append(true)
743            .open(&self.config.log_file_path)
744            .await?;
745
746        #[cfg(unix)]
747        {
748            use std::os::unix::fs::PermissionsExt;
749            let perms = std::fs::Permissions::from_mode(0o600);
750            // Best-effort: narrow permissions. Don't fail the write if the
751            // FS is a shared mount that rejects chmod, just warn.
752            if let Err(e) = tokio::fs::set_permissions(&self.config.log_file_path, perms).await {
753                log::warn!(
754                    "Failed to set 0o600 permissions on model log {}: {}",
755                    self.config.log_file_path,
756                    e
757                );
758            }
759        }
760
761        file.write_all(log_line.as_bytes()).await?;
762        // fsync so a crash between write() and the OS flush doesn't lose
763        // the last entry silently.
764        file.sync_all().await?;
765
766        Ok(())
767    }
768
769    /// Write a log update (for response data)
770    async fn write_log_update(&self, update: &serde_json::Value) -> Result<(), LoggingError> {
771        // In a production implementation, this would update the existing entry
772        // For now, we'll append an update record
773        let update_line = format!("UPDATE: {}\n", serde_json::to_string(update)?);
774
775        use tokio::io::AsyncWriteExt;
776        let mut file = tokio::fs::OpenOptions::new()
777            .create(true)
778            .append(true)
779            .open(&self.config.log_file_path)
780            .await?;
781
782        file.write_all(update_line.as_bytes()).await?;
783        file.flush().await?;
784
785        Ok(())
786    }
787
788    /// Decrypt and read log entries (for debugging/analysis)
789    pub async fn decrypt_log_entry(
790        &self,
791        encrypted_entry: &ModelLogEntry,
792    ) -> Result<(RequestData, Option<ResponseData>), LoggingError> {
793        // Decrypt request data
794        let request_json = Aes256GcmCrypto::decrypt_with_password(
795            &encrypted_entry.request_data,
796            &self.encryption_key,
797        )
798        .map_err(|e| LoggingError::EncryptionFailed {
799            message: format!("Failed to decrypt request data: {}", e),
800        })?;
801
802        let request_data: RequestData = serde_json::from_slice(&request_json)?;
803
804        // Decrypt response data if present
805        let response_data = if let Some(ref encrypted_response) = encrypted_entry.response_data {
806            let response_json =
807                Aes256GcmCrypto::decrypt_with_password(encrypted_response, &self.encryption_key)
808                    .map_err(|e| LoggingError::EncryptionFailed {
809                        message: format!("Failed to decrypt response data: {}", e),
810                    })?;
811
812            Some(serde_json::from_slice(&response_json)?)
813        } else {
814            None
815        };
816
817        Ok((request_data, response_data))
818    }
819}
820
821/// Helper trait for timing model operations
822pub trait TimedOperation {
823    /// Execute an operation and return the result with timing
824    #[allow(async_fn_in_trait)]
825    async fn timed<F, R, E>(&self, operation: F) -> (Result<R, E>, Duration)
826    where
827        F: std::future::Future<Output = Result<R, E>>;
828}
829
830impl TimedOperation for ModelLogger {
831    async fn timed<F, R, E>(&self, operation: F) -> (Result<R, E>, Duration)
832    where
833        F: std::future::Future<Output = Result<R, E>>,
834    {
835        let start = Instant::now();
836        let result = operation.await;
837        let duration = start.elapsed();
838        (result, duration)
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::types::AgentId;
846    use std::collections::HashMap;
847    use std::sync::Arc;
848    use tempfile::tempdir;
849
850    // Mock SecretStore for testing
851    #[derive(Debug, Clone)]
852    struct MockSecretStore {
853        secrets: HashMap<String, String>,
854        should_fail: bool,
855    }
856
857    impl MockSecretStore {
858        fn new() -> Self {
859            let mut secrets = HashMap::new();
860            secrets.insert(
861                "symbiont/logging/encryption_key".to_string(),
862                "test_key_123".to_string(),
863            );
864            Self {
865                secrets,
866                should_fail: false,
867            }
868        }
869
870        fn new_failing() -> Self {
871            Self {
872                secrets: HashMap::new(),
873                should_fail: true,
874            }
875        }
876    }
877
878    #[async_trait::async_trait]
879    impl crate::secrets::SecretStore for MockSecretStore {
880        async fn get_secret(
881            &self,
882            key: &str,
883        ) -> Result<crate::secrets::Secret, crate::secrets::SecretError> {
884            if self.should_fail {
885                return Err(crate::secrets::SecretError::NotFound {
886                    key: key.to_string(),
887                });
888            }
889
890            if let Some(value) = self.secrets.get(key) {
891                Ok(crate::secrets::Secret::new(key.to_string(), value.clone()))
892            } else {
893                Err(crate::secrets::SecretError::NotFound {
894                    key: key.to_string(),
895                })
896            }
897        }
898
899        async fn list_secrets(&self) -> Result<Vec<String>, crate::secrets::SecretError> {
900            Ok(self.secrets.keys().cloned().collect())
901        }
902    }
903
904    #[tokio::test]
905    async fn test_logger_creation_with_secret_store() {
906        let config = LoggingConfig {
907            log_file_path: "/tmp/test_model_logs.json".to_string(),
908            ..Default::default()
909        };
910
911        let secret_store: Arc<dyn crate::secrets::SecretStore> = Arc::new(MockSecretStore::new());
912        let logger = ModelLogger::new(config, Some(secret_store));
913        assert!(logger.is_ok());
914    }
915
916    #[tokio::test]
917    async fn test_logger_creation_without_secret_store() {
918        let config = LoggingConfig {
919            log_file_path: "/tmp/test_model_logs.json".to_string(),
920            encryption_key_env: Some("TEST_LOGGING_KEY".to_string()),
921            ..Default::default()
922        };
923
924        // Set environment variable for fallback
925        std::env::set_var("TEST_LOGGING_KEY", "fallback_key_456");
926
927        let logger = ModelLogger::new(config, None);
928        assert!(logger.is_ok());
929
930        std::env::remove_var("TEST_LOGGING_KEY");
931    }
932
933    #[tokio::test]
934    async fn test_logger_creation_with_defaults() {
935        let logger = ModelLogger::with_defaults();
936        assert!(logger.is_ok());
937    }
938
939    #[tokio::test]
940    async fn test_encryption_key_retrieval_priority() {
941        // Test SecretStore priority
942        let config = LoggingConfig {
943            encryption_key_name: "test/key".to_string(),
944            encryption_key_env: Some("TEST_ENV_KEY".to_string()),
945            ..Default::default()
946        };
947
948        let secret_store: Arc<dyn crate::secrets::SecretStore> = Arc::new(MockSecretStore::new());
949        std::env::set_var("TEST_ENV_KEY", "env_key_value");
950
951        let key = ModelLogger::get_encryption_key(&config, &Some(secret_store));
952        // Should get from secret store, not environment
953        assert!(key.is_ok());
954
955        std::env::remove_var("TEST_ENV_KEY");
956    }
957
958    #[tokio::test]
959    async fn test_encryption_key_fallback_to_env() {
960        let config = LoggingConfig {
961            encryption_key_name: "nonexistent/key".to_string(),
962            encryption_key_env: Some("TEST_FALLBACK_KEY".to_string()),
963            ..Default::default()
964        };
965
966        let secret_store: Arc<dyn crate::secrets::SecretStore> =
967            Arc::new(MockSecretStore::new_failing());
968        std::env::set_var("TEST_FALLBACK_KEY", "fallback_env_key");
969
970        let key = ModelLogger::get_encryption_key(&config, &Some(secret_store));
971        assert!(key.is_ok());
972
973        std::env::remove_var("TEST_FALLBACK_KEY");
974    }
975
976    #[tokio::test]
977    async fn test_encryption_decryption_roundtrip() {
978        let logger = ModelLogger::with_defaults().unwrap();
979
980        let request_data = RequestData {
981            prompt: "Test prompt".to_string(),
982            tool_name: Some("test_tool".to_string()),
983            tool_arguments: Some(serde_json::json!({"arg1": "value1"})),
984            parameters: {
985                let mut params = HashMap::new();
986                params.insert("param1".to_string(), serde_json::json!("value1"));
987                params
988            },
989        };
990
991        let response_data = ResponseData {
992            content: "Test response".to_string(),
993            tool_result: Some(serde_json::json!({"result": "success"})),
994            confidence: Some(0.95),
995            metadata: {
996                let mut meta = HashMap::new();
997                meta.insert("meta1".to_string(), serde_json::json!("value1"));
998                meta
999            },
1000        };
1001
1002        // Test request encryption/decryption
1003        let encrypted_request = logger.encrypt_request_data(&request_data).unwrap();
1004        let encrypted_response = logger.encrypt_response_data(&response_data).unwrap();
1005
1006        // Create a mock log entry for decryption testing
1007        let log_entry = ModelLogEntry {
1008            id: "test_id".to_string(),
1009            agent_id: AgentId::new(),
1010            interaction_type: ModelInteractionType::Completion,
1011            timestamp: chrono::Utc::now(),
1012            latency_ms: 100,
1013            model_identifier: "test_model".to_string(),
1014            request_data: encrypted_request,
1015            response_data: Some(encrypted_response),
1016            metadata: HashMap::new(),
1017            error: None,
1018            token_usage: None,
1019        };
1020
1021        let (decrypted_request, decrypted_response) =
1022            logger.decrypt_log_entry(&log_entry).await.unwrap();
1023
1024        assert_eq!(decrypted_request.prompt, request_data.prompt);
1025        assert_eq!(decrypted_request.tool_name, request_data.tool_name);
1026
1027        let decrypted_resp = decrypted_response.unwrap();
1028        assert_eq!(decrypted_resp.content, response_data.content);
1029        assert_eq!(decrypted_resp.confidence, response_data.confidence);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_pii_masking_comprehensive() {
1034        let logger = ModelLogger::with_defaults().unwrap();
1035
1036        // Test various PII patterns. Replacement strings now carry a
1037        // labelled `[REDACTED:<KIND>]` marker so a grep through logs can
1038        // prove redaction without revealing the source value.
1039        let test_cases = vec![
1040            ("SSN: 123-45-6789", "[REDACTED:SSN]"),
1041            ("Credit card: 4532-1234-5678-9012", "[REDACTED:CC]"),
1042            ("Email: user@example.com", "[REDACTED:EMAIL]"),
1043            ("Phone: 555-123-4567", "[REDACTED:PHONE]"),
1044            ("API_KEY: abc123def456ghi789abcdef", "api_key=[REDACTED]"),
1045            ("TOKEN: xyz789uvw456rst123abcdef", "token=[REDACTED]"),
1046        ];
1047
1048        for (input, expected_pattern) in test_cases {
1049            let masked = logger.mask_sensitive_patterns(input);
1050            assert!(
1051                masked.contains(expected_pattern),
1052                "Failed to mask '{}', got '{}'",
1053                input,
1054                masked
1055            );
1056        }
1057    }
1058
1059    #[tokio::test]
1060    async fn test_pii_masking_json_values() {
1061        let logger = ModelLogger::with_defaults().unwrap();
1062
1063        let json_data = serde_json::json!({
1064            "password": "secret123",
1065            "api_key": "abc123def456",
1066            "username": "john_doe",
1067            "data": "safe_content",
1068            "nested": {
1069                "token": "xyz789",
1070                "info": "public_info"
1071            }
1072        });
1073
1074        let masked_json = logger.mask_json_values(json_data);
1075
1076        // Sensitive keys should be masked
1077        assert_eq!(masked_json["password"], "***");
1078        assert_eq!(masked_json["api_key"], "***");
1079        assert_eq!(masked_json["nested"]["token"], "***");
1080
1081        // Non-sensitive keys should remain
1082        assert_eq!(masked_json["username"], "john_doe");
1083        assert_eq!(masked_json["data"], "safe_content");
1084        assert_eq!(masked_json["nested"]["info"], "public_info");
1085    }
1086
1087    #[tokio::test]
1088    async fn test_sensitive_key_detection() {
1089        let logger = ModelLogger::with_defaults().unwrap();
1090
1091        // Sensitive keys
1092        let sensitive_keys = vec![
1093            "password",
1094            "PASSWORD",
1095            "Password",
1096            "token",
1097            "TOKEN",
1098            "auth_token",
1099            "key",
1100            "api_key",
1101            "API_KEY",
1102            "secret",
1103            "SECRET",
1104            "client_secret",
1105            "credential",
1106            "credentials",
1107            "ssn",
1108            "social_security",
1109            "credit_card",
1110            "card_number",
1111            "cvv",
1112            "pin",
1113        ];
1114
1115        for key in sensitive_keys {
1116            assert!(
1117                logger.is_sensitive_key(key),
1118                "Should detect '{}' as sensitive",
1119                key
1120            );
1121        }
1122
1123        // Non-sensitive keys
1124        let safe_keys = vec![
1125            "username",
1126            "user_id",
1127            "name",
1128            "data",
1129            "content",
1130            "message",
1131            "timestamp",
1132            "id",
1133            "status",
1134        ];
1135
1136        for key in safe_keys {
1137            assert!(
1138                !logger.is_sensitive_key(key),
1139                "Should not detect '{}' as sensitive",
1140                key
1141            );
1142        }
1143    }
1144
1145    #[tokio::test]
1146    async fn test_log_request_and_response() {
1147        let temp_dir = tempdir().unwrap();
1148        let log_path = temp_dir.path().join("test_request_response.json");
1149
1150        let config = LoggingConfig {
1151            log_file_path: log_path.to_string_lossy().to_string(),
1152            ..Default::default()
1153        };
1154
1155        let logger = ModelLogger::new(config, None).unwrap();
1156        let agent_id = AgentId::new();
1157
1158        let request_data = RequestData {
1159            prompt: "What is the weather?".to_string(),
1160            tool_name: None,
1161            tool_arguments: None,
1162            parameters: HashMap::new(),
1163        };
1164
1165        // Log request
1166        let entry_id = logger
1167            .log_request(
1168                agent_id,
1169                ModelInteractionType::Completion,
1170                "test-model",
1171                request_data,
1172                HashMap::new(),
1173            )
1174            .await
1175            .unwrap();
1176
1177        assert!(!entry_id.is_empty());
1178
1179        // Log response
1180        let response_data = ResponseData {
1181            content: "The weather is sunny".to_string(),
1182            tool_result: None,
1183            confidence: Some(0.95),
1184            metadata: HashMap::new(),
1185        };
1186
1187        let result = logger
1188            .log_response(
1189                &entry_id,
1190                response_data,
1191                Duration::from_millis(150),
1192                Some(TokenUsage {
1193                    input_tokens: 10,
1194                    output_tokens: 15,
1195                    total_tokens: 25,
1196                }),
1197                None,
1198            )
1199            .await;
1200
1201        assert!(result.is_ok());
1202
1203        // Verify log file was created and updated
1204        assert!(tokio::fs::metadata(&log_path).await.is_ok());
1205    }
1206
1207    #[tokio::test]
1208    async fn test_complete_interaction_logging() {
1209        let temp_dir = tempdir().unwrap();
1210        let log_path = temp_dir.path().join("test_complete_interaction.json");
1211
1212        let config = LoggingConfig {
1213            log_file_path: log_path.to_string_lossy().to_string(),
1214            ..Default::default()
1215        };
1216
1217        let logger = ModelLogger::new(config, None).unwrap();
1218        let agent_id = AgentId::new();
1219
1220        let request_data = RequestData {
1221            prompt: "Generate code for sorting".to_string(),
1222            tool_name: Some("code_generator".to_string()),
1223            tool_arguments: Some(serde_json::json!({"language": "python"})),
1224            parameters: {
1225                let mut params = HashMap::new();
1226                params.insert("temperature".to_string(), serde_json::json!(0.7));
1227                params
1228            },
1229        };
1230
1231        let response_data = ResponseData {
1232            content: "def sort_list(lst): return sorted(lst)".to_string(),
1233            tool_result: Some(serde_json::json!({"status": "success"})),
1234            confidence: Some(0.92),
1235            metadata: {
1236                let mut meta = HashMap::new();
1237                meta.insert("language".to_string(), serde_json::json!("python"));
1238                meta
1239            },
1240        };
1241
1242        let result = logger
1243            .log_interaction(
1244                agent_id,
1245                ModelInteractionType::ToolCall,
1246                "test-code-model",
1247                request_data,
1248                response_data,
1249                Duration::from_millis(350),
1250                {
1251                    let mut meta = HashMap::new();
1252                    meta.insert("session_id".to_string(), "test_session".to_string());
1253                    meta
1254                },
1255                Some(TokenUsage {
1256                    input_tokens: 25,
1257                    output_tokens: 40,
1258                    total_tokens: 65,
1259                }),
1260                None,
1261            )
1262            .await;
1263
1264        assert!(result.is_ok());
1265
1266        // Verify log file was created
1267        assert!(tokio::fs::metadata(&log_path).await.is_ok());
1268    }
1269
1270    #[tokio::test]
1271    async fn test_logging_disabled() {
1272        let config = LoggingConfig {
1273            enabled: false,
1274            ..Default::default()
1275        };
1276
1277        let logger = ModelLogger::new(config, None).unwrap();
1278        let agent_id = AgentId::new();
1279
1280        let request_data = RequestData {
1281            prompt: "Test prompt".to_string(),
1282            tool_name: None,
1283            tool_arguments: None,
1284            parameters: HashMap::new(),
1285        };
1286
1287        // When logging is disabled, should return empty string
1288        let entry_id = logger
1289            .log_request(
1290                agent_id,
1291                ModelInteractionType::Completion,
1292                "test-model",
1293                request_data,
1294                HashMap::new(),
1295            )
1296            .await
1297            .unwrap();
1298
1299        assert!(entry_id.is_empty());
1300    }
1301
1302    #[tokio::test]
1303    async fn test_logging_with_error() {
1304        let temp_dir = tempdir().unwrap();
1305        let log_path = temp_dir.path().join("test_error_logging.json");
1306
1307        let config = LoggingConfig {
1308            log_file_path: log_path.to_string_lossy().to_string(),
1309            ..Default::default()
1310        };
1311
1312        let logger = ModelLogger::new(config, None).unwrap();
1313        let agent_id = AgentId::new();
1314
1315        let request_data = RequestData {
1316            prompt: "Error test".to_string(),
1317            tool_name: None,
1318            tool_arguments: None,
1319            parameters: HashMap::new(),
1320        };
1321
1322        let response_data = ResponseData {
1323            content: "Error occurred".to_string(),
1324            tool_result: None,
1325            confidence: None,
1326            metadata: HashMap::new(),
1327        };
1328
1329        let result = logger
1330            .log_interaction(
1331                agent_id,
1332                ModelInteractionType::Completion,
1333                "test-model",
1334                request_data,
1335                response_data,
1336                Duration::from_millis(50),
1337                HashMap::new(),
1338                None,
1339                Some("Model execution failed".to_string()),
1340            )
1341            .await;
1342
1343        assert!(result.is_ok());
1344        assert!(tokio::fs::metadata(&log_path).await.is_ok());
1345    }
1346
1347    #[tokio::test]
1348    async fn test_logging_config_validation() {
1349        // Test default config
1350        let config = LoggingConfig::default();
1351        assert!(config.enabled);
1352        assert_eq!(config.log_file_path, "logs/model_io.encrypted.log");
1353        assert_eq!(
1354            config.encryption_key_name,
1355            "symbiont/logging/encryption_key"
1356        );
1357        assert_eq!(config.max_entry_size, 1024 * 1024);
1358        assert_eq!(config.retention_days, 90);
1359        assert!(config.enable_pii_masking);
1360        assert_eq!(config.batch_size, 100);
1361    }
1362
1363    #[tokio::test]
1364    async fn test_model_interaction_types() {
1365        // Test all ModelInteractionType variants
1366        let types = vec![
1367            ModelInteractionType::Completion,
1368            ModelInteractionType::ToolCall,
1369            ModelInteractionType::RagQuery,
1370            ModelInteractionType::AgentExecution,
1371        ];
1372
1373        for interaction_type in types {
1374            // Ensure they can be serialized/deserialized
1375            let serialized = serde_json::to_string(&interaction_type).unwrap();
1376            let deserialized: ModelInteractionType = serde_json::from_str(&serialized).unwrap();
1377            assert_eq!(interaction_type, deserialized);
1378        }
1379    }
1380
1381    #[tokio::test]
1382    async fn test_token_usage_tracking() {
1383        let token_usage = TokenUsage {
1384            input_tokens: 100,
1385            output_tokens: 50,
1386            total_tokens: 150,
1387        };
1388
1389        // Test serialization
1390        let serialized = serde_json::to_string(&token_usage).unwrap();
1391        let deserialized: TokenUsage = serde_json::from_str(&serialized).unwrap();
1392
1393        assert_eq!(token_usage.input_tokens, deserialized.input_tokens);
1394        assert_eq!(token_usage.output_tokens, deserialized.output_tokens);
1395        assert_eq!(token_usage.total_tokens, deserialized.total_tokens);
1396    }
1397
1398    #[tokio::test]
1399    async fn test_request_response_data_structures() {
1400        let request_data = RequestData {
1401            prompt: "Test prompt".to_string(),
1402            tool_name: Some("test_tool".to_string()),
1403            tool_arguments: Some(serde_json::json!({"arg": "value"})),
1404            parameters: {
1405                let mut params = HashMap::new();
1406                params.insert("temp".to_string(), serde_json::json!(0.8));
1407                params
1408            },
1409        };
1410
1411        let response_data = ResponseData {
1412            content: "Test response".to_string(),
1413            tool_result: Some(serde_json::json!({"result": "success"})),
1414            confidence: Some(0.9),
1415            metadata: {
1416                let mut meta = HashMap::new();
1417                meta.insert("model".to_string(), serde_json::json!("test"));
1418                meta
1419            },
1420        };
1421
1422        // Test serialization/deserialization
1423        let req_serialized = serde_json::to_string(&request_data).unwrap();
1424        let req_deserialized: RequestData = serde_json::from_str(&req_serialized).unwrap();
1425        assert_eq!(request_data.prompt, req_deserialized.prompt);
1426
1427        let resp_serialized = serde_json::to_string(&response_data).unwrap();
1428        let resp_deserialized: ResponseData = serde_json::from_str(&resp_serialized).unwrap();
1429        assert_eq!(response_data.content, resp_deserialized.content);
1430    }
1431
1432    #[tokio::test]
1433    async fn test_pii_masking_request_data() {
1434        let logger = ModelLogger::with_defaults().unwrap();
1435
1436        let request_data = RequestData {
1437            prompt: "My SSN is 123-45-6789 and email is user@example.com".to_string(),
1438            tool_name: Some("sensitive_tool".to_string()),
1439            tool_arguments: Some(serde_json::json!({
1440                "user_password": "secret123",
1441                "api_token": "xyz789",
1442                "safe_data": "public_info"
1443            })),
1444            parameters: {
1445                let mut params = HashMap::new();
1446                params.insert("auth_key".to_string(), serde_json::json!("sensitive_key"));
1447                params.insert("username".to_string(), serde_json::json!("john_doe"));
1448                params
1449            },
1450        };
1451
1452        let masked_request = logger.mask_pii_in_request(request_data).unwrap();
1453
1454        // Check prompt masking
1455        assert!(!masked_request.prompt.contains("123-45-6789"));
1456        assert!(!masked_request.prompt.contains("user@example.com"));
1457
1458        // Check tool arguments masking
1459        if let Some(args) = &masked_request.tool_arguments {
1460            assert_eq!(args["user_password"], "***");
1461            assert_eq!(args["api_token"], "***");
1462            assert_eq!(args["safe_data"], "public_info");
1463        }
1464
1465        // Check parameters masking
1466        assert_eq!(masked_request.parameters["auth_key"], "***");
1467        assert_eq!(masked_request.parameters["username"], "john_doe");
1468    }
1469
1470    #[tokio::test]
1471    async fn test_pii_masking_response_data() {
1472        let logger = ModelLogger::with_defaults().unwrap();
1473
1474        let response_data = ResponseData {
1475            content: "Your SSN is 123-45-6789 and email is user@example.com".to_string(),
1476            tool_result: Some(serde_json::json!({
1477                "password": "hidden123",
1478                "result": "success"
1479            })),
1480            confidence: Some(0.95),
1481            metadata: {
1482                let mut meta = HashMap::new();
1483                meta.insert("secret".to_string(), serde_json::json!("confidential"));
1484                meta.insert("public".to_string(), serde_json::json!("open"));
1485                meta
1486            },
1487        };
1488
1489        let masked_response = logger.mask_pii_in_response(response_data).unwrap();
1490
1491        // Check content masking
1492        assert!(!masked_response.content.contains("123-45-6789"));
1493        assert!(!masked_response.content.contains("user@example.com"));
1494
1495        // Check tool result masking
1496        if let Some(result) = &masked_response.tool_result {
1497            assert_eq!(result["password"], "***");
1498            assert_eq!(result["result"], "success");
1499        }
1500
1501        // Check metadata masking
1502        assert_eq!(masked_response.metadata["secret"], "***");
1503        assert_eq!(masked_response.metadata["public"], "open");
1504    }
1505}