Skip to main content

tsafe_core/
audit.rs

1//! Append-only structured audit logging for vault operations.
2//!
3//! Each vault operation (get, set, delete, rotate, …) appends a [`AuditEntry`]
4//! as a single JSON line to the profile's `.audit.jsonl` file.  The log file is
5//! created with `0o600` permissions on Unix so other local users cannot read it.
6//!
7//! ## HMAC chain integrity (v2)
8//!
9//! Each entry carries a `prev_entry_hmac` field: the HMAC-SHA256 of the
10//! immediately preceding entry's canonical JSON, keyed by a per-session ephemeral
11//! key generated at [`AuditLog::new()`] time.
12//!
13//! **What this provides:**
14//! - Within-session tamper detection: any modification, insertion, or deletion of
15//!   entries is detectable via [`AuditLog::verify_chain()`] as long as the
16//!   `AuditLog` handle that wrote the entries is still in scope.
17//!
18//! **Explicit ceiling:**
19//! - The chain key is ephemeral — it is generated fresh on each [`AuditLog::new()`]
20//!   and is never persisted.  Cross-session verification is therefore not possible
21//!   (the key used to write a previous session's entries is gone).  Entries written
22//!   by a previous session have `prev_entry_hmac = None` from the perspective of the
23//!   new session's chain.  This is an accepted v2 residual; cross-session
24//!   verification would require persisting the chain key (e.g. in the vault's
25//!   keyring), which is deferred to a post-v2 upgrade.
26//! - Filesystem-level tamper (deleting the entire log file, swapping it for another)
27//!   is not detectable without an external root of trust.
28
29use 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/// Error type returned by [`AuditLog::verify_chain`].
55#[derive(Debug, Error, PartialEq, Eq)]
56pub enum AuditVerifyError {
57    /// An entry's `prev_entry_hmac` does not match the HMAC computed from the
58    /// previous entry's canonical JSON using the session chain key.
59    #[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
60    ChainBroken {
61        /// Zero-based index of the entry whose `prev_entry_hmac` is wrong.
62        at_entry: usize,
63        /// The UUID of the offending entry, for human correlation.
64        entry_id: String,
65    },
66
67    /// A non-empty line in the log could not be parsed as an [`AuditEntry`].
68    /// Treated as tampering: a corrupted/garbage line is a verification failure,
69    /// not something to silently skip (which would let an attacker mangle a line
70    /// to evade chain detection).
71    #[error("audit log line {line} is malformed (not a valid audit entry)")]
72    MalformedLine {
73        /// One-based line number of the unparseable line.
74        line: usize,
75    },
76
77    /// The log file could not be read.
78    #[error("could not read audit log: {0}")]
79    Io(String),
80}
81
82/// Outcome of an audited vault operation.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum AuditStatus {
86    /// The operation completed without error.
87    Success,
88    /// The operation was attempted but failed (reason in [`AuditEntry::message`]).
89    Failure,
90}
91
92/// Optional structured context for an audit entry.
93///
94/// This keeps the local audit log compatible with older entries while giving
95/// higher layers a place to attach non-plaintext execution metadata such as the
96/// chosen authority contract, target, and projected secret set.
97#[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/// CellOS-specific audit context for resolve and revoke operations.
112///
113/// Enables `tsafe audit --cell-id` to return the full resolve/revoke chain for
114/// a given cell without correlating two separate audit systems manually.
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AuditCellosContext {
117    /// CellOS cell identifier, e.g. `doom-cell-001`.
118    pub cellos_cell_id: String,
119    /// The 32-byte hex cell_token registered at supervisor spawn.
120    /// Logged for correlation; safe to store (random nonce, not a secret value).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub cell_token: Option<String>,
123}
124
125/// Clipboard-copy audit context for TUI yank operations.
126///
127/// Recorded on `clipboard.copy`, `clipboard.copy_failed`, `clipboard.cleared`,
128/// and `clipboard.preserved` entries. Never carries the secret value — only
129/// metadata about what the daemon did and what the OS guaranteed.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct AuditClipboardContext {
132    /// Auto-clear TTL in seconds (e.g. 30).
133    pub ttl_secs: u64,
134    /// Backend error string when the operation failed; absent on success.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub reason: Option<String>,
137    /// `Some(true)` when the producer set `org.nspasteboard.ConcealedType`
138    /// (macOS, via arboard's `exclude_from_history`) so conformant clipboard
139    /// managers skip archiving the value. `Some(false)` when concealment was
140    /// attempted but failed. `None` on platforms with no convention (current
141    /// Linux/Windows). Distinguishes "we asked the OS to hide it" from
142    /// "we just wrote and hoped".
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub excluded_from_history: Option<bool>,
145    /// On `clipboard.cleared` / `clipboard.preserved` entries: `Some(true)`
146    /// when the daemon read the clipboard back and the contents matched what
147    /// it had written (so the clear / preservation reflects ground truth).
148    /// `Some(false)` when the contents differed (user copied something else).
149    /// `None` on the original `clipboard.copy` entry (verification hadn't
150    /// happened yet).
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub cleared_verified: Option<bool>,
153}
154
155/// MCP-server audit context recorded on each `tsafe-mcp` tool call.
156///
157/// Reused by every `mcp.<tool>` operation written to the profile's
158/// `.audit.jsonl` by `tsafe-mcp`. Secret values are never recorded; only the
159/// host identifier (from `--audit-source`), the daemon PID, the tool name,
160/// the names of any keys injected by `tsafe_run`, and child-process exit
161/// metadata for `tsafe_run`. See ADR-006 §6.1.
162#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct AuditMcpContext {
164    /// Host identifier set by `tsafe-mcp --audit-source` (e.g.
165    /// `mcp:claude-desktop:1234`). When the flag is absent the daemon falls
166    /// back to `mcp:unknown:<pid>` per ADR-006 §6.1.
167    pub host: String,
168    /// PID of the `tsafe-mcp` daemon that produced this entry.
169    pub pid: u32,
170    /// Tool name (e.g. `tsafe_run`, `tsafe_list_keys`, `tsafe_reveal`).
171    pub tool: String,
172    /// Vault key names injected into the child process by `tsafe_run`. Values
173    /// are NEVER recorded — only the key names that were resolved into the
174    /// child's environment.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub injected_keys: Vec<String>,
177    /// Exit code of the child spawned by `tsafe_run`. Absent for every other
178    /// tool.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub exit_code: Option<i32>,
181    /// Wall-clock duration in milliseconds for `tsafe_run`. Absent for every
182    /// other tool.
183    #[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    /// Build a context that carries only the MCP-server payload.
217    ///
218    /// Used by `tsafe-mcp` per ADR-006 §6.1 to attach host, pid, tool, and
219    /// optional `tsafe_run` execution metadata to every audit entry without
220    /// touching the other context variants.
221    pub fn from_mcp(mcp: AuditMcpContext) -> Self {
222        Self {
223            mcp: Some(mcp),
224            ..Default::default()
225        }
226    }
227}
228
229/// Reveal audit context for TUI `'r'` operations.
230///
231/// Recorded on `secret.reveal_started` (user pressed `r`) and
232/// `secret.reveal_expired` (auto-conceal fired). User-initiated toggle-off
233/// is intentionally not audited — silence implies deliberate user action.
234/// Never carries the secret value — only the TTL.
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct AuditRevealContext {
237    /// Auto-conceal TTL in seconds (e.g. 60).
238    pub ttl_secs: u64,
239}
240
241/// One `--env ENV_VAR=VAULT_KEY` mapping recorded in the audit trail.
242///
243/// This lets auditors see which vault key was sourced for each renamed env var
244/// without the audit log exposing secret values.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AuditEnvMapping {
247    /// The environment variable name that the child process received.
248    pub env: String,
249    /// The vault key whose value was injected under `env`.
250    pub vault_key: String,
251}
252
253/// Execution-specific audit context for `tsafe exec`-style operations.
254#[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    /// Records each `--env ENV_VAR=VAULT_KEY` mapping so the audit trail shows
287    /// which vault key was sourced for each renamed env var injection.
288    #[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    /// Seed an exec context from an authority contract without committing to a
302    /// concrete target or the final injected/dropped sets yet.
303    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/// One structured audit event. Written as a single JSON line (JSONL).
390///
391/// ## Audit integrity contract (v2)
392///
393/// Entries are **chronologically ordered** (append-only JSONL) and each entry
394/// carries a `prev_entry_hmac` field: the hex-encoded HMAC-SHA256 of the
395/// immediately preceding entry's canonical JSON, keyed by the session's
396/// ephemeral chain key held in [`AuditLog`].
397///
398/// **What this provides:**
399/// - Within-session tamper detection: modification, insertion, or deletion of
400///   entries written during a single session can be detected via
401///   [`AuditLog::verify_chain`].
402///
403/// **Explicit ceiling:**
404/// - The chain key is ephemeral (generated at [`AuditLog::new()`], never
405///   persisted). Cross-session verification is not possible. Old entries
406///   written before v2 (no `prev_entry_hmac`) are treated as chain anchors
407///   and do not cause verification failures on their own.
408/// - Filesystem-level attacks (deleting the file, swapping it entirely) are
409///   not detectable without an external root of trust.
410#[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    /// HMAC-SHA256 of the previous entry's canonical JSON, hex-encoded.
422    ///
423    /// `None` for the first entry in a session, for entries written before the
424    /// v2 chain was introduced, or after a cross-session chain break.
425    /// Verification is only meaningful within a single [`AuditLog`] session.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub prev_entry_hmac: Option<String>,
428}
429
430impl AuditEntry {
431    /// Build a success entry with a new UUID and the current UTC timestamp.
432    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    /// Build a failure entry with a new UUID, the current UTC timestamp, and an error message.
447    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    /// Attach optional structured context without changing the legacy fields.
462    pub fn with_context(mut self, context: AuditContext) -> Self {
463        self.context = Some(context);
464        self
465    }
466}
467
468/// Compute HMAC-SHA256 of `entry`'s canonical JSON serialization using `key`.
469///
470/// The canonical form is `serde_json::to_string(entry)` — the same bytes
471/// written to the log file for that entry (before the trailing newline).
472/// Returns a lowercase hex-encoded digest.
473pub 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
482/// Generate a cryptographically random 32-byte chain key.
483fn derive_chain_key() -> [u8; 32] {
484    let mut key = [0u8; 32];
485    rand::rngs::OsRng.fill_bytes(&mut key);
486    key
487}
488
489/// Append-only JSONL audit log with within-session HMAC chain integrity.
490///
491/// The chain key is generated fresh at [`AuditLog::new()`] and is held only
492/// in memory for the lifetime of this handle. All entries appended through
493/// this handle form a verifiable chain via [`AuditLog::verify_chain()`].
494/// Entries from prior sessions (different `AuditLog` instances) have
495/// `prev_entry_hmac = None` and act as chain anchors for the new session.
496pub struct AuditLog {
497    path: PathBuf,
498    /// Per-session ephemeral HMAC key. Never persisted.
499    chain_key: [u8; 32],
500    /// HMAC of the last entry written through this handle. Seeded from the
501    /// file's existing last line on the first append of a new session, then
502    /// updated after every subsequent append.
503    prev_hmac: std::cell::Cell<Option<String>>,
504    /// Whether `prev_hmac` has been bootstrapped from the file.
505    bootstrapped: std::cell::Cell<bool>,
506}
507
508impl AuditLog {
509    /// Create a handle to the audit log at `path`. The file is created lazily on first [`Self::append`].
510    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    /// Read the last line of the audit log and return the `prev_entry_hmac`
520    /// of that entry (if any). Used to bootstrap the chain on the first append
521    /// of a new session: the new session starts fresh (no prior HMAC), so
522    /// the first entry always has `prev_entry_hmac = None`.
523    fn bootstrap_if_needed(&self) {
524        if self.bootstrapped.get() {
525            return;
526        }
527        self.bootstrapped.set(true);
528        // The new session's chain starts with None — we do not inherit the
529        // previous session's HMAC because that key is gone. The first entry
530        // of this session is a chain anchor.
531        self.prev_hmac.set(None);
532    }
533
534    /// Append an entry. The entry's `prev_entry_hmac` is set to the HMAC of
535    /// the previously written entry (within this session), then the entry is
536    /// written and this handle's chain state is advanced.
537    ///
538    /// Errors here are non-fatal (caller should `.ok()` if audit is best-effort).
539    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        // Clone and set the chain link on our local copy.
552        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        // Advance chain: compute HMAC of what we just wrote (without the newline).
569        let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
570        self.prev_hmac.set(Some(next_hmac));
571
572        Ok(())
573    }
574
575    /// Verify the HMAC chain for all entries written by this session.
576    ///
577    /// Reads the log file and replays the chain using this session's key.
578    /// Entries with `prev_entry_hmac = None` are treated as chain anchors
579    /// (session boundaries or pre-v2 entries) — they reset the expected HMAC
580    /// to `None` and verification continues from there.
581    ///
582    /// Returns `Ok(())` if every non-anchor entry's `prev_entry_hmac` matches
583    /// the HMAC computed from the previous entry. Returns
584    /// [`AuditVerifyError::ChainBroken`] at the first mismatch, or
585    /// [`AuditVerifyError::MalformedLine`] if any non-empty line cannot be
586    /// parsed as an audit entry (corruption is treated as tampering).
587    pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
588        verify_chain_with_key(&self.path, &self.chain_key)
589    }
590
591    /// Read entries, most recent first. Silently skip malformed lines.
592    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(); // most recent first
603        if let Some(n) = limit {
604            entries.truncate(n);
605        }
606        Ok(entries)
607    }
608
609    /// Read and project entries into plaintext-free explanation sessions.
610    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    /// Most recent successful audit entry for this profile and operation, if any.
615    /// Entries are scanned newest-first (up to `scan_limit` lines worth of entries).
616    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    /// Return filtered audit entries.
634    ///
635    /// All parameters are optional:
636    /// - `since`   — only entries with `timestamp >= since` are returned.
637    /// - `until`   — only entries with `timestamp <= until` are returned.
638    /// - `command` — only entries whose `operation` exactly matches this string.
639    ///
640    /// Results are returned newest-first (matching `read()`).
641    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    /// Remove all audit entries with `timestamp < before` (i.e. older than the cutoff).
672    ///
673    /// Rewrites the log file atomically. Entries at or after `before` are kept.
674    /// Returns the number of entries that were removed.
675    ///
676    /// Note: pruning breaks the HMAC chain because it removes entries; the
677    /// retained entries' `prev_entry_hmac` values become invalid relative to
678    /// the new session's key. This is expected — prune is a privileged
679    /// administrative operation.
680    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        // On Windows rename may fail if the log is open by another process.
707        if let Err(e) = std::fs::rename(&tmp, &self.path) {
708            let _ = std::fs::remove_file(&tmp); // clean up temp on failure
709            return Err(std::io::Error::other(format!(
710                "audit prune: failed to rename temp file — log unchanged: {e}"
711            ))
712            .into());
713        }
714        // Append a sentinel entry so HMAC-chain readers can distinguish a
715        // legitimate prune from tampering. The sentinel carries no secret data.
716        // Derive the profile name from the log file stem (e.g. prod.audit.jsonl → prod).
717        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
729/// Return the size in bytes of the audit log file at `path`.
730///
731/// Returns `Ok(0)` when the file does not exist (no log yet is not an error).
732pub 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
740/// Verify the HMAC chain in a log file using the provided key.
741///
742/// This is the inner implementation used by [`AuditLog::verify_chain`].
743/// Entries whose `prev_entry_hmac` is `None` are treated as chain anchors
744/// (session starts or pre-v2 entries) and do not cause verification failures.
745fn 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    // Parse every non-empty line. A line that fails to parse is treated as
752    // tampering (MalformedLine) rather than silently skipped — otherwise an
753    // attacker could corrupt a line just enough to make it non-JSON and evade
754    // the HMAC chain check entirely.
755    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    // Walk entries in append order. prev_computed tracks the HMAC of the last
767    // entry we processed. For entries with prev_entry_hmac = None we treat
768    // them as anchors and reset our expected value to None.
769    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                // Chain anchor: reset the running HMAC and compute this entry's HMAC
775                // for the next iteration.
776                prev_computed = Some(compute_entry_hmac(entry, key));
777            }
778            Some(stored_hmac) => {
779                // Verify the stored HMAC matches what we expect.
780                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// ── tests ────────────────────────────────────────────────────────────────────
799
800#[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); // most recent first
824    }
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    /// Verifies the HMAC chain integrity contract introduced in v2.
872    ///
873    /// Three entries are written through a single `AuditLog` handle (one session).
874    /// The chain must be intact. Entry 2's JSON is then tampered with on disk
875    /// and re-verification must report `ChainBroken { at_entry: 2 }`.
876    #[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        // Chain must be intact immediately after writing.
895        log.verify_chain()
896            .expect("chain must be intact after write");
897
898        // Tamper with entry at index 1 (second line, 0-based) on disk.
899        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        // Deserialize line 1, modify its operation field, re-serialize.
903        let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
904        tampered.operation = "TAMPERED".to_string();
905        // Preserve the original prev_entry_hmac — attacker does not know the key.
906        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        // Verification must detect the broken chain at entry index 2 (entry 2's
912        // prev_entry_hmac now points to a digest that no longer matches line 1).
913        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    /// A non-empty line that cannot be parsed as an audit entry must be treated
928    /// as tampering (MalformedLine), not silently skipped — otherwise an attacker
929    /// could corrupt a line into non-JSON to evade the HMAC chain check.
930    #[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        // Corrupt the second line into invalid JSON.
945        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    /// Chain starts fresh (no inherited HMAC) for the first entry in a session.
965    #[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    /// Subsequent entries within a session carry the chain HMAC.
982    #[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    /// Old entries without `prev_entry_hmac` deserialize cleanly (backward compat).
1002    #[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    /// Documents the current audit integrity contract (v2).
1011    #[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        // Most-recent-first order (failure was last appended).
1032        assert_eq!(entries[0].status, AuditStatus::Failure);
1033        assert_eq!(entries[1].operation, "get");
1034        assert_eq!(entries[2].operation, "set");
1035
1036        // Every entry carries a unique UUID.
1037        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        // Timestamps are monotonically non-decreasing in append order.
1041        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        // Chain must be intact.
1051        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    // ── Task 1.9: filter_audit and prune_audit_before ─────────────────────────
1063
1064    #[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        // Inject entries with specific timestamps.
1094        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        // since = 1 hour ago → excludes old
1105        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        // until = 1 hour ago → includes only old
1110        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        // Prune entries older than 1 day — all entries are recent.
1197        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        // host/pid/tool are mandatory; injected_keys/exit_code/duration_ms skip when default.
1240        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}