Skip to main content

heartbit_core/agent/
audit.rs

1//! Agent audit trail — structured records of LLM calls, tool invocations, and completions.
2
3#![allow(missing_docs)]
4use parking_lot::RwLock;
5use std::future::Future;
6use std::pin::Pin;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::auth::TenantScope;
12use crate::error::Error;
13use crate::llm::types::TokenUsage;
14
15/// Controls what data is stored in audit records.
16///
17/// **BREAKING CHANGE (F-AUTH-6)**: the default is now `MetadataOnly`.
18/// Previously the default was `Full`, which logged complete LLM responses,
19/// tool inputs, and tool outputs — incompatible with privacy-by-default
20/// for regulated deployments (RGPD/HIPAA). Set `AuditMode::Full` explicitly
21/// when you want content captured (e.g., debugging, single-tenant CLI dev).
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AuditMode {
25    /// Full content logging — explicit opt-in.
26    Full,
27    /// Metadata only: tool names, timing, token counts, verdicts. No user content.
28    /// **Default** (F-AUTH-6).
29    #[default]
30    MetadataOnly,
31}
32
33/// Allow-list of audit payload keys that are guaranteed to contain only
34/// metadata (no user content). Anything outside this list is stripped in
35/// `MetadataOnly` mode.
36///
37/// SECURITY (F-AUTH-3): the previous implementation used a deny-list
38/// (`text|input|output|data|command|content|result`) which silently leaked
39/// `result_preview`, `error`, `reason`, and any nested user content (deny
40/// list was not recursive). For privacy-by-default the boundary needs to be
41/// "deny everything except known metadata", recursively.
42const METADATA_ALLOWLIST: &[&str] = &[
43    "tool_name",
44    "tool_call_id",
45    "tool_call_count",
46    "duration_ms",
47    "latency_ms",
48    "is_error",
49    "turn",
50    "hook",
51    "event_type",
52    "stop_reason",
53    "model",
54    "total_tool_calls",
55    "input_tokens",
56    "output_tokens",
57    "cache_creation_input_tokens",
58    "cache_read_input_tokens",
59    "reasoning_tokens",
60    "agent",
61    "tenant_id",
62    "user_id",
63    "verdict",
64    "guardrail_name",
65    "consecutive_count",
66    "tool_names",
67    "from_tier",
68    "to_tier",
69    "decision",
70    "priority",
71    "spawned_name",
72    "complexity_score",
73    "escalated",
74    "tool_results_pruned",
75    "tool_results_total",
76    "bytes_saved",
77    "success",
78    // `reason` is the policy-rule descriptor on guardrail denials and
79    // doom-loop signals — content is the rule pattern / event type, not
80    // user data. Documented as a metadata field by upstream call sites.
81    "reason",
82];
83
84/// Strip user-content fields from an audit record payload, keeping only metadata.
85///
86/// Recursive walk: keeps only keys in [`METADATA_ALLOWLIST`]; replaces every
87/// other field's value with the string `"[stripped]"` so the audit log
88/// records *which* fields existed without their content. Numbers and bools
89/// are preserved (they cannot leak content). Arrays/objects are recursed.
90///
91/// Prefer [`strip_content_owned`] in hot paths — it consumes the payload by
92/// ownership and avoids the top-level `clone()` (P-CROSS-7).
93pub fn strip_content(payload: &serde_json::Value) -> serde_json::Value {
94    strip_content_owned(payload.clone())
95}
96
97/// Owned variant of [`strip_content`] — consumes `payload` and walks the tree
98/// without cloning any preserved scalars/arrays. Used by the agent runner to
99/// strip audit payloads in place when `AuditMode::MetadataOnly` is active.
100///
101/// Per `tasks/perf-audit-cross.md` (P-CROSS-7): the previous `&Value` variant
102/// cloned every scalar, every recursed sub-value, and the top-level payload
103/// — ~1 ms of avoidable CPU per audit record on 100 KB tool outputs.
104pub fn strip_content_owned(payload: serde_json::Value) -> serde_json::Value {
105    strip_value_owned(payload)
106}
107
108fn strip_value_owned(value: serde_json::Value) -> serde_json::Value {
109    match value {
110        serde_json::Value::Object(map) => {
111            let mut stripped = serde_json::Map::with_capacity(map.len());
112            for (key, val) in map {
113                if METADATA_ALLOWLIST.contains(&key.as_str()) {
114                    // Even allow-listed keys: recurse so nested user content
115                    // inside `tool_names: [...]` is still cleaned (it's a
116                    // string array, scalars survive — see strip_scalar).
117                    stripped.insert(key, strip_scalar_or_recurse_owned(val));
118                } else {
119                    // Replace non-metadata content with a marker. Preserve
120                    // the structural type so downstream tooling sees a
121                    // similarly-shaped object.
122                    stripped.insert(key, redact_marker_owned(val));
123                }
124            }
125            serde_json::Value::Object(stripped)
126        }
127        // Top-level non-object scalars/arrays pass through unchanged. The
128        // production `AuditRecord.payload` is always built as a JSON object;
129        // the non-object case exists only for tests / legacy callers.
130        other => other,
131    }
132}
133
134fn strip_scalar_or_recurse_owned(value: serde_json::Value) -> serde_json::Value {
135    match value {
136        serde_json::Value::Object(_) | serde_json::Value::Array(_) => strip_value_owned(value),
137        // Scalars under allow-listed keys are safe (numbers, bools, string
138        // metadata like tool names).
139        other => other,
140    }
141}
142
143fn redact_marker_owned(value: serde_json::Value) -> serde_json::Value {
144    match value {
145        // Numbers and bools cannot leak user content; keep them.
146        v @ (serde_json::Value::Number(_)
147        | serde_json::Value::Bool(_)
148        | serde_json::Value::Null) => v,
149        _ => serde_json::Value::String("[stripped]".into()),
150    }
151}
152
153/// One entry per decision point in an agent run.
154///
155/// Records LLM responses, tool calls, tool results, run completion/failure,
156/// and guardrail denials with full (untruncated) payloads.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct AuditRecord {
159    pub agent: String,
160    pub turn: usize,
161    pub event_type: String,
162    pub payload: serde_json::Value,
163    pub usage: TokenUsage,
164    pub timestamp: DateTime<Utc>,
165    /// User ID of the authenticated user who triggered this action.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub user_id: Option<String>,
168    /// Tenant ID for multi-tenant isolation.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub tenant_id: Option<String>,
171    /// RFC 8693 delegation chain: \[actor1, actor2, ...\] from outermost to innermost.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub delegation_chain: Vec<String>,
174}
175
176/// Minimal interface for persisting and querying audit records.
177///
178/// The trail instance is scoped to a single run. Callers hold `Arc<dyn AuditTrail>`
179/// and read entries after `execute()` returns.
180pub trait AuditTrail: Send + Sync {
181    /// Record a single audit entry. Best-effort: failures are logged, never abort the agent.
182    fn record(
183        &self,
184        entry: AuditRecord,
185    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>;
186
187    /// Tenant-scoped read. Returns the most recent `limit` entries whose
188    /// `tenant_id` matches `scope.tenant_id`. Single-tenant scope (empty
189    /// tenant_id) returns rows where `tenant_id` is `None` or `""`.
190    fn entries(
191        &self,
192        scope: &TenantScope,
193        limit: usize,
194    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
195
196    /// Cross-tenant admin read. Renamed from the previous unscoped `entries()`
197    /// so call sites must explicitly opt in to cross-tenant visibility.
198    /// Returns the most recent `limit` entries.
199    fn entries_unscoped(
200        &self,
201        limit: usize,
202    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
203
204    /// Time-windowed scoped read. Returns the most recent `limit` entries for
205    /// the given scope where `timestamp >= since`.
206    fn entries_since(
207        &self,
208        scope: &TenantScope,
209        since: chrono::DateTime<chrono::Utc>,
210        limit: usize,
211    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
212
213    /// Delete entries older than `now - retain`. Returns count deleted.
214    fn prune(
215        &self,
216        retain: chrono::Duration,
217    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>>;
218
219    /// Erase all audit records for the given user (GDPR right to erasure).
220    /// Returns the number of records removed. Default: no-op returning 0.
221    fn erase_for_user(
222        &self,
223        _user_id: &str,
224    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
225        Box::pin(async { Ok(0) })
226    }
227}
228
229/// In-memory audit trail backed by `parking_lot::RwLock<Vec<AuditRecord>>`.
230///
231/// Lock is never held across `.await` — all operations are synchronous inside
232/// the lock, then wrapped in `Box::pin(async { ... })`. `parking_lot` is used
233/// (not `std::sync::RwLock`) for ~2× faster uncontended read acquisition on
234/// the audit hot path (every turn, every tool call); see T2 in
235/// `tasks/performance-audit-heartbit-core-2026-05-06.md`.
236pub struct InMemoryAuditTrail {
237    records: RwLock<Vec<AuditRecord>>,
238}
239
240impl InMemoryAuditTrail {
241    pub fn new() -> Self {
242        Self {
243            records: RwLock::new(Vec::new()),
244        }
245    }
246}
247
248impl Default for InMemoryAuditTrail {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl AuditTrail for InMemoryAuditTrail {
255    fn record(
256        &self,
257        entry: AuditRecord,
258    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
259        Box::pin(async move {
260            self.records.write().push(entry);
261            Ok(())
262        })
263    }
264
265    fn entries(
266        &self,
267        scope: &TenantScope,
268        limit: usize,
269    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
270        let tid = scope.tenant_id.clone();
271        Box::pin(async move {
272            let records = self.records.read();
273            let matched: Vec<AuditRecord> = records
274                .iter()
275                .filter(|r| r.tenant_id.as_deref().unwrap_or("") == tid.as_str())
276                .cloned()
277                .collect();
278            let start = matched.len().saturating_sub(limit);
279            Ok(matched[start..].to_vec())
280        })
281    }
282
283    fn entries_unscoped(
284        &self,
285        limit: usize,
286    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
287        Box::pin(async move {
288            let records = self.records.read();
289            let start = records.len().saturating_sub(limit);
290            Ok(records[start..].to_vec())
291        })
292    }
293
294    fn entries_since(
295        &self,
296        scope: &TenantScope,
297        since: chrono::DateTime<chrono::Utc>,
298        limit: usize,
299    ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
300        let tid = scope.tenant_id.clone();
301        Box::pin(async move {
302            let records = self.records.read();
303            let matched: Vec<AuditRecord> = records
304                .iter()
305                .filter(|r| {
306                    r.tenant_id.as_deref().unwrap_or("") == tid.as_str() && r.timestamp >= since
307                })
308                .cloned()
309                .collect();
310            let start = matched.len().saturating_sub(limit);
311            Ok(matched[start..].to_vec())
312        })
313    }
314
315    fn prune(
316        &self,
317        retain: chrono::Duration,
318    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
319        Box::pin(async move {
320            let cutoff = chrono::Utc::now() - retain;
321            let mut records = self.records.write();
322            let before = records.len();
323            records.retain(|r| r.timestamp >= cutoff);
324            Ok(before - records.len())
325        })
326    }
327
328    fn erase_for_user(
329        &self,
330        user_id: &str,
331    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
332        let user_id = user_id.to_string();
333        Box::pin(async move {
334            let mut records = self.records.write();
335            let before = records.len();
336            records.retain(|r| r.user_id.as_deref() != Some(user_id.as_str()));
337            Ok(before - records.len())
338        })
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::json;
346
347    fn make_record(agent: &str, event_type: &str, payload: serde_json::Value) -> AuditRecord {
348        AuditRecord {
349            agent: agent.into(),
350            turn: 1,
351            event_type: event_type.into(),
352            payload,
353            usage: TokenUsage::default(),
354            timestamp: Utc::now(),
355            user_id: None,
356            tenant_id: None,
357            delegation_chain: Vec::new(),
358        }
359    }
360
361    #[test]
362    fn audit_record_serializes() {
363        let record = make_record("test-agent", "llm_response", json!({"text": "hello"}));
364        let json = serde_json::to_string(&record).expect("serialize");
365        let deserialized: AuditRecord = serde_json::from_str(&json).expect("deserialize");
366        assert_eq!(deserialized.agent, "test-agent");
367        assert_eq!(deserialized.event_type, "llm_response");
368        assert_eq!(deserialized.payload, json!({"text": "hello"}));
369    }
370
371    #[tokio::test]
372    async fn in_memory_trail_stores_and_retrieves() {
373        let trail = InMemoryAuditTrail::new();
374        trail
375            .record(make_record("a", "llm_response", json!({"turn": 1})))
376            .await
377            .unwrap();
378        trail
379            .record(make_record("a", "tool_call", json!({"name": "bash"})))
380            .await
381            .unwrap();
382        trail
383            .record(make_record("a", "tool_result", json!({"ok": true})))
384            .await
385            .unwrap();
386
387        let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
388        assert_eq!(entries.len(), 3);
389        assert_eq!(entries[0].event_type, "llm_response");
390        assert_eq!(entries[1].event_type, "tool_call");
391        assert_eq!(entries[2].event_type, "tool_result");
392    }
393
394    #[tokio::test]
395    async fn in_memory_trail_empty_by_default() {
396        let trail = InMemoryAuditTrail::new();
397        let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
398        assert!(entries.is_empty());
399    }
400
401    fn make_record_with_context(
402        agent: &str,
403        event_type: &str,
404        payload: serde_json::Value,
405        user_id: Option<&str>,
406        tenant_id: Option<&str>,
407        delegation_chain: Vec<String>,
408    ) -> AuditRecord {
409        AuditRecord {
410            agent: agent.into(),
411            turn: 1,
412            event_type: event_type.into(),
413            payload,
414            usage: TokenUsage::default(),
415            timestamp: Utc::now(),
416            user_id: user_id.map(String::from),
417            tenant_id: tenant_id.map(String::from),
418            delegation_chain,
419        }
420    }
421
422    #[test]
423    fn audit_record_with_user_context() {
424        let record = make_record_with_context(
425            "agent-1",
426            "llm_response",
427            json!({"text": "hi"}),
428            Some("user-42"),
429            Some("tenant-a"),
430            vec!["actor-1".into(), "actor-2".into()],
431        );
432        let json_str = serde_json::to_string(&record).expect("serialize");
433        let deserialized: AuditRecord = serde_json::from_str(&json_str).expect("deserialize");
434        assert_eq!(deserialized.user_id.as_deref(), Some("user-42"));
435        assert_eq!(deserialized.tenant_id.as_deref(), Some("tenant-a"));
436        assert_eq!(deserialized.delegation_chain, vec!["actor-1", "actor-2"]);
437    }
438
439    #[test]
440    fn audit_record_backward_compat() {
441        // Old JSON without the new fields must deserialize with defaults.
442        let old_json = json!({
443            "agent": "old-agent",
444            "turn": 3,
445            "event_type": "tool_call",
446            "payload": {"name": "bash"},
447            "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0},
448            "timestamp": "2026-01-01T00:00:00Z"
449        });
450        let record: AuditRecord = serde_json::from_value(old_json).expect("deserialize old format");
451        assert_eq!(record.user_id, None);
452        assert_eq!(record.tenant_id, None);
453        assert!(record.delegation_chain.is_empty());
454    }
455
456    #[test]
457    fn audit_record_delegation_chain_omitted_when_empty() {
458        let record =
459            make_record_with_context("agent-1", "llm_response", json!({}), None, None, vec![]);
460        let json_val = serde_json::to_value(&record).expect("serialize");
461        assert!(
462            !json_val
463                .as_object()
464                .unwrap()
465                .contains_key("delegation_chain")
466        );
467    }
468
469    #[test]
470    fn audit_record_user_id_omitted_when_none() {
471        let record = make_record_with_context(
472            "agent-1",
473            "llm_response",
474            json!({}),
475            None,
476            Some("tenant-a"),
477            vec![],
478        );
479        let json_val = serde_json::to_value(&record).expect("serialize");
480        let obj = json_val.as_object().unwrap();
481        assert!(!obj.contains_key("user_id"));
482        assert!(obj.contains_key("tenant_id"));
483    }
484
485    #[tokio::test]
486    async fn entries_scoped_filters_correctly() {
487        use crate::auth::TenantScope;
488
489        let trail = InMemoryAuditTrail::new();
490        trail
491            .record(make_record_with_context(
492                "a",
493                "llm_response",
494                json!({}),
495                None,
496                Some("tenant-a"),
497                vec![],
498            ))
499            .await
500            .unwrap();
501        trail
502            .record(make_record_with_context(
503                "b",
504                "tool_call",
505                json!({}),
506                None,
507                Some("tenant-b"),
508                vec![],
509            ))
510            .await
511            .unwrap();
512        trail
513            .record(make_record_with_context(
514                "c",
515                "tool_result",
516                json!({}),
517                None,
518                Some("tenant-a"),
519                vec![],
520            ))
521            .await
522            .unwrap();
523
524        let scope = TenantScope::new("tenant-a");
525        let filtered = trail.entries(&scope, usize::MAX).await.unwrap();
526        assert_eq!(filtered.len(), 2);
527        assert!(
528            filtered
529                .iter()
530                .all(|r| r.tenant_id.as_deref() == Some("tenant-a"))
531        );
532    }
533
534    #[tokio::test]
535    async fn entries_unscoped_returns_all() {
536        let trail = InMemoryAuditTrail::new();
537        trail
538            .record(make_record_with_context(
539                "a",
540                "llm_response",
541                json!({}),
542                None,
543                Some("tenant-a"),
544                vec![],
545            ))
546            .await
547            .unwrap();
548        trail
549            .record(make_record_with_context(
550                "b",
551                "tool_call",
552                json!({}),
553                None,
554                Some("tenant-b"),
555                vec![],
556            ))
557            .await
558            .unwrap();
559        trail
560            .record(make_record_with_context(
561                "c",
562                "tool_result",
563                json!({}),
564                None,
565                None,
566                vec![],
567            ))
568            .await
569            .unwrap();
570
571        let all = trail.entries_unscoped(usize::MAX).await.unwrap();
572        assert_eq!(all.len(), 3);
573    }
574
575    #[tokio::test]
576    async fn audit_record_with_large_payload() {
577        let trail = InMemoryAuditTrail::new();
578        // 1MB payload — must survive untruncated
579        let large = "x".repeat(1_000_000);
580        let payload = json!({"data": large});
581        trail
582            .record(make_record("a", "tool_result", payload.clone()))
583            .await
584            .unwrap();
585
586        let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
587        assert_eq!(entries.len(), 1);
588        assert_eq!(entries[0].payload, payload);
589    }
590
591    // --- Phase 25: Privacy-Preserving Audit Mode tests ---
592
593    #[test]
594    fn metadata_only_strips_content_fields() {
595        let payload = json!({
596            "tool_name": "bash",
597            "text": "secret user input",
598            "input": {"command": "ls"},
599            "output": "file list",
600            "duration_ms": 42,
601            "is_error": false
602        });
603        let stripped = strip_content(&payload);
604        let obj = stripped.as_object().unwrap();
605        assert_eq!(obj["tool_name"], "bash");
606        assert_eq!(obj["duration_ms"], 42);
607        assert_eq!(obj["is_error"], false);
608        assert_eq!(obj["text"], "[stripped]");
609        assert_eq!(obj["input"], "[stripped]");
610        assert_eq!(obj["output"], "[stripped]");
611    }
612
613    #[test]
614    fn full_mode_preserves_content() {
615        let payload = json!({
616            "text": "hello world",
617            "tool_name": "bash",
618            "duration_ms": 10
619        });
620        // Full mode means no stripping — payload is used as-is
621        let mode = AuditMode::Full;
622        assert_eq!(mode, AuditMode::Full);
623        // The payload should remain unchanged (strip_content is only called in MetadataOnly)
624        assert_eq!(payload["text"], "hello world");
625        assert_eq!(payload["tool_name"], "bash");
626    }
627
628    #[test]
629    fn strip_content_replaces_known_fields_with_marker() {
630        let payload = json!({
631            "text": "some text",
632            "input": "some input",
633            "output": "some output",
634            "data": "some data",
635            "command": "some command",
636            "content": "some content",
637            "result": "some result"
638        });
639        let stripped = strip_content(&payload);
640        let obj = stripped.as_object().unwrap();
641        for key in &[
642            "text", "input", "output", "data", "command", "content", "result",
643        ] {
644            assert_eq!(obj[*key], "[stripped]", "field {key} should be stripped");
645        }
646    }
647
648    #[test]
649    fn strip_content_preserves_metadata_fields() {
650        let payload = json!({
651            "tool_name": "read_file",
652            "duration_ms": 100,
653            "is_error": false,
654            "turn": 3,
655            "hook": "pre_tool",
656            "reason": "policy violation",
657            "event_type": "tool_call",
658            "stop_reason": "end_turn",
659            "tool_call_count": 2
660        });
661        let stripped = strip_content(&payload);
662        assert_eq!(
663            stripped, payload,
664            "metadata-only payload should be unchanged"
665        );
666    }
667
668    #[tokio::test]
669    async fn erase_for_user_removes_matching_records() {
670        let trail = InMemoryAuditTrail::new();
671        trail
672            .record(make_record_with_context(
673                "a",
674                "llm_response",
675                json!({}),
676                Some("user-1"),
677                None,
678                vec![],
679            ))
680            .await
681            .unwrap();
682        trail
683            .record(make_record_with_context(
684                "b",
685                "tool_call",
686                json!({}),
687                Some("user-2"),
688                None,
689                vec![],
690            ))
691            .await
692            .unwrap();
693        trail
694            .record(make_record_with_context(
695                "c",
696                "tool_result",
697                json!({}),
698                Some("user-1"),
699                None,
700                vec![],
701            ))
702            .await
703            .unwrap();
704
705        let removed = trail.erase_for_user("user-1").await.unwrap();
706        assert_eq!(removed, 2);
707
708        let remaining = trail.entries_unscoped(usize::MAX).await.unwrap();
709        assert_eq!(remaining.len(), 1);
710        assert_eq!(remaining[0].user_id.as_deref(), Some("user-2"));
711    }
712
713    #[tokio::test]
714    async fn erase_for_user_no_matches_returns_zero() {
715        let trail = InMemoryAuditTrail::new();
716        trail
717            .record(make_record_with_context(
718                "a",
719                "llm_response",
720                json!({}),
721                Some("user-1"),
722                None,
723                vec![],
724            ))
725            .await
726            .unwrap();
727
728        let removed = trail.erase_for_user("user-999").await.unwrap();
729        assert_eq!(removed, 0);
730
731        let remaining = trail.entries_unscoped(usize::MAX).await.unwrap();
732        assert_eq!(remaining.len(), 1);
733    }
734
735    #[test]
736    fn audit_mode_serde_roundtrip() {
737        let full = AuditMode::Full;
738        let json_full = serde_json::to_string(&full).expect("serialize full");
739        assert_eq!(json_full, "\"full\"");
740        let deserialized: AuditMode = serde_json::from_str(&json_full).expect("deserialize full");
741        assert_eq!(deserialized, AuditMode::Full);
742
743        let meta = AuditMode::MetadataOnly;
744        let json_meta = serde_json::to_string(&meta).expect("serialize metadata_only");
745        assert_eq!(json_meta, "\"metadata_only\"");
746        let deserialized: AuditMode =
747            serde_json::from_str(&json_meta).expect("deserialize metadata_only");
748        assert_eq!(deserialized, AuditMode::MetadataOnly);
749    }
750
751    /// SECURITY (F-AUTH-3): the previous deny-list missed `result_preview`,
752    /// `error`, and any nested user-content fields. The new allow-list
753    /// approach must redact these even though they were not in the old list.
754    #[test]
755    fn strip_content_redacts_result_preview_and_error() {
756        // run_completed payload — result_preview is the first 1000 chars of
757        // the LLM's reply, which is user-facing content.
758        let payload = json!({
759            "total_tool_calls": 2,
760            "result_preview": "user secret content here"
761        });
762        let stripped = strip_content(&payload);
763        let obj = stripped.as_object().unwrap();
764        assert_eq!(obj["total_tool_calls"], 2);
765        assert_eq!(
766            obj["result_preview"], "[stripped]",
767            "result_preview MUST be stripped (F-AUTH-3)"
768        );
769
770        // run_failed payload — error message can echo user input.
771        let payload = json!({
772            "error": "tool foo failed: <user data was here>"
773        });
774        let stripped = strip_content(&payload);
775        let obj = stripped.as_object().unwrap();
776        assert_eq!(
777            obj["error"], "[stripped]",
778            "error MUST be stripped (F-AUTH-3)"
779        );
780    }
781
782    /// SECURITY (F-AUTH-3): nested user content inside arbitrary parent keys
783    /// (e.g. `meta.command`) was previously preserved because the deny-list
784    /// only scanned top-level keys. The recursive walk redacts the parent
785    /// when its key is not in the allow-list.
786    #[test]
787    fn strip_content_recursive_no_leak_via_nested_object() {
788        let payload = json!({
789            "meta": {
790                "command": "rm -rf /",
791                "input": {"file": "secret.txt"}
792            }
793        });
794        let stripped = strip_content(&payload);
795        let s = serde_json::to_string(&stripped).unwrap();
796        assert!(
797            !s.contains("rm -rf"),
798            "nested command must be redacted (F-AUTH-3): {s}"
799        );
800        assert!(
801            !s.contains("secret.txt"),
802            "nested user content must be redacted: {s}"
803        );
804    }
805
806    #[test]
807    fn strip_content_non_object_passthrough() {
808        let string_val = serde_json::Value::String("hello".into());
809        assert_eq!(strip_content(&string_val), string_val);
810
811        let number_val = json!(42);
812        assert_eq!(strip_content(&number_val), number_val);
813
814        let array_val = json!([1, 2, 3]);
815        assert_eq!(strip_content(&array_val), array_val);
816
817        let null_val = serde_json::Value::Null;
818        assert_eq!(strip_content(&null_val), null_val);
819
820        let bool_val = json!(true);
821        assert_eq!(strip_content(&bool_val), bool_val);
822    }
823
824    #[tokio::test]
825    async fn entries_filters_by_scope() {
826        use crate::auth::TenantScope;
827
828        let trail = InMemoryAuditTrail::new();
829        let acme = TenantScope::new("acme");
830        let globex = TenantScope::new("globex");
831
832        let mk = |tid: Option<&str>| AuditRecord {
833            agent: "a".into(),
834            turn: 0,
835            event_type: "x".into(),
836            payload: serde_json::Value::Null,
837            usage: TokenUsage::default(),
838            timestamp: chrono::Utc::now(),
839            user_id: None,
840            tenant_id: tid.map(|s| s.into()),
841            delegation_chain: vec![],
842        };
843
844        trail.record(mk(Some("acme"))).await.unwrap();
845        trail.record(mk(Some("globex"))).await.unwrap();
846
847        let acme_rows = trail.entries(&acme, 100).await.unwrap();
848        assert_eq!(acme_rows.len(), 1);
849        assert_eq!(acme_rows[0].tenant_id.as_deref(), Some("acme"));
850
851        let globex_rows = trail.entries(&globex, 100).await.unwrap();
852        assert_eq!(globex_rows.len(), 1);
853        assert_eq!(globex_rows[0].tenant_id.as_deref(), Some("globex"));
854
855        let unscoped = trail.entries_unscoped(100).await.unwrap();
856        assert_eq!(unscoped.len(), 2);
857    }
858
859    #[tokio::test]
860    async fn prune_deletes_old_entries() {
861        let trail = InMemoryAuditTrail::new();
862        let now = chrono::Utc::now();
863        let mk = |timestamp| AuditRecord {
864            agent: "a".into(),
865            turn: 0,
866            event_type: "x".into(),
867            payload: serde_json::Value::Null,
868            usage: TokenUsage::default(),
869            timestamp,
870            user_id: None,
871            tenant_id: None,
872            delegation_chain: vec![],
873        };
874
875        trail
876            .record(mk(now - chrono::Duration::days(10)))
877            .await
878            .unwrap();
879        trail.record(mk(now)).await.unwrap();
880
881        let removed = trail.prune(chrono::Duration::days(7)).await.unwrap();
882        assert_eq!(removed, 1);
883
884        let rest = trail.entries_unscoped(100).await.unwrap();
885        assert_eq!(rest.len(), 1);
886    }
887
888    #[tokio::test]
889    async fn entries_since_filters_by_time() {
890        use crate::auth::TenantScope;
891
892        let trail = InMemoryAuditTrail::new();
893        let now = chrono::Utc::now();
894        let scope = TenantScope::new("acme");
895        let mk = |timestamp| AuditRecord {
896            agent: "a".into(),
897            turn: 0,
898            event_type: "x".into(),
899            payload: serde_json::Value::Null,
900            usage: TokenUsage::default(),
901            timestamp,
902            user_id: None,
903            tenant_id: Some("acme".into()),
904            delegation_chain: vec![],
905        };
906
907        trail
908            .record(mk(now - chrono::Duration::hours(48)))
909            .await
910            .unwrap();
911        trail
912            .record(mk(now - chrono::Duration::hours(1)))
913            .await
914            .unwrap();
915
916        let recent = trail
917            .entries_since(&scope, now - chrono::Duration::hours(24), 100)
918            .await
919            .unwrap();
920        assert_eq!(recent.len(), 1);
921    }
922}