1use 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 out.push(cr);
111 }
112 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
143pub 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 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 let records = DecisionLog::read_all_chained(&path).unwrap();
206 assert_eq!(records.len(), 2);
207 for cr in &records {
208 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 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 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 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}