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("could not read audit log: {0}")]
69 Io(String),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum AuditStatus {
76 Success,
78 Failure,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct AuditContext {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub exec: Option<AuditExecContext>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub cellos: Option<AuditCellosContext>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub clipboard: Option<AuditClipboardContext>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub reveal: Option<AuditRevealContext>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub mcp: Option<AuditMcpContext>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct AuditCellosContext {
107 pub cellos_cell_id: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub cell_token: Option<String>,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct AuditClipboardContext {
122 pub ttl_secs: u64,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub reason: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub excluded_from_history: Option<bool>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub cleared_verified: Option<bool>,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct AuditMcpContext {
154 pub host: String,
158 pub pid: u32,
160 pub tool: String,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub injected_keys: Vec<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub exit_code: Option<i32>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub duration_ms: Option<u64>,
175}
176
177impl AuditContext {
178 pub fn from_exec(exec: AuditExecContext) -> Self {
179 Self {
180 exec: Some(exec),
181 ..Default::default()
182 }
183 }
184
185 pub fn from_cellos(cellos: AuditCellosContext) -> Self {
186 Self {
187 cellos: Some(cellos),
188 ..Default::default()
189 }
190 }
191
192 pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
193 Self {
194 clipboard: Some(clipboard),
195 ..Default::default()
196 }
197 }
198
199 pub fn from_reveal(reveal: AuditRevealContext) -> Self {
200 Self {
201 reveal: Some(reveal),
202 ..Default::default()
203 }
204 }
205
206 pub fn from_mcp(mcp: AuditMcpContext) -> Self {
212 Self {
213 mcp: Some(mcp),
214 ..Default::default()
215 }
216 }
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
226pub struct AuditRevealContext {
227 pub ttl_secs: u64,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct AuditEnvMapping {
237 pub env: String,
239 pub vault_key: String,
241}
242
243#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
245pub struct AuditExecContext {
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub contract_name: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub target: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub authority_profile: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub authority_namespace: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub trust_level: Option<AuthorityTrustLevel>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub access_profile: Option<RbacProfile>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub inherit: Option<AuthorityInheritMode>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub deny_dangerous_env: Option<bool>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub redact_output: Option<bool>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub network: Option<AuthorityNetworkPolicy>,
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub allowed_secrets: Vec<String>,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub required_secrets: Vec<String>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub injected_secrets: Vec<String>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub missing_required_secrets: Vec<String>,
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub dropped_env_names: Vec<String>,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub env_mappings: Vec<AuditEnvMapping>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub target_allowed: Option<bool>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub target_decision: Option<AuthorityTargetDecision>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub matched_target: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub deny_reason: Option<DenyReason>,
288}
289
290impl AuditExecContext {
291 pub fn from_contract(contract: &AuthorityContract) -> Self {
294 let resolved = contract.resolved_exec_policy();
295 Self {
296 contract_name: Some(contract.name.clone()),
297 target: None,
298 authority_profile: contract.profile.clone(),
299 authority_namespace: contract.namespace.clone(),
300 trust_level: Some(resolved.trust_level),
301 access_profile: Some(resolved.access_profile),
302 inherit: Some(resolved.inherit),
303 deny_dangerous_env: Some(resolved.deny_dangerous_env),
304 redact_output: Some(resolved.redact_output),
305 network: Some(contract.network),
306 allowed_secrets: contract.allowed_secrets.clone(),
307 required_secrets: contract.required_secrets.clone(),
308 injected_secrets: Vec::new(),
309 missing_required_secrets: Vec::new(),
310 dropped_env_names: Vec::new(),
311 env_mappings: Vec::new(),
312 target_allowed: None,
313 target_decision: None,
314 matched_target: None,
315 deny_reason: None,
316 }
317 }
318
319 pub fn with_target(mut self, target: impl Into<String>) -> Self {
320 self.target = Some(target.into());
321 self
322 }
323
324 pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
325 where
326 I: IntoIterator<Item = S>,
327 S: AsRef<str>,
328 {
329 self.injected_secrets = normalize_names(names);
330 self
331 }
332
333 pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
334 where
335 I: IntoIterator<Item = S>,
336 S: AsRef<str>,
337 {
338 self.missing_required_secrets = normalize_names(names);
339 self
340 }
341
342 pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
343 where
344 I: IntoIterator<Item = S>,
345 S: AsRef<str>,
346 {
347 self.dropped_env_names = normalize_names(names);
348 self
349 }
350
351 pub fn with_target_allowed(mut self, allowed: bool) -> Self {
352 self.target_allowed = Some(allowed);
353 self
354 }
355
356 pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
357 self.target_allowed = Some(evaluation.decision.is_allowed());
358 self.target_decision = Some(evaluation.decision);
359 self.matched_target = evaluation.matched_allowlist_entry.clone();
360 self
361 }
362}
363
364fn normalize_names<I, S>(names: I) -> Vec<String>
365where
366 I: IntoIterator<Item = S>,
367 S: AsRef<str>,
368{
369 let mut out = names
370 .into_iter()
371 .map(|name| name.as_ref().trim().to_string())
372 .filter(|name| !name.is_empty())
373 .collect::<Vec<_>>();
374 out.sort();
375 out.dedup();
376 out
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct AuditEntry {
402 pub id: String,
403 pub timestamp: DateTime<Utc>,
404 pub profile: String,
405 pub operation: String,
406 pub key: Option<String>,
407 pub status: AuditStatus,
408 pub message: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub context: Option<AuditContext>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub prev_entry_hmac: Option<String>,
418}
419
420impl AuditEntry {
421 pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
423 Self {
424 id: Uuid::new_v4().to_string(),
425 timestamp: Utc::now(),
426 profile: profile.to_string(),
427 operation: operation.to_string(),
428 key: key.map(str::to_string),
429 status: AuditStatus::Success,
430 message: None,
431 context: None,
432 prev_entry_hmac: None,
433 }
434 }
435
436 pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
438 Self {
439 id: Uuid::new_v4().to_string(),
440 timestamp: Utc::now(),
441 profile: profile.to_string(),
442 operation: operation.to_string(),
443 key: key.map(str::to_string),
444 status: AuditStatus::Failure,
445 message: Some(message.to_string()),
446 context: None,
447 prev_entry_hmac: None,
448 }
449 }
450
451 pub fn with_context(mut self, context: AuditContext) -> Self {
453 self.context = Some(context);
454 self
455 }
456}
457
458pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
464 let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
465 let mut mac =
466 HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
467 mac.update(json.as_bytes());
468 let result = mac.finalize().into_bytes();
469 bytes_to_hex(&result)
470}
471
472fn derive_chain_key() -> [u8; 32] {
474 let mut key = [0u8; 32];
475 rand::rngs::OsRng.fill_bytes(&mut key);
476 key
477}
478
479pub struct AuditLog {
487 path: PathBuf,
488 chain_key: [u8; 32],
490 prev_hmac: std::cell::Cell<Option<String>>,
494 bootstrapped: std::cell::Cell<bool>,
496}
497
498impl AuditLog {
499 pub fn new(path: &Path) -> Self {
501 Self {
502 path: path.to_path_buf(),
503 chain_key: derive_chain_key(),
504 prev_hmac: std::cell::Cell::new(None),
505 bootstrapped: std::cell::Cell::new(false),
506 }
507 }
508
509 fn bootstrap_if_needed(&self) {
514 if self.bootstrapped.get() {
515 return;
516 }
517 self.bootstrapped.set(true);
518 self.prev_hmac.set(None);
522 }
523
524 pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
530 self.bootstrap_if_needed();
531
532 if let Some(parent) = self.path.parent() {
533 std::fs::create_dir_all(parent)?;
534 #[cfg(unix)]
535 {
536 use std::os::unix::fs::PermissionsExt;
537 let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
538 }
539 }
540
541 let mut chained = entry.clone();
543 chained.prev_entry_hmac = self.prev_hmac.take();
544
545 let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
546 line.push('\n');
547
548 let mut opts = std::fs::OpenOptions::new();
549 opts.create(true).append(true);
550 #[cfg(unix)]
551 {
552 use std::os::unix::fs::OpenOptionsExt;
553 opts.mode(0o600);
554 }
555 let mut file = opts.open(&self.path)?;
556 file.write_all(line.as_bytes())?;
557
558 let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
560 self.prev_hmac.set(Some(next_hmac));
561
562 Ok(())
563 }
564
565 pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
576 verify_chain_with_key(&self.path, &self.chain_key)
577 }
578
579 pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
581 if !self.path.exists() {
582 return Ok(Vec::new());
583 }
584 let content = std::fs::read_to_string(&self.path)?;
585 let mut entries: Vec<AuditEntry> = content
586 .lines()
587 .filter(|l| !l.trim().is_empty())
588 .filter_map(|l| serde_json::from_str(l).ok())
589 .collect();
590 entries.reverse(); if let Some(n) = limit {
592 entries.truncate(n);
593 }
594 Ok(entries)
595 }
596
597 pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
599 Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
600 }
601
602 pub fn last_successful_operation(
605 &self,
606 profile: &str,
607 operation: &str,
608 scan_limit: usize,
609 ) -> SafeResult<Option<DateTime<Utc>>> {
610 let entries = self.read(Some(scan_limit))?;
611 Ok(entries
612 .into_iter()
613 .find(|e| {
614 e.profile == profile
615 && e.operation == operation
616 && matches!(e.status, AuditStatus::Success)
617 })
618 .map(|e| e.timestamp))
619 }
620
621 pub fn filter_audit(
630 &self,
631 since: Option<DateTime<Utc>>,
632 until: Option<DateTime<Utc>>,
633 command: Option<&str>,
634 ) -> SafeResult<Vec<AuditEntry>> {
635 let entries = self.read(None)?;
636 Ok(entries
637 .into_iter()
638 .filter(|e| {
639 if let Some(s) = since {
640 if e.timestamp < s {
641 return false;
642 }
643 }
644 if let Some(u) = until {
645 if e.timestamp > u {
646 return false;
647 }
648 }
649 if let Some(cmd) = command {
650 if e.operation != cmd {
651 return false;
652 }
653 }
654 true
655 })
656 .collect())
657 }
658
659 pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
669 if !self.path.exists() {
670 return Ok(0);
671 }
672 let content = std::fs::read_to_string(&self.path)?;
673 let mut kept: Vec<&str> = Vec::new();
674 let mut removed = 0usize;
675 for line in content.lines() {
676 if line.trim().is_empty() {
677 continue;
678 }
679 match serde_json::from_str::<AuditEntry>(line) {
680 Ok(entry) if entry.timestamp < before => {
681 removed += 1;
682 }
683 _ => {
684 kept.push(line);
685 }
686 }
687 }
688 if removed == 0 {
689 return Ok(0);
690 }
691 let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
692 let tmp = self.path.with_extension("jsonl.tmp");
693 std::fs::write(&tmp, &new_content)?;
694 if let Err(e) = std::fs::rename(&tmp, &self.path) {
696 let _ = std::fs::remove_file(&tmp); return Err(std::io::Error::other(format!(
698 "audit prune: failed to rename temp file — log unchanged: {e}"
699 ))
700 .into());
701 }
702 let profile_name = self
706 .path
707 .file_stem()
708 .and_then(|s| s.to_str())
709 .unwrap_or("unknown");
710 let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
711 sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
712 self.append(&sentinel)?;
713 Ok(removed)
714 }
715}
716
717pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
721 match std::fs::metadata(path) {
722 Ok(meta) => Ok(meta.len()),
723 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
724 Err(e) => Err(SafeError::Io(e)),
725 }
726}
727
728fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
734 if !path.exists() {
735 return Ok(());
736 }
737 let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;
738
739 let entries: Vec<AuditEntry> = content
740 .lines()
741 .filter(|l| !l.trim().is_empty())
742 .filter_map(|l| serde_json::from_str(l).ok())
743 .collect();
744
745 let mut prev_computed: Option<String> = None;
749
750 for (idx, entry) in entries.iter().enumerate() {
751 match &entry.prev_entry_hmac {
752 None => {
753 prev_computed = Some(compute_entry_hmac(entry, key));
756 }
757 Some(stored_hmac) => {
758 match &prev_computed {
760 Some(expected) if expected == stored_hmac => {
761 prev_computed = Some(compute_entry_hmac(entry, key));
762 }
763 _ => {
764 return Err(AuditVerifyError::ChainBroken {
765 at_entry: idx,
766 entry_id: entry.id.clone(),
767 });
768 }
769 }
770 }
771 }
772 }
773
774 Ok(())
775}
776
777#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
783 use tempfile::tempdir;
784
785 #[test]
786 fn append_and_read_roundtrip() {
787 let dir = tempdir().unwrap();
788 let log = AuditLog::new(&dir.path().join("t.jsonl"));
789 log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
790 .unwrap();
791 log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
792 .unwrap();
793 log.append(&AuditEntry::failure(
794 "dev",
795 "get",
796 Some("MISSING"),
797 "not found",
798 ))
799 .unwrap();
800 let entries = log.read(None).unwrap();
801 assert_eq!(entries.len(), 3);
802 assert_eq!(entries[0].status, AuditStatus::Failure); }
804
805 #[test]
806 fn limit_truncates() {
807 let dir = tempdir().unwrap();
808 let log = AuditLog::new(&dir.path().join("t.jsonl"));
809 for i in 0..10 {
810 log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
811 .unwrap();
812 }
813 assert_eq!(log.read(Some(3)).unwrap().len(), 3);
814 }
815
816 #[test]
817 fn nonexistent_log_returns_empty() {
818 let dir = tempdir().unwrap();
819 let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
820 assert!(log.read(None).unwrap().is_empty());
821 }
822
823 #[test]
824 fn ids_are_unique() {
825 let e1 = AuditEntry::success("p", "op", None);
826 let e2 = AuditEntry::success("p", "op", None);
827 assert_ne!(e1.id, e2.id);
828 }
829
830 #[test]
831 fn last_successful_operation_finds_rotate() {
832 let dir = tempdir().unwrap();
833 let log = AuditLog::new(&dir.path().join("a.jsonl"));
834 log.append(&AuditEntry::success("dev", "set", Some("K")))
835 .unwrap();
836 log.append(&AuditEntry::success("dev", "rotate", None))
837 .unwrap();
838 log.append(&AuditEntry::success("dev", "get", Some("K")))
839 .unwrap();
840 assert!(log
841 .last_successful_operation("dev", "rotate", 100)
842 .unwrap()
843 .is_some());
844 assert!(log
845 .last_successful_operation("dev", "missing-op", 100)
846 .unwrap()
847 .is_none());
848 }
849
850 #[test]
856 fn hmac_chain_intact_and_detects_tampering() {
857 let dir = tempdir().unwrap();
858 let path = dir.path().join("chain.jsonl");
859 let log = AuditLog::new(&path);
860
861 log.append(&AuditEntry::success("dev", "set", Some("A")))
862 .unwrap();
863 log.append(&AuditEntry::success("dev", "get", Some("A")))
864 .unwrap();
865 log.append(&AuditEntry::failure(
866 "dev",
867 "get",
868 Some("MISSING"),
869 "not found",
870 ))
871 .unwrap();
872
873 log.verify_chain()
875 .expect("chain must be intact after write");
876
877 let content = std::fs::read_to_string(&path).unwrap();
879 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
880
881 let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
883 tampered.operation = "TAMPERED".to_string();
884 lines[1] = serde_json::to_string(&tampered).unwrap();
886
887 let tampered_content = lines.join("\n") + "\n";
888 std::fs::write(&path, tampered_content).unwrap();
889
890 let err = log
893 .verify_chain()
894 .expect_err("tampered log must fail verification");
895 match err {
896 AuditVerifyError::ChainBroken { at_entry, .. } => {
897 assert_eq!(
898 at_entry, 2,
899 "chain should break at the entry after the tampered one"
900 );
901 }
902 other => panic!("unexpected error: {other}"),
903 }
904 }
905
906 #[test]
908 fn first_entry_has_no_prev_hmac() {
909 let dir = tempdir().unwrap();
910 let path = dir.path().join("first.jsonl");
911 let log = AuditLog::new(&path);
912 log.append(&AuditEntry::success("dev", "set", Some("K")))
913 .unwrap();
914
915 let content = std::fs::read_to_string(&path).unwrap();
916 let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
917 assert!(
918 entry.prev_entry_hmac.is_none(),
919 "first entry must have no prev_entry_hmac"
920 );
921 }
922
923 #[test]
925 fn subsequent_entries_carry_prev_hmac() {
926 let dir = tempdir().unwrap();
927 let path = dir.path().join("chain2.jsonl");
928 let log = AuditLog::new(&path);
929 log.append(&AuditEntry::success("dev", "set", Some("A")))
930 .unwrap();
931 log.append(&AuditEntry::success("dev", "get", Some("A")))
932 .unwrap();
933
934 let content = std::fs::read_to_string(&path).unwrap();
935 let lines: Vec<&str> = content.lines().collect();
936 let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
937 assert!(
938 second.prev_entry_hmac.is_some(),
939 "second entry must carry prev_entry_hmac"
940 );
941 }
942
943 #[test]
945 fn old_entries_deserialize_without_prev_hmac() {
946 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
947 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
948 assert!(entry.prev_entry_hmac.is_none());
949 assert!(entry.context.is_none());
950 }
951
952 #[test]
954 fn audit_integrity_contract_v2() {
955 let dir = tempdir().unwrap();
956 let log = AuditLog::new(&dir.path().join("integrity.jsonl"));
957
958 log.append(&AuditEntry::success("dev", "set", Some("A")))
959 .unwrap();
960 log.append(&AuditEntry::success("dev", "get", Some("A")))
961 .unwrap();
962 log.append(&AuditEntry::failure(
963 "dev",
964 "get",
965 Some("MISSING"),
966 "not found",
967 ))
968 .unwrap();
969
970 let entries = log.read(None).unwrap();
971 assert_eq!(entries.len(), 3, "all appended entries must be retained");
972
973 assert_eq!(entries[0].status, AuditStatus::Failure);
975 assert_eq!(entries[1].operation, "get");
976 assert_eq!(entries[2].operation, "set");
977
978 let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
980 assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");
981
982 let mut ordered = entries.clone();
984 ordered.reverse();
985 for w in ordered.windows(2) {
986 assert!(
987 w[0].timestamp <= w[1].timestamp,
988 "timestamps should be non-decreasing in append order"
989 );
990 }
991
992 log.verify_chain()
994 .expect("integrity contract: chain must be intact");
995 }
996
997 #[test]
998 fn old_entries_deserialize_without_context() {
999 let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
1000 let entry: AuditEntry = serde_json::from_str(raw).unwrap();
1001 assert!(entry.context.is_none());
1002 }
1003
1004 #[test]
1007 fn filter_audit_by_command() {
1008 let dir = tempdir().unwrap();
1009 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1010 log.append(&AuditEntry::success("dev", "get", Some("A")))
1011 .unwrap();
1012 log.append(&AuditEntry::success("dev", "set", Some("B")))
1013 .unwrap();
1014 log.append(&AuditEntry::success("dev", "get", Some("C")))
1015 .unwrap();
1016
1017 let gets = log.filter_audit(None, None, Some("get")).unwrap();
1018 assert_eq!(gets.len(), 2);
1019 assert!(gets.iter().all(|e| e.operation == "get"));
1020
1021 let sets = log.filter_audit(None, None, Some("set")).unwrap();
1022 assert_eq!(sets.len(), 1);
1023 }
1024
1025 #[test]
1026 fn filter_audit_by_time_range() {
1027 use chrono::Duration;
1028 let dir = tempdir().unwrap();
1029 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1030
1031 let now = Utc::now();
1032 let old = now - Duration::hours(2);
1033 let recent = now - Duration::minutes(30);
1034
1035 let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
1037 e_old.timestamp = old;
1038 let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
1039 e_recent.timestamp = recent;
1040 let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
1041 e_now.timestamp = now;
1042 log.append(&e_old).unwrap();
1043 log.append(&e_recent).unwrap();
1044 log.append(&e_now).unwrap();
1045
1046 let since_cutoff = now - Duration::hours(1);
1048 let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
1049 assert_eq!(results.len(), 2);
1050
1051 let results = log
1053 .filter_audit(None, Some(now - Duration::hours(1)), None)
1054 .unwrap();
1055 assert_eq!(results.len(), 1);
1056 assert_eq!(results[0].key.as_deref(), Some("OLD"));
1057 }
1058
1059 #[test]
1060 fn filter_audit_combined_since_and_command() {
1061 use chrono::Duration;
1062 let dir = tempdir().unwrap();
1063 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1064
1065 let now = Utc::now();
1066 let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
1067 old_get.timestamp = now - Duration::hours(3);
1068 let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
1069 new_get.timestamp = now - Duration::minutes(5);
1070 let mut new_set = AuditEntry::success("dev", "set", Some("S"));
1071 new_set.timestamp = now - Duration::minutes(5);
1072 log.append(&old_get).unwrap();
1073 log.append(&new_get).unwrap();
1074 log.append(&new_set).unwrap();
1075
1076 let results = log
1077 .filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
1078 .unwrap();
1079 assert_eq!(results.len(), 1);
1080 assert_eq!(results[0].key.as_deref(), Some("NEW"));
1081 }
1082
1083 #[test]
1084 fn filter_audit_empty_log_returns_empty() {
1085 let dir = tempdir().unwrap();
1086 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1087 let results = log.filter_audit(None, None, Some("get")).unwrap();
1088 assert!(results.is_empty());
1089 }
1090
1091 #[test]
1092 fn prune_audit_before_removes_old_entries() {
1093 use chrono::Duration;
1094 let dir = tempdir().unwrap();
1095 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1096
1097 let now = Utc::now();
1098 let mut old = AuditEntry::success("dev", "get", Some("A"));
1099 old.timestamp = now - Duration::hours(48);
1100 let mut recent = AuditEntry::success("dev", "set", Some("B"));
1101 recent.timestamp = now - Duration::hours(1);
1102 log.append(&old).unwrap();
1103 log.append(&recent).unwrap();
1104
1105 let cutoff = now - Duration::hours(24);
1106 let removed = log.prune_audit_before(cutoff).unwrap();
1107 assert_eq!(removed, 1);
1108
1109 let remaining = log.read(None).unwrap();
1110 assert_eq!(remaining.len(), 1);
1111 assert_eq!(remaining[0].key.as_deref(), Some("B"));
1112 }
1113
1114 #[test]
1115 fn prune_audit_before_noop_on_empty_log() {
1116 let dir = tempdir().unwrap();
1117 let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1118 let removed = log.prune_audit_before(Utc::now()).unwrap();
1119 assert_eq!(removed, 0);
1120 }
1121
1122 #[test]
1123 fn prune_audit_before_keeps_all_if_none_old() {
1124 use chrono::Duration;
1125 let dir = tempdir().unwrap();
1126 let log = AuditLog::new(&dir.path().join("t.jsonl"));
1127 log.append(&AuditEntry::success("dev", "get", Some("A")))
1128 .unwrap();
1129 log.append(&AuditEntry::success("dev", "set", Some("B")))
1130 .unwrap();
1131
1132 let removed = log
1134 .prune_audit_before(Utc::now() - Duration::days(1))
1135 .unwrap();
1136 assert_eq!(removed, 0);
1137 assert_eq!(log.read(None).unwrap().len(), 2);
1138 }
1139
1140 #[test]
1141 fn mcp_context_roundtrips_through_serde() {
1142 let entry = AuditEntry::success("dev", "mcp.run", Some("DEMO_KEY")).with_context(
1143 AuditContext::from_mcp(AuditMcpContext {
1144 host: "mcp:claude-desktop:1234".to_string(),
1145 pid: 5678,
1146 tool: "tsafe_run".to_string(),
1147 injected_keys: vec!["DEMO_KEY".to_string()],
1148 exit_code: Some(0),
1149 duration_ms: Some(42),
1150 }),
1151 );
1152
1153 let encoded = serde_json::to_string(&entry).expect("AuditEntry serializes");
1154 let decoded: AuditEntry =
1155 serde_json::from_str(&encoded).expect("AuditEntry round-trips through serde_json");
1156
1157 let ctx = decoded.context.expect("context present after round-trip");
1158 let mcp = ctx.mcp.expect("mcp variant present after round-trip");
1159 assert_eq!(mcp.host, "mcp:claude-desktop:1234");
1160 assert_eq!(mcp.pid, 5678);
1161 assert_eq!(mcp.tool, "tsafe_run");
1162 assert_eq!(mcp.injected_keys, vec!["DEMO_KEY".to_string()]);
1163 assert_eq!(mcp.exit_code, Some(0));
1164 assert_eq!(mcp.duration_ms, Some(42));
1165 assert!(ctx.exec.is_none());
1166 assert!(ctx.cellos.is_none());
1167 assert!(ctx.clipboard.is_none());
1168 assert!(ctx.reveal.is_none());
1169 }
1170
1171 #[test]
1172 fn mcp_context_default_serializes_minimally() {
1173 let json = serde_json::to_string(&AuditMcpContext::default())
1174 .expect("default AuditMcpContext serializes");
1175 assert!(json.contains("\"host\":\"\""));
1177 assert!(json.contains("\"pid\":0"));
1178 assert!(json.contains("\"tool\":\"\""));
1179 assert!(!json.contains("injected_keys"));
1180 assert!(!json.contains("exit_code"));
1181 assert!(!json.contains("duration_ms"));
1182 }
1183
1184 #[test]
1185 fn exec_context_from_contract_seeds_trust_shape() {
1186 let contract = AuthorityContract {
1187 name: "deploy".into(),
1188 profile: Some("work".into()),
1189 namespace: Some("infra".into()),
1190 access_profile: RbacProfile::ReadOnly,
1191 allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
1192 required_secrets: vec!["DB_PASSWORD".into()],
1193 allowed_targets: vec!["terraform".into()],
1194 trust: AuthorityTrust::Hardened,
1195 network: AuthorityNetworkPolicy::Restricted,
1196 };
1197
1198 let exec = AuditExecContext::from_contract(&contract)
1199 .with_target("terraform")
1200 .with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
1201 .with_missing_required_secrets(["DB_PASSWORD"])
1202 .with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
1203 .with_target_evaluation(&contract.evaluate_target(Some("terraform")));
1204
1205 assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
1206 assert_eq!(exec.authority_profile.as_deref(), Some("work"));
1207 assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
1208 assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
1209 assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1210 assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
1211 assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1212 assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
1213 assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
1214 assert_eq!(exec.target_allowed, Some(true));
1215 assert_eq!(
1216 exec.target_decision,
1217 Some(AuthorityTargetDecision::AllowedExact)
1218 );
1219 assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
1220 }
1221}