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    /// The log file could not be read.
68    #[error("could not read audit log: {0}")]
69    Io(String),
70}
71
72/// Outcome of an audited vault operation.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum AuditStatus {
76    /// The operation completed without error.
77    Success,
78    /// The operation was attempted but failed (reason in [`AuditEntry::message`]).
79    Failure,
80}
81
82/// Optional structured context for an audit entry.
83///
84/// This keeps the local audit log compatible with older entries while giving
85/// higher layers a place to attach non-plaintext execution metadata such as the
86/// chosen authority contract, target, and projected secret set.
87#[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/// CellOS-specific audit context for resolve and revoke operations.
102///
103/// Enables `tsafe audit --cell-id` to return the full resolve/revoke chain for
104/// a given cell without correlating two separate audit systems manually.
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct AuditCellosContext {
107    /// CellOS cell identifier, e.g. `doom-cell-001`.
108    pub cellos_cell_id: String,
109    /// The 32-byte hex cell_token registered at supervisor spawn.
110    /// Logged for correlation; safe to store (random nonce, not a secret value).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub cell_token: Option<String>,
113}
114
115/// Clipboard-copy audit context for TUI yank operations.
116///
117/// Recorded on `clipboard.copy`, `clipboard.copy_failed`, `clipboard.cleared`,
118/// and `clipboard.preserved` entries. Never carries the secret value — only
119/// metadata about what the daemon did and what the OS guaranteed.
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct AuditClipboardContext {
122    /// Auto-clear TTL in seconds (e.g. 30).
123    pub ttl_secs: u64,
124    /// Backend error string when the operation failed; absent on success.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub reason: Option<String>,
127    /// `Some(true)` when the producer set `org.nspasteboard.ConcealedType`
128    /// (macOS, via arboard's `exclude_from_history`) so conformant clipboard
129    /// managers skip archiving the value. `Some(false)` when concealment was
130    /// attempted but failed. `None` on platforms with no convention (current
131    /// Linux/Windows). Distinguishes "we asked the OS to hide it" from
132    /// "we just wrote and hoped".
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub excluded_from_history: Option<bool>,
135    /// On `clipboard.cleared` / `clipboard.preserved` entries: `Some(true)`
136    /// when the daemon read the clipboard back and the contents matched what
137    /// it had written (so the clear / preservation reflects ground truth).
138    /// `Some(false)` when the contents differed (user copied something else).
139    /// `None` on the original `clipboard.copy` entry (verification hadn't
140    /// happened yet).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub cleared_verified: Option<bool>,
143}
144
145/// MCP-server audit context recorded on each `tsafe-mcp` tool call.
146///
147/// Reused by every `mcp.<tool>` operation written to the profile's
148/// `.audit.jsonl` by `tsafe-mcp`. Secret values are never recorded; only the
149/// host identifier (from `--audit-source`), the daemon PID, the tool name,
150/// the names of any keys injected by `tsafe_run`, and child-process exit
151/// metadata for `tsafe_run`. See ADR-006 §6.1.
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct AuditMcpContext {
154    /// Host identifier set by `tsafe-mcp --audit-source` (e.g.
155    /// `mcp:claude-desktop:1234`). When the flag is absent the daemon falls
156    /// back to `mcp:unknown:<pid>` per ADR-006 §6.1.
157    pub host: String,
158    /// PID of the `tsafe-mcp` daemon that produced this entry.
159    pub pid: u32,
160    /// Tool name (e.g. `tsafe_run`, `tsafe_list_keys`, `tsafe_reveal`).
161    pub tool: String,
162    /// Vault key names injected into the child process by `tsafe_run`. Values
163    /// are NEVER recorded — only the key names that were resolved into the
164    /// child's environment.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub injected_keys: Vec<String>,
167    /// Exit code of the child spawned by `tsafe_run`. Absent for every other
168    /// tool.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub exit_code: Option<i32>,
171    /// Wall-clock duration in milliseconds for `tsafe_run`. Absent for every
172    /// other tool.
173    #[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    /// Build a context that carries only the MCP-server payload.
207    ///
208    /// Used by `tsafe-mcp` per ADR-006 §6.1 to attach host, pid, tool, and
209    /// optional `tsafe_run` execution metadata to every audit entry without
210    /// touching the other context variants.
211    pub fn from_mcp(mcp: AuditMcpContext) -> Self {
212        Self {
213            mcp: Some(mcp),
214            ..Default::default()
215        }
216    }
217}
218
219/// Reveal audit context for TUI `'r'` operations.
220///
221/// Recorded on `secret.reveal_started` (user pressed `r`) and
222/// `secret.reveal_expired` (auto-conceal fired). User-initiated toggle-off
223/// is intentionally not audited — silence implies deliberate user action.
224/// Never carries the secret value — only the TTL.
225#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
226pub struct AuditRevealContext {
227    /// Auto-conceal TTL in seconds (e.g. 60).
228    pub ttl_secs: u64,
229}
230
231/// One `--env ENV_VAR=VAULT_KEY` mapping recorded in the audit trail.
232///
233/// This lets auditors see which vault key was sourced for each renamed env var
234/// without the audit log exposing secret values.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct AuditEnvMapping {
237    /// The environment variable name that the child process received.
238    pub env: String,
239    /// The vault key whose value was injected under `env`.
240    pub vault_key: String,
241}
242
243/// Execution-specific audit context for `tsafe exec`-style operations.
244#[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    /// Records each `--env ENV_VAR=VAULT_KEY` mapping so the audit trail shows
277    /// which vault key was sourced for each renamed env var injection.
278    #[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    /// Seed an exec context from an authority contract without committing to a
292    /// concrete target or the final injected/dropped sets yet.
293    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/// One structured audit event. Written as a single JSON line (JSONL).
380///
381/// ## Audit integrity contract (v2)
382///
383/// Entries are **chronologically ordered** (append-only JSONL) and each entry
384/// carries a `prev_entry_hmac` field: the hex-encoded HMAC-SHA256 of the
385/// immediately preceding entry's canonical JSON, keyed by the session's
386/// ephemeral chain key held in [`AuditLog`].
387///
388/// **What this provides:**
389/// - Within-session tamper detection: modification, insertion, or deletion of
390///   entries written during a single session can be detected via
391///   [`AuditLog::verify_chain`].
392///
393/// **Explicit ceiling:**
394/// - The chain key is ephemeral (generated at [`AuditLog::new()`], never
395///   persisted). Cross-session verification is not possible. Old entries
396///   written before v2 (no `prev_entry_hmac`) are treated as chain anchors
397///   and do not cause verification failures on their own.
398/// - Filesystem-level attacks (deleting the file, swapping it entirely) are
399///   not detectable without an external root of trust.
400#[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    /// HMAC-SHA256 of the previous entry's canonical JSON, hex-encoded.
412    ///
413    /// `None` for the first entry in a session, for entries written before the
414    /// v2 chain was introduced, or after a cross-session chain break.
415    /// Verification is only meaningful within a single [`AuditLog`] session.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub prev_entry_hmac: Option<String>,
418}
419
420impl AuditEntry {
421    /// Build a success entry with a new UUID and the current UTC timestamp.
422    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    /// Build a failure entry with a new UUID, the current UTC timestamp, and an error message.
437    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    /// Attach optional structured context without changing the legacy fields.
452    pub fn with_context(mut self, context: AuditContext) -> Self {
453        self.context = Some(context);
454        self
455    }
456}
457
458/// Compute HMAC-SHA256 of `entry`'s canonical JSON serialization using `key`.
459///
460/// The canonical form is `serde_json::to_string(entry)` — the same bytes
461/// written to the log file for that entry (before the trailing newline).
462/// Returns a lowercase hex-encoded digest.
463pub 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
472/// Generate a cryptographically random 32-byte chain key.
473fn derive_chain_key() -> [u8; 32] {
474    let mut key = [0u8; 32];
475    rand::rngs::OsRng.fill_bytes(&mut key);
476    key
477}
478
479/// Append-only JSONL audit log with within-session HMAC chain integrity.
480///
481/// The chain key is generated fresh at [`AuditLog::new()`] and is held only
482/// in memory for the lifetime of this handle. All entries appended through
483/// this handle form a verifiable chain via [`AuditLog::verify_chain()`].
484/// Entries from prior sessions (different `AuditLog` instances) have
485/// `prev_entry_hmac = None` and act as chain anchors for the new session.
486pub struct AuditLog {
487    path: PathBuf,
488    /// Per-session ephemeral HMAC key. Never persisted.
489    chain_key: [u8; 32],
490    /// HMAC of the last entry written through this handle. Seeded from the
491    /// file's existing last line on the first append of a new session, then
492    /// updated after every subsequent append.
493    prev_hmac: std::cell::Cell<Option<String>>,
494    /// Whether `prev_hmac` has been bootstrapped from the file.
495    bootstrapped: std::cell::Cell<bool>,
496}
497
498impl AuditLog {
499    /// Create a handle to the audit log at `path`. The file is created lazily on first [`Self::append`].
500    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    /// Read the last line of the audit log and return the `prev_entry_hmac`
510    /// of that entry (if any). Used to bootstrap the chain on the first append
511    /// of a new session: the new session starts fresh (no prior HMAC), so
512    /// the first entry always has `prev_entry_hmac = None`.
513    fn bootstrap_if_needed(&self) {
514        if self.bootstrapped.get() {
515            return;
516        }
517        self.bootstrapped.set(true);
518        // The new session's chain starts with None — we do not inherit the
519        // previous session's HMAC because that key is gone. The first entry
520        // of this session is a chain anchor.
521        self.prev_hmac.set(None);
522    }
523
524    /// Append an entry. The entry's `prev_entry_hmac` is set to the HMAC of
525    /// the previously written entry (within this session), then the entry is
526    /// written and this handle's chain state is advanced.
527    ///
528    /// Errors here are non-fatal (caller should `.ok()` if audit is best-effort).
529    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        // Clone and set the chain link on our local copy.
542        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        // Advance chain: compute HMAC of what we just wrote (without the newline).
559        let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
560        self.prev_hmac.set(Some(next_hmac));
561
562        Ok(())
563    }
564
565    /// Verify the HMAC chain for all entries written by this session.
566    ///
567    /// Reads the log file and replays the chain using this session's key.
568    /// Entries with `prev_entry_hmac = None` are treated as chain anchors
569    /// (session boundaries or pre-v2 entries) — they reset the expected HMAC
570    /// to `None` and verification continues from there.
571    ///
572    /// Returns `Ok(())` if every non-anchor entry's `prev_entry_hmac` matches
573    /// the HMAC computed from the previous entry. Returns
574    /// [`AuditVerifyError::ChainBroken`] at the first mismatch.
575    pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
576        verify_chain_with_key(&self.path, &self.chain_key)
577    }
578
579    /// Read entries, most recent first. Silently skip malformed lines.
580    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(); // most recent first
591        if let Some(n) = limit {
592            entries.truncate(n);
593        }
594        Ok(entries)
595    }
596
597    /// Read and project entries into plaintext-free explanation sessions.
598    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    /// Most recent successful audit entry for this profile and operation, if any.
603    /// Entries are scanned newest-first (up to `scan_limit` lines worth of entries).
604    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    /// Return filtered audit entries.
622    ///
623    /// All parameters are optional:
624    /// - `since`   — only entries with `timestamp >= since` are returned.
625    /// - `until`   — only entries with `timestamp <= until` are returned.
626    /// - `command` — only entries whose `operation` exactly matches this string.
627    ///
628    /// Results are returned newest-first (matching `read()`).
629    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    /// Remove all audit entries with `timestamp < before` (i.e. older than the cutoff).
660    ///
661    /// Rewrites the log file atomically. Entries at or after `before` are kept.
662    /// Returns the number of entries that were removed.
663    ///
664    /// Note: pruning breaks the HMAC chain because it removes entries; the
665    /// retained entries' `prev_entry_hmac` values become invalid relative to
666    /// the new session's key. This is expected — prune is a privileged
667    /// administrative operation.
668    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        // On Windows rename may fail if the log is open by another process.
695        if let Err(e) = std::fs::rename(&tmp, &self.path) {
696            let _ = std::fs::remove_file(&tmp); // clean up temp on failure
697            return Err(std::io::Error::other(format!(
698                "audit prune: failed to rename temp file — log unchanged: {e}"
699            ))
700            .into());
701        }
702        // Append a sentinel entry so HMAC-chain readers can distinguish a
703        // legitimate prune from tampering. The sentinel carries no secret data.
704        // Derive the profile name from the log file stem (e.g. prod.audit.jsonl → prod).
705        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
717/// Return the size in bytes of the audit log file at `path`.
718///
719/// Returns `Ok(0)` when the file does not exist (no log yet is not an error).
720pub 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
728/// Verify the HMAC chain in a log file using the provided key.
729///
730/// This is the inner implementation used by [`AuditLog::verify_chain`].
731/// Entries whose `prev_entry_hmac` is `None` are treated as chain anchors
732/// (session starts or pre-v2 entries) and do not cause verification failures.
733fn 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    // Walk entries in append order. prev_computed tracks the HMAC of the last
746    // entry we processed. For entries with prev_entry_hmac = None we treat
747    // them as anchors and reset our expected value to None.
748    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                // Chain anchor: reset the running HMAC and compute this entry's HMAC
754                // for the next iteration.
755                prev_computed = Some(compute_entry_hmac(entry, key));
756            }
757            Some(stored_hmac) => {
758                // Verify the stored HMAC matches what we expect.
759                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// ── tests ────────────────────────────────────────────────────────────────────
778
779#[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); // most recent first
803    }
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    /// Verifies the HMAC chain integrity contract introduced in v2.
851    ///
852    /// Three entries are written through a single `AuditLog` handle (one session).
853    /// The chain must be intact. Entry 2's JSON is then tampered with on disk
854    /// and re-verification must report `ChainBroken { at_entry: 2 }`.
855    #[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        // Chain must be intact immediately after writing.
874        log.verify_chain()
875            .expect("chain must be intact after write");
876
877        // Tamper with entry at index 1 (second line, 0-based) on disk.
878        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        // Deserialize line 1, modify its operation field, re-serialize.
882        let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
883        tampered.operation = "TAMPERED".to_string();
884        // Preserve the original prev_entry_hmac — attacker does not know the key.
885        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        // Verification must detect the broken chain at entry index 2 (entry 2's
891        // prev_entry_hmac now points to a digest that no longer matches line 1).
892        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    /// Chain starts fresh (no inherited HMAC) for the first entry in a session.
907    #[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    /// Subsequent entries within a session carry the chain HMAC.
924    #[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    /// Old entries without `prev_entry_hmac` deserialize cleanly (backward compat).
944    #[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    /// Documents the current audit integrity contract (v2).
953    #[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        // Most-recent-first order (failure was last appended).
974        assert_eq!(entries[0].status, AuditStatus::Failure);
975        assert_eq!(entries[1].operation, "get");
976        assert_eq!(entries[2].operation, "set");
977
978        // Every entry carries a unique UUID.
979        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        // Timestamps are monotonically non-decreasing in append order.
983        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        // Chain must be intact.
993        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    // ── Task 1.9: filter_audit and prune_audit_before ─────────────────────────
1005
1006    #[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        // Inject entries with specific timestamps.
1036        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        // since = 1 hour ago → excludes old
1047        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        // until = 1 hour ago → includes only old
1052        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        // Prune entries older than 1 day — all entries are recent.
1133        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        // host/pid/tool are mandatory; injected_keys/exit_code/duration_ms skip when default.
1176        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}