1use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use chrono::{DateTime, Utc};
33use hmac::{Hmac, Mac};
34use rand::RngCore;
35use serde::{Deserialize, Serialize};
36use sha2::Sha256;
37use thiserror::Error;
38use uuid::Uuid;
39
40fn bytes_to_hex(bytes: &[u8]) -> String {
41 bytes.iter().map(|b| format!("{b:02x}")).collect()
42}
43
44use crate::contracts::{
45 AuthorityContract, AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision,
46 AuthorityTargetEvaluation, AuthorityTrustLevel,
47};
48use crate::deny_reason::DenyReason;
49use crate::errors::{SafeError, SafeResult};
50use crate::rbac::RbacProfile;
51
52type HmacSha256 = Hmac<Sha256>;
53
54#[derive(Debug, Error, PartialEq, Eq)]
56pub enum AuditVerifyError {
57 #[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
60 ChainBroken {
61 at_entry: usize,
63 entry_id: String,
65 },
66
67 #[error("audit log line {line} is malformed (not a valid audit entry)")]
72 MalformedLine {
73 line: usize,
75 },
76
77 #[error("could not read audit log: {0}")]
79 Io(String),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum AuditStatus {
86 Success,
88 Failure,
90}
91
92#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct AuditContext {
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub exec: Option<AuditExecContext>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub cellos: Option<AuditCellosContext>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub clipboard: Option<AuditClipboardContext>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub reveal: Option<AuditRevealContext>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub mcp: Option<AuditMcpContext>,
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AuditCellosContext {
117 pub cellos_cell_id: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub cell_token: Option<String>,
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct AuditClipboardContext {
132 pub ttl_secs: u64,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub reason: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub excluded_from_history: Option<bool>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub cleared_verified: Option<bool>,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct AuditMcpContext {
164 pub host: String,
168 pub pid: u32,
170 pub tool: String,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub injected_keys: Vec<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub exit_code: Option<i32>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub duration_ms: Option<u64>,
185}
186
187impl AuditContext {
188 pub fn from_exec(exec: AuditExecContext) -> Self {
189 Self {
190 exec: Some(exec),
191 ..Default::default()
192 }
193 }
194
195 pub fn from_cellos(cellos: AuditCellosContext) -> Self {
196 Self {
197 cellos: Some(cellos),
198 ..Default::default()
199 }
200 }
201
202 pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
203 Self {
204 clipboard: Some(clipboard),
205 ..Default::default()
206 }
207 }
208
209 pub fn from_reveal(reveal: AuditRevealContext) -> Self {
210 Self {
211 reveal: Some(reveal),
212 ..Default::default()
213 }
214 }
215
216 pub fn from_mcp(mcp: AuditMcpContext) -> Self {
222 Self {
223 mcp: Some(mcp),
224 ..Default::default()
225 }
226 }
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct AuditRevealContext {
237 pub ttl_secs: u64,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AuditEnvMapping {
247 pub env: String,
249 pub vault_key: String,
251}
252
253#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AuditExecContext {
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub contract_name: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub target: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub authority_profile: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub authority_namespace: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub trust_level: Option<AuthorityTrustLevel>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub access_profile: Option<RbacProfile>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub inherit: Option<AuthorityInheritMode>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub deny_dangerous_env: Option<bool>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub redact_output: Option<bool>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub network: Option<AuthorityNetworkPolicy>,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub allowed_secrets: Vec<String>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub required_secrets: Vec<String>,
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub injected_secrets: Vec<String>,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 pub missing_required_secrets: Vec<String>,
284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub dropped_env_names: Vec<String>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub env_mappings: Vec<AuditEnvMapping>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub target_allowed: Option<bool>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub target_decision: Option<AuthorityTargetDecision>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub matched_target: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub deny_reason: Option<DenyReason>,
298}
299
300impl AuditExecContext {
301 pub fn from_contract(contract: &AuthorityContract) -> Self {
304 let resolved = contract.resolved_exec_policy();
305 Self {
306 contract_name: Some(contract.name.clone()),
307 target: None,
308 authority_profile: contract.profile.clone(),
309 authority_namespace: contract.namespace.clone(),
310 trust_level: Some(resolved.trust_level),
311 access_profile: Some(resolved.access_profile),
312 inherit: Some(resolved.inherit),
313 deny_dangerous_env: Some(resolved.deny_dangerous_env),
314 redact_output: Some(resolved.redact_output),
315 network: Some(contract.network),
316 allowed_secrets: contract.allowed_secrets.clone(),
317 required_secrets: contract.required_secrets.clone(),
318 injected_secrets: Vec::new(),
319 missing_required_secrets: Vec::new(),
320 dropped_env_names: Vec::new(),
321 env_mappings: Vec::new(),
322 target_allowed: None,
323 target_decision: None,
324 matched_target: None,
325 deny_reason: None,
326 }
327 }
328
329 pub fn with_target(mut self, target: impl Into<String>) -> Self {
330 self.target = Some(target.into());
331 self
332 }
333
334 pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
335 where
336 I: IntoIterator<Item = S>,
337 S: AsRef<str>,
338 {
339 self.injected_secrets = normalize_names(names);
340 self
341 }
342
343 pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
344 where
345 I: IntoIterator<Item = S>,
346 S: AsRef<str>,
347 {
348 self.missing_required_secrets = normalize_names(names);
349 self
350 }
351
352 pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
353 where
354 I: IntoIterator<Item = S>,
355 S: AsRef<str>,
356 {
357 self.dropped_env_names = normalize_names(names);
358 self
359 }
360
361 pub fn with_target_allowed(mut self, allowed: bool) -> Self {
362 self.target_allowed = Some(allowed);
363 self
364 }
365
366 pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
367 self.target_allowed = Some(evaluation.decision.is_allowed());
368 self.target_decision = Some(evaluation.decision);
369 self.matched_target = evaluation.matched_allowlist_entry.clone();
370 self
371 }
372}
373
374fn normalize_names<I, S>(names: I) -> Vec<String>
375where
376 I: IntoIterator<Item = S>,
377 S: AsRef<str>,
378{
379 let mut out = names
380 .into_iter()
381 .map(|name| name.as_ref().trim().to_string())
382 .filter(|name| !name.is_empty())
383 .collect::<Vec<_>>();
384 out.sort();
385 out.dedup();
386 out
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct AuditEntry {
412 pub id: String,
413 pub timestamp: DateTime<Utc>,
414 pub profile: String,
415 pub operation: String,
416 pub key: Option<String>,
417 pub status: AuditStatus,
418 pub message: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub context: Option<AuditContext>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub prev_entry_hmac: Option<String>,
428}
429
430impl AuditEntry {
431 pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
433 Self {
434 id: Uuid::new_v4().to_string(),
435 timestamp: Utc::now(),
436 profile: profile.to_string(),
437 operation: operation.to_string(),
438 key: key.map(str::to_string),
439 status: AuditStatus::Success,
440 message: None,
441 context: None,
442 prev_entry_hmac: None,
443 }
444 }
445
446 pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
448 Self {
449 id: Uuid::new_v4().to_string(),
450 timestamp: Utc::now(),
451 profile: profile.to_string(),
452 operation: operation.to_string(),
453 key: key.map(str::to_string),
454 status: AuditStatus::Failure,
455 message: Some(message.to_string()),
456 context: None,
457 prev_entry_hmac: None,
458 }
459 }
460
461 pub fn with_context(mut self, context: AuditContext) -> Self {
463 self.context = Some(context);
464 self
465 }
466}
467
468pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
474 let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
475 let mut mac =
476 HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
477 mac.update(json.as_bytes());
478 let result = mac.finalize().into_bytes();
479 bytes_to_hex(&result)
480}
481
482fn derive_chain_key() -> [u8; 32] {
484 let mut key = [0u8; 32];
485 rand::rngs::OsRng.fill_bytes(&mut key);
486 key
487}
488
489pub struct AuditLog {
497 path: PathBuf,
498 chain_key: [u8; 32],
500 prev_hmac: std::cell::Cell<Option<String>>,
504 bootstrapped: std::cell::Cell<bool>,
506}
507
508impl AuditLog {
509 pub fn new(path: &Path) -> Self {
511 Self {
512 path: path.to_path_buf(),
513 chain_key: derive_chain_key(),
514 prev_hmac: std::cell::Cell::new(None),
515 bootstrapped: std::cell::Cell::new(false),
516 }
517 }
518
519 fn bootstrap_if_needed(&self) {
524 if self.bootstrapped.get() {
525 return;
526 }
527 self.bootstrapped.set(true);
528 self.prev_hmac.set(None);
532 }
533
534 pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
540 self.bootstrap_if_needed();
541
542 if let Some(parent) = self.path.parent() {
543 std::fs::create_dir_all(parent)?;
544 #[cfg(unix)]
545 {
546 use std::os::unix::fs::PermissionsExt;
547 let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
548 }
549 }
550
551 let mut chained = entry.clone();
553 chained.prev_entry_hmac = self.prev_hmac.take();
554
555 let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
556 line.push('\n');
557
558 let mut opts = std::fs::OpenOptions::new();
559 opts.create(true).append(true);
560 #[cfg(unix)]
561 {
562 use std::os::unix::fs::OpenOptionsExt;
563 opts.mode(0o600);
564 }
565 let mut file = opts.open(&self.path)?;
566 file.write_all(line.as_bytes())?;
567
568 let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
570 self.prev_hmac.set(Some(next_hmac));
571
572 Ok(())
573 }
574
575 pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
588 verify_chain_with_key(&self.path, &self.chain_key)
589 }
590
591 pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
593 if !self.path.exists() {
594 return Ok(Vec::new());
595 }
596 let content = std::fs::read_to_string(&self.path)?;
597 let mut entries: Vec<AuditEntry> = content
598 .lines()
599 .filter(|l| !l.trim().is_empty())
600 .filter_map(|l| serde_json::from_str(l).ok())
601 .collect();
602 entries.reverse(); if let Some(n) = limit {
604 entries.truncate(n);
605 }
606 Ok(entries)
607 }
608
609 pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
611 Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
612 }
613
614 pub fn last_successful_operation(
617 &self,
618 profile: &str,
619 operation: &str,
620 scan_limit: usize,
621 ) -> SafeResult<Option<DateTime<Utc>>> {
622 let entries = self.read(Some(scan_limit))?;
623 Ok(entries
624 .into_iter()
625 .find(|e| {
626 e.profile == profile
627 && e.operation == operation
628 && matches!(e.status, AuditStatus::Success)
629 })
630 .map(|e| e.timestamp))
631 }
632
633 pub fn filter_audit(
642 &self,
643 since: Option<DateTime<Utc>>,
644 until: Option<DateTime<Utc>>,
645 command: Option<&str>,
646 ) -> SafeResult<Vec<AuditEntry>> {
647 let entries = self.read(None)?;
648 Ok(entries
649 .into_iter()
650 .filter(|e| {
651 if let Some(s) = since {
652 if e.timestamp < s {
653 return false;
654 }
655 }
656 if let Some(u) = until {
657 if e.timestamp > u {
658 return false;
659 }
660 }
661 if let Some(cmd) = command {
662 if e.operation != cmd {
663 return false;
664 }
665 }
666 true
667 })
668 .collect())
669 }
670
671 pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
681 if !self.path.exists() {
682 return Ok(0);
683 }
684 let content = std::fs::read_to_string(&self.path)?;
685 let mut kept: Vec<&str> = Vec::new();
686 let mut removed = 0usize;
687 for line in content.lines() {
688 if line.trim().is_empty() {
689 continue;
690 }
691 match serde_json::from_str::<AuditEntry>(line) {
692 Ok(entry) if entry.timestamp < before => {
693 removed += 1;
694 }
695 _ => {
696 kept.push(line);
697 }
698 }
699 }
700 if removed == 0 {
701 return Ok(0);
702 }
703 let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
704 let tmp = self.path.with_extension("jsonl.tmp");
705 std::fs::write(&tmp, &new_content)?;
706 if let Err(e) = std::fs::rename(&tmp, &self.path) {
708 let _ = std::fs::remove_file(&tmp); return Err(std::io::Error::other(format!(
710 "audit prune: failed to rename temp file — log unchanged: {e}"
711 ))
712 .into());
713 }
714 let profile_name = self
718 .path
719 .file_stem()
720 .and_then(|s| s.to_str())
721 .unwrap_or("unknown");
722 let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
723 sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
724 self.append(&sentinel)?;
725 Ok(removed)
726 }
727}
728
729pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
733 match std::fs::metadata(path) {
734 Ok(meta) => Ok(meta.len()),
735 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
736 Err(e) => Err(SafeError::Io(e)),
737 }
738}
739
740fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
746 if !path.exists() {
747 return Ok(());
748 }
749 let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;
750
751 let mut entries: Vec<AuditEntry> = Vec::new();
756 for (idx, line) in content.lines().enumerate() {
757 if line.trim().is_empty() {
758 continue;
759 }
760 match serde_json::from_str::<AuditEntry>(line) {
761 Ok(entry) => entries.push(entry),
762 Err(_) => return Err(AuditVerifyError::MalformedLine { line: idx + 1 }),
763 }
764 }
765
766 let mut prev_computed: Option<String> = None;
770
771 for (idx, entry) in entries.iter().enumerate() {
772 match &entry.prev_entry_hmac {
773 None => {
774 prev_computed = Some(compute_entry_hmac(entry, key));
777 }
778 Some(stored_hmac) => {
779 match &prev_computed {
781 Some(expected) if expected == stored_hmac => {
782 prev_computed = Some(compute_entry_hmac(entry, key));
783 }
784 _ => {
785 return Err(AuditVerifyError::ChainBroken {
786 at_entry: idx,
787 entry_id: entry.id.clone(),
788 });
789 }
790 }
791 }
792 }
793 }
794
795 Ok(())
796}
797
798#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
804 use tempfile::tempdir;
805
806 #[test]
807 fn append_and_read_roundtrip() {
808 let dir = tempdir().unwrap();
809 let log = AuditLog::new(&dir.path().join("t.jsonl"));
810 log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
811 .unwrap();
812 log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
813 .unwrap();
814 log.append(&AuditEntry::failure(
815 "dev",
816 "get",
817 Some("MISSING"),
818 "not found",
819 ))
820 .unwrap();
821 let entries = log.read(None).unwrap();
822 assert_eq!(entries.len(), 3);
823 assert_eq!(entries[0].status, AuditStatus::Failure); }
825
826 #[test]
827 fn limit_truncates() {
828 let dir = tempdir().unwrap();
829 let log = AuditLog::new(&dir.path().join("t.jsonl"));
830 for i in 0..10 {
831 log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
832 .unwrap();
833 }
834 assert_eq!(log.read(Some(3)).unwrap().len(), 3);
835 }
836
837 #[test]
838 fn nonexistent_log_returns_empty() {
839 let dir = tempdir().unwrap();
840 let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
841 assert!(log.read(None).unwrap().is_empty());
842 }
843
844 #[test]
845 fn ids_are_unique() {
846 let e1 = AuditEntry::success("p", "op", None);
847 let e2 = AuditEntry::success("p", "op", None);
848 assert_ne!(e1.id, e2.id);
849 }
850
851 #[test]
852 fn last_successful_operation_finds_rotate() {
853 let dir = tempdir().unwrap();
854 let log = AuditLog::new(&dir.path().join("a.jsonl"));
855 log.append(&AuditEntry::success("dev", "set", Some("K")))
856 .unwrap();
857 log.append(&AuditEntry::success("dev", "rotate", None))
858 .unwrap();
859 log.append(&AuditEntry::success("dev", "get", Some("K")))
860 .unwrap();
861 assert!(log
862 .last_successful_operation("dev", "rotate", 100)
863 .unwrap()
864 .is_some());
865 assert!(log
866 .last_successful_operation("dev", "missing-op", 100)
867 .unwrap()
868 .is_none());
869 }
870
871 #[test]
877 fn hmac_chain_intact_and_detects_tampering() {
878 let dir = tempdir().unwrap();
879 let path = dir.path().join("chain.jsonl");
880 let log = AuditLog::new(&path);
881
882 log.append(&AuditEntry::success("dev", "set", Some("A")))
883 .unwrap();
884 log.append(&AuditEntry::success("dev", "get", Some("A")))
885 .unwrap();
886 log.append(&AuditEntry::failure(
887 "dev",
888 "get",
889 Some("MISSING"),
890 "not found",
891 ))
892 .unwrap();
893
894 log.verify_chain()
896 .expect("chain must be intact after write");
897
898 let content = std::fs::read_to_string(&path).unwrap();
900 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
901
902 let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
904 tampered.operation = "TAMPERED".to_string();
905 lines[1] = serde_json::to_string(&tampered).unwrap();
907
908 let tampered_content = lines.join("\n") + "\n";
909 std::fs::write(&path, tampered_content).unwrap();
910
911 let err = log
914 .verify_chain()
915 .expect_err("tampered log must fail verification");
916 match err {
917 AuditVerifyError::ChainBroken { at_entry, .. } => {
918 assert_eq!(
919 at_entry, 2,
920 "chain should break at the entry after the tampered one"
921 );
922 }
923 other => panic!("unexpected error: {other}"),
924 }
925 }
926
927 #[test]
931 fn malformed_line_fails_verification() {
932 let dir = tempdir().unwrap();
933 let path = dir.path().join("malformed.jsonl");
934 let log = AuditLog::new(&path);
935
936 log.append(&AuditEntry::success("dev", "set", Some("A")))
937 .unwrap();
938 log.append(&AuditEntry::success("dev", "get", Some("A")))
939 .unwrap();
940
941 log.verify_chain()
942 .expect("chain must be intact after write");
943
944 let content = std::fs::read_to_string(&path).unwrap();
946 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
947 lines[1] = "{ this is not valid json".to_string();
948 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
949
950 let err = log
951 .verify_chain()
952 .expect_err("corrupted line must fail verification");
953 match err {
954 AuditVerifyError::MalformedLine { line } => {
955 assert_eq!(
956 line, 2,
957 "malformed line number should be reported (1-based)"
958 );
959 }
960 other => panic!("unexpected error: {other}"),
961 }
962 }
963
964 #[test]
966 fn first_entry_has_no_prev_hmac() {
967 let dir = tempdir().unwrap();
968 let path = dir.path().join("first.jsonl");
969 let log = AuditLog::new(&path);
970 log.append(&AuditEntry::success("dev", "set", Some("K")))
971 .unwrap();
972
973 let content = std::fs::read_to_string(&path).unwrap();
974 let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
975 assert!(
976 entry.prev_entry_hmac.is_none(),
977 "first entry must have no prev_entry_hmac"
978 );
979 }
980
981 #[test]
983 fn subsequent_entries_carry_prev_hmac() {
984 let dir = tempdir().unwrap();
985 let path = dir.path().join("chain2.jsonl");
986 let log = AuditLog::new(&path);
987 log.append(&AuditEntry::success("dev", "set", Some("A")))
988 .unwrap();
989 log.append(&AuditEntry::success("dev", "get", Some("A")))
990 .unwrap();
991
992 let content = std::fs::read_to_string(&path).unwrap();
993 let lines: Vec<&str> = content.lines().collect();
994 let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
995 assert!(
996 second.prev_entry_hmac.is_some(),
997 "second entry must carry prev_entry_hmac"
998 );
999 }
1000
1001 #[test]
1003 fn old_entries_deserialize_without_prev_hmac() {
1004 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
1005 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
1006 assert!(entry.prev_entry_hmac.is_none());
1007 assert!(entry.context.is_none());
1008 }
1009
1010 #[test]
1012 fn audit_integrity_contract_v2() {
1013 let dir = tempdir().unwrap();
1014 let log = AuditLog::new(&dir.path().join("integrity.jsonl"));
1015
1016 log.append(&AuditEntry::success("dev", "set", Some("A")))
1017 .unwrap();
1018 log.append(&AuditEntry::success("dev", "get", Some("A")))
1019 .unwrap();
1020 log.append(&AuditEntry::failure(
1021 "dev",
1022 "get",
1023 Some("MISSING"),
1024 "not found",
1025 ))
1026 .unwrap();
1027
1028 let entries = log.read(None).unwrap();
1029 assert_eq!(entries.len(), 3, "all appended entries must be retained");
1030
1031 assert_eq!(entries[0].status, AuditStatus::Failure);
1033 assert_eq!(entries[1].operation, "get");
1034 assert_eq!(entries[2].operation, "set");
1035
1036 let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
1038 assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");
1039
1040 let mut ordered = entries.clone();
1042 ordered.reverse();
1043 for w in ordered.windows(2) {
1044 assert!(
1045 w[0].timestamp <= w[1].timestamp,
1046 "timestamps should be non-decreasing in append order"
1047 );
1048 }
1049
1050 log.verify_chain()
1052 .expect("integrity contract: chain must be intact");
1053 }
1054
1055 #[test]
1056 fn old_entries_deserialize_without_context() {
1057 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
1058 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
1059 assert!(entry.context.is_none());
1060 }
1061
1062 #[test]
1065 fn filter_audit_by_command() {
1066 let dir = tempdir().unwrap();
1067 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1068 log.append(&AuditEntry::success("dev", "get", Some("A")))
1069 .unwrap();
1070 log.append(&AuditEntry::success("dev", "set", Some("B")))
1071 .unwrap();
1072 log.append(&AuditEntry::success("dev", "get", Some("C")))
1073 .unwrap();
1074
1075 let gets = log.filter_audit(None, None, Some("get")).unwrap();
1076 assert_eq!(gets.len(), 2);
1077 assert!(gets.iter().all(|e| e.operation == "get"));
1078
1079 let sets = log.filter_audit(None, None, Some("set")).unwrap();
1080 assert_eq!(sets.len(), 1);
1081 }
1082
1083 #[test]
1084 fn filter_audit_by_time_range() {
1085 use chrono::Duration;
1086 let dir = tempdir().unwrap();
1087 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1088
1089 let now = Utc::now();
1090 let old = now - Duration::hours(2);
1091 let recent = now - Duration::minutes(30);
1092
1093 let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
1095 e_old.timestamp = old;
1096 let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
1097 e_recent.timestamp = recent;
1098 let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
1099 e_now.timestamp = now;
1100 log.append(&e_old).unwrap();
1101 log.append(&e_recent).unwrap();
1102 log.append(&e_now).unwrap();
1103
1104 let since_cutoff = now - Duration::hours(1);
1106 let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
1107 assert_eq!(results.len(), 2);
1108
1109 let results = log
1111 .filter_audit(None, Some(now - Duration::hours(1)), None)
1112 .unwrap();
1113 assert_eq!(results.len(), 1);
1114 assert_eq!(results[0].key.as_deref(), Some("OLD"));
1115 }
1116
1117 #[test]
1118 fn filter_audit_combined_since_and_command() {
1119 use chrono::Duration;
1120 let dir = tempdir().unwrap();
1121 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1122
1123 let now = Utc::now();
1124 let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
1125 old_get.timestamp = now - Duration::hours(3);
1126 let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
1127 new_get.timestamp = now - Duration::minutes(5);
1128 let mut new_set = AuditEntry::success("dev", "set", Some("S"));
1129 new_set.timestamp = now - Duration::minutes(5);
1130 log.append(&old_get).unwrap();
1131 log.append(&new_get).unwrap();
1132 log.append(&new_set).unwrap();
1133
1134 let results = log
1135 .filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
1136 .unwrap();
1137 assert_eq!(results.len(), 1);
1138 assert_eq!(results[0].key.as_deref(), Some("NEW"));
1139 }
1140
1141 #[test]
1142 fn filter_audit_empty_log_returns_empty() {
1143 let dir = tempdir().unwrap();
1144 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1145 let results = log.filter_audit(None, None, Some("get")).unwrap();
1146 assert!(results.is_empty());
1147 }
1148
1149 #[test]
1150 fn prune_audit_before_removes_old_entries() {
1151 use chrono::Duration;
1152 let dir = tempdir().unwrap();
1153 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1154
1155 let now = Utc::now();
1156 let mut old = AuditEntry::success("dev", "get", Some("A"));
1157 old.timestamp = now - Duration::hours(48);
1158 let mut recent = AuditEntry::success("dev", "set", Some("B"));
1159 recent.timestamp = now - Duration::hours(1);
1160 log.append(&old).unwrap();
1161 log.append(&recent).unwrap();
1162
1163 let cutoff = now - Duration::hours(24);
1164 let removed = log.prune_audit_before(cutoff).unwrap();
1165 assert_eq!(removed, 1);
1166
1167 let remaining = log.read(None).unwrap();
1168 assert_eq!(remaining.len(), 2);
1169 assert_eq!(remaining[0].operation, "audit-prune");
1170 assert_eq!(remaining[0].key.as_deref(), None);
1171 assert!(remaining[0]
1172 .message
1173 .as_deref()
1174 .is_some_and(|message| message.contains("pruned 1 entries")));
1175 assert_eq!(remaining[1].key.as_deref(), Some("B"));
1176 }
1177
1178 #[test]
1179 fn prune_audit_before_noop_on_empty_log() {
1180 let dir = tempdir().unwrap();
1181 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1182 let removed = log.prune_audit_before(Utc::now()).unwrap();
1183 assert_eq!(removed, 0);
1184 }
1185
1186 #[test]
1187 fn prune_audit_before_keeps_all_if_none_old() {
1188 use chrono::Duration;
1189 let dir = tempdir().unwrap();
1190 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1191 log.append(&AuditEntry::success("dev", "get", Some("A")))
1192 .unwrap();
1193 log.append(&AuditEntry::success("dev", "set", Some("B")))
1194 .unwrap();
1195
1196 let removed = log
1198 .prune_audit_before(Utc::now() - Duration::days(1))
1199 .unwrap();
1200 assert_eq!(removed, 0);
1201 assert_eq!(log.read(None).unwrap().len(), 2);
1202 }
1203
1204 #[test]
1205 fn mcp_context_roundtrips_through_serde() {
1206 let entry = AuditEntry::success("dev", "mcp.run", Some("DEMO_KEY")).with_context(
1207 AuditContext::from_mcp(AuditMcpContext {
1208 host: "mcp:claude-desktop:1234".to_string(),
1209 pid: 5678,
1210 tool: "tsafe_run".to_string(),
1211 injected_keys: vec!["DEMO_KEY".to_string()],
1212 exit_code: Some(0),
1213 duration_ms: Some(42),
1214 }),
1215 );
1216
1217 let encoded = serde_json::to_string(&entry).expect("AuditEntry serializes");
1218 let decoded: AuditEntry =
1219 serde_json::from_str(&encoded).expect("AuditEntry round-trips through serde_json");
1220
1221 let ctx = decoded.context.expect("context present after round-trip");
1222 let mcp = ctx.mcp.expect("mcp variant present after round-trip");
1223 assert_eq!(mcp.host, "mcp:claude-desktop:1234");
1224 assert_eq!(mcp.pid, 5678);
1225 assert_eq!(mcp.tool, "tsafe_run");
1226 assert_eq!(mcp.injected_keys, vec!["DEMO_KEY".to_string()]);
1227 assert_eq!(mcp.exit_code, Some(0));
1228 assert_eq!(mcp.duration_ms, Some(42));
1229 assert!(ctx.exec.is_none());
1230 assert!(ctx.cellos.is_none());
1231 assert!(ctx.clipboard.is_none());
1232 assert!(ctx.reveal.is_none());
1233 }
1234
1235 #[test]
1236 fn mcp_context_default_serializes_minimally() {
1237 let json = serde_json::to_string(&AuditMcpContext::default())
1238 .expect("default AuditMcpContext serializes");
1239 assert!(json.contains("\"host\":\"\""));
1241 assert!(json.contains("\"pid\":0"));
1242 assert!(json.contains("\"tool\":\"\""));
1243 assert!(!json.contains("injected_keys"));
1244 assert!(!json.contains("exit_code"));
1245 assert!(!json.contains("duration_ms"));
1246 }
1247
1248 #[test]
1249 fn exec_context_from_contract_seeds_trust_shape() {
1250 let contract = AuthorityContract {
1251 name: "deploy".into(),
1252 profile: Some("work".into()),
1253 namespace: Some("infra".into()),
1254 access_profile: RbacProfile::ReadOnly,
1255 allow_all_secrets: false,
1256 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
1257 required_secrets: vec!["DB_PASSWORD".into()],
1258 allowed_targets: vec!["terraform".into()],
1259 trust: AuthorityTrust::Hardened,
1260 network: AuthorityNetworkPolicy::Restricted,
1261 };
1262
1263 let exec = AuditExecContext::from_contract(&contract)
1264 .with_target("terraform")
1265 .with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
1266 .with_missing_required_secrets(["DB_PASSWORD"])
1267 .with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
1268 .with_target_evaluation(&contract.evaluate_target(Some("terraform")));
1269
1270 assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
1271 assert_eq!(exec.authority_profile.as_deref(), Some("work"));
1272 assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
1273 assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
1274 assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1275 assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
1276 assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1277 assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
1278 assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
1279 assert_eq!(exec.target_allowed, Some(true));
1280 assert_eq!(
1281 exec.target_decision,
1282 Some(AuthorityTargetDecision::AllowedExact)
1283 );
1284 assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
1285 }
1286}