agentguard/commands/
audit.rs

1//! Audit log commands: verify, export, sar, erase, rotate.
2
3use agentguard_core::decision::{AuditFormat, DecisionLog, RotationConfig};
4use agentguard_core::decode_chain_secret;
5use anyhow::{anyhow, Result};
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use std::path::Path;
9use std::str::FromStr;
10
11type HmacSha256 = Hmac<Sha256>;
12
13pub fn verify(
14    audit_path: impl AsRef<Path>,
15    secret_file: impl AsRef<Path>,
16    output: &str,
17) -> Result<()> {
18    let key =
19        std::fs::read(secret_file.as_ref()).map_err(|e| anyhow!("read secret file: {}", e))?;
20    let key_bytes =
21        decode_chain_secret(&key).ok_or_else(|| anyhow!("chain secret file is empty"))?;
22    let chain_id = DecisionLog::verify_chain(audit_path.as_ref(), &key_bytes)?;
23    if output == "json" {
24        println!(
25            "{}",
26            serde_json::json!({"status": "ok", "chain_id": chain_id.to_string()})
27        );
28    } else {
29        println!("✓ audit log chain verified");
30        println!("  chain_id: {}", chain_id);
31        println!("  file:     {}", audit_path.as_ref().display());
32    }
33    Ok(())
34}
35
36pub fn export(
37    audit_path: impl AsRef<Path>,
38    format: &str,
39    out_path: Option<impl AsRef<Path>>,
40    _output: &str,
41) -> Result<()> {
42    let format = AuditFormat::from_str(format).map_err(anyhow::Error::msg)?;
43    let records = DecisionLog::read_all(audit_path.as_ref())?;
44    let lines: Vec<String> = records.iter().map(|r| format.format(r)).collect();
45    if let Some(p) = out_path {
46        std::fs::write(p.as_ref(), lines.join("\n") + "\n")?;
47        println!("wrote {} records to {}", lines.len(), p.as_ref().display());
48    } else {
49        for line in &lines {
50            println!("{}", line);
51        }
52    }
53    Ok(())
54}
55
56pub fn sar(audit_path: impl AsRef<Path>, subject_id: &str, output: &str) -> Result<()> {
57    let records = DecisionLog::read_all(audit_path.as_ref())?;
58    let matching: Vec<_> = records
59        .into_iter()
60        .filter(|r| r.principal == subject_id || r.subject_id.as_deref() == Some(subject_id))
61        .collect();
62    if output == "json" {
63        println!("{}", serde_json::to_string_pretty(&matching)?);
64    } else {
65        println!(
66            "found {} record(s) for subject {}",
67            matching.len(),
68            subject_id
69        );
70        for r in &matching {
71            println!(
72                "  {} {} {} {} {}",
73                r.timestamp.format("%Y-%m-%dT%H:%M:%S"),
74                if r.effect == "allow" { "✓" } else { "✗" },
75                r.effect.to_uppercase(),
76                r.action,
77                r.resource
78            );
79        }
80    }
81    Ok(())
82}
83
84pub fn erase(
85    audit_path: impl AsRef<Path>,
86    subject_id: &str,
87    salt_file: impl AsRef<Path>,
88    out_path: Option<impl AsRef<Path>>,
89) -> Result<()> {
90    let salt = std::fs::read(salt_file.as_ref()).map_err(|e| anyhow!("read salt file: {}", e))?;
91    let records = DecisionLog::read_all_chained(audit_path.as_ref())?;
92    let mut out = Vec::new();
93    let mut erased = 0;
94    for mut cr in records {
95        let r_matches = cr.record.principal == subject_id
96            || cr.record.subject_id.as_deref() == Some(subject_id);
97        if r_matches {
98            let mut mac = <HmacSha256 as Mac>::new_from_slice(&salt)
99                .map_err(|e| anyhow!("hmac init: {}", e))?;
100            mac.update(cr.record.principal.as_bytes());
101            let hash = mac.finalize().into_bytes();
102            let tag = format!("erased:{}", hex::encode(hash));
103            cr.record.principal = tag.clone();
104            cr.record.subject_id = Some(tag);
105            erased += 1;
106        }
107        // Preserve chain metadata (prev_hash / record_hash / chain_id)
108        // so each record's place in the chain is documented even though
109        // the HMAC no longer recomputes for erased rows.
110        out.push(cr);
111    }
112    // Default to in-place rewrite; honor explicit --out if given.
113    let target = out_path
114        .as_ref()
115        .map(|p| p.as_ref())
116        .unwrap_or_else(|| audit_path.as_ref());
117    let file = std::fs::File::create(target)?;
118    let mut writer = std::io::BufWriter::new(file);
119    for cr in &out {
120        serde_json::to_writer(&mut writer, cr)?;
121        use std::io::Write;
122        writer.write_all(b"\n")?;
123    }
124    println!(
125        "erased {} records; wrote {} records to {}",
126        erased,
127        out.len(),
128        target.display()
129    );
130    if erased > 0 {
131        println!(
132            "WARNING: the rewritten audit log will fail `agentguard audit verify` \
133             because the HMAC of each erased record no longer matches its stored \
134             hash. This is expected for GDPR Art. 17 erasure — the audit trail \
135             keeps its position markers but the canonical record body has been \
136             modified. Operators MUST keep the pre-erasure file as evidence of \
137             the original chain."
138        );
139    }
140    Ok(())
141}
142
143/// Force-rotate the audit log at `audit_path`. The active file is
144/// renamed to a timestamped sibling and a fresh file is opened. The
145/// chain id sidecar is moved alongside.
146pub fn rotate(audit_path: impl AsRef<Path>, secret_file: Option<&Path>) -> Result<()> {
147    let rotation = RotationConfig { max_bytes: 1 };
148    let log = match secret_file {
149        Some(secret_path) => {
150            let key = std::fs::read(secret_path).map_err(|e| anyhow!("read secret file: {}", e))?;
151            let key_bytes =
152                decode_chain_secret(&key).ok_or_else(|| anyhow!("chain secret file is empty"))?;
153            DecisionLog::open_with_rotation(audit_path.as_ref(), Some(&key_bytes), rotation)?
154        }
155        None => DecisionLog::open_with_rotation(audit_path.as_ref(), None::<&[u8]>, rotation)?,
156    };
157    log.rotate()?;
158    println!("✓ audit log rotated");
159    println!("  active:    {}", audit_path.as_ref().display());
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use agentguard_core::decision::DecisionRecord;
167    use tempfile::tempdir;
168
169    #[test]
170    fn erase_preserves_chain_metadata() {
171        // The audit log is rewritten preserving prev_hash / record_hash /
172        // chain_id fields (so downstream verifiers see *what was* there),
173        // even though the record body has been mutated and the HMAC no
174        // longer recomputes. The operator is warned to keep the original.
175        let dir = tempdir().unwrap();
176        let path = dir.path().join("audit.jsonl");
177        let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
178        let rec = DecisionRecord {
179            id: uuid::Uuid::new_v4().to_string(),
180            timestamp: chrono::Utc::now(),
181            effect: "allow".into(),
182            policies: vec![],
183            request_id: None,
184            principal: "alice".into(),
185            action: "send".into(),
186            resource: "doc".into(),
187            reasons: vec![],
188            session_id: None,
189            agent_chain: None,
190            trace_id: None,
191            span_id: None,
192            tenant_id: None,
193            subject_id: Some("alice".into()),
194            authenticated_actor: None,
195        };
196        log.append(&rec).unwrap();
197        log.append(&rec).unwrap();
198        drop(log);
199
200        let salt_path = dir.path().join("salt");
201        std::fs::write(&salt_path, b"random-salt-bytes").unwrap();
202        erase(&path, "alice", &salt_path, None::<&Path>).unwrap();
203
204        // Re-read as ChainedRecord and verify the chain metadata is intact.
205        let records = DecisionLog::read_all_chained(&path).unwrap();
206        assert_eq!(records.len(), 2);
207        for cr in &records {
208            // hex of 32 bytes
209            assert_eq!(cr.prev_hash.len(), 64);
210            assert_eq!(cr.record_hash.len(), 64);
211            assert_ne!(cr.chain_id.0, uuid::Uuid::nil());
212            // principal was rewritten to "erased:<hex>"
213            assert!(cr.record.principal.starts_with("erased:"));
214        }
215    }
216
217    #[test]
218    fn rotate_renames_active_file() {
219        let dir = tempfile::tempdir().unwrap();
220        let path = dir.path().join("rotating.jsonl");
221        std::fs::write(&path, b"{\"id\":\"seed\"}\n").unwrap();
222        super::rotate(&path, None).unwrap();
223        let mut entries = std::fs::read_dir(dir.path())
224            .unwrap()
225            .filter_map(|e| e.ok())
226            .map(|e| e.file_name().to_string_lossy().to_string())
227            .collect::<Vec<_>>();
228        entries.sort();
229        // Active file must be re-created; one rotated sibling present.
230        assert!(entries.iter().any(|n| n == "rotating.jsonl"));
231        assert!(
232            entries.iter().any(|n| n.starts_with("rotating-")),
233            "expected a rotated sibling, got {entries:?}"
234        );
235    }
236
237    #[test]
238    fn export_includes_records_from_rotated_segments() {
239        let dir = tempdir().unwrap();
240        let audit_path = dir.path().join("export.jsonl");
241        let output_path = dir.path().join("exported.jsonl");
242        let log = DecisionLog::open_with_chain(&audit_path, b"root").unwrap();
243        let record = |id: &str| DecisionRecord {
244            id: id.into(),
245            timestamp: chrono::Utc::now(),
246            effect: "allow".into(),
247            policies: vec![],
248            request_id: None,
249            principal: "alice".into(),
250            action: "read".into(),
251            resource: "document".into(),
252            reasons: vec![],
253            session_id: None,
254            agent_chain: None,
255            trace_id: None,
256            span_id: None,
257            tenant_id: None,
258            subject_id: None,
259            authenticated_actor: None,
260        };
261        log.append(&record("before-rotation")).unwrap();
262        log.rotate().unwrap();
263        log.append(&record("after-rotation")).unwrap();
264        drop(log);
265
266        export(&audit_path, "jsonl", Some(output_path.as_path()), "pretty").unwrap();
267        let exported = DecisionLog::read_all(&output_path).unwrap();
268        assert_eq!(exported.len(), 2);
269        assert_eq!(exported[0].id, "before-rotation");
270        assert_eq!(exported[1].id, "after-rotation");
271
272        // SIEM JSONL is a DecisionRecord export, not the native chained
273        // format. Keep the external-verification boundary explicit: the
274        // exported records must not be mistaken for independently verifiable
275        // HMAC evidence.
276        let exported_lines = std::fs::read_to_string(output_path).unwrap();
277        for line in exported_lines.lines() {
278            let value: serde_json::Value = serde_json::from_str(line).unwrap();
279            assert!(value.get("chain_id").is_none());
280            assert!(value.get("prev_hash").is_none());
281            assert!(value.get("record_hash").is_none());
282        }
283    }
284}