Skip to main content

tryaudex_core/
audit.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::Result;
7use crate::session::{CloudProvider, Session};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuditEntry {
11    pub timestamp: DateTime<Utc>,
12    pub session_id: String,
13    /// Cloud provider for this entry (defaults to "aws" for backwards compat).
14    #[serde(default = "default_provider")]
15    pub provider: String,
16    pub event: AuditEvent,
17}
18
19fn default_provider() -> String {
20    "aws".to_string()
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum AuditEvent {
26    SessionCreated {
27        /// "Role ARN" for AWS, "Service Account" for GCP, "Subscription" for Azure
28        role_arn: String,
29        ttl_seconds: u64,
30        budget: Option<f64>,
31        allowed_actions: Vec<String>,
32        command: Vec<String>,
33        /// AI agent/model identity (from AUDEX_AGENT_ID env var).
34        #[serde(default, skip_serializing_if = "Option::is_none")]
35        agent_id: Option<String>,
36    },
37    CredentialsIssued {
38        access_key_id: String,
39        expires_at: DateTime<Utc>,
40    },
41    SessionEnded {
42        status: String,
43        duration_seconds: i64,
44        exit_code: Option<i32>,
45    },
46    BudgetWarning {
47        current_spend: f64,
48        limit: f64,
49    },
50    BudgetExceeded {
51        final_spend: f64,
52        limit: f64,
53    },
54    /// A cloud resource created during this session (Phase 1 of the
55    /// resource-lifecycle flow). Emitted once per resource after the wrapped
56    /// subprocess exits successfully, parsed from the command args.
57    ResourceCreated {
58        service: String,
59        resource_type: String,
60        identifier: String,
61    },
62}
63
64/// Append-only audit logger that writes to a local JSONL file.
65pub struct AuditLog {
66    path: PathBuf,
67}
68
69impl AuditLog {
70    pub fn new() -> Result<Self> {
71        let dir = dirs::data_local_dir()
72            .unwrap_or_else(|| PathBuf::from("."))
73            .join("audex")
74            .join("audit");
75        std::fs::create_dir_all(&dir)?;
76        let path = dir.join("audit.jsonl");
77        Ok(Self { path })
78    }
79
80    /// Construct an AuditLog at a caller-provided path. Used by tests so they
81    /// don't pollute the real audit log at `~/.local/share/audex/audit/audit.jsonl`.
82    pub fn with_path(path: PathBuf) -> Result<Self> {
83        if let Some(parent) = path.parent() {
84            std::fs::create_dir_all(parent)?;
85        }
86        Ok(Self { path })
87    }
88
89    pub fn log(&self, session_id: &str, event: AuditEvent) -> Result<()> {
90        self.log_with_provider(session_id, "aws", event)
91    }
92
93    pub fn log_with_provider(
94        &self,
95        session_id: &str,
96        provider: &str,
97        event: AuditEvent,
98    ) -> Result<()> {
99        let entry = AuditEntry {
100            timestamp: Utc::now(),
101            session_id: session_id.to_string(),
102            provider: provider.to_string(),
103            event,
104        };
105        let json_line = serde_json::to_string(&entry)?;
106
107        // Redact any credential material before persisting
108        let json_line = crate::leakdetect::redact_secrets(&json_line);
109
110        // Take an exclusive OS-level lock on the audit log for the entire
111        // read-hash-then-append sequence. Without this, concurrent writers
112        // (threads OR separate tryaudex processes) would each read the same
113        // `previous_hash`, compute HMACs against it, and append — breaking
114        // the chain for every line after the first one to hit disk.
115        use fs4::fs_std::FileExt;
116        use std::io::Write;
117        let mut file = std::fs::OpenOptions::new()
118            .create(true)
119            .append(true)
120            .read(true)
121            .open(&self.path)?;
122        file.lock_exclusive()?;
123
124        // Sign with chain HMAC for tamper detection — now inside the lock.
125        let previous_hash = crate::integrity::last_chain_hash(&self.path);
126        let signed_line = crate::integrity::sign_line(&json_line, &previous_hash);
127
128        let mut output = signed_line;
129        output.push('\n');
130
131        let write_result = file.write_all(output.as_bytes());
132        // Always release the lock, even if the write failed.
133        let _ = FileExt::unlock(&file);
134        write_result?;
135
136        Ok(())
137    }
138
139    pub fn log_session_created(&self, session: &Session) -> Result<()> {
140        let allowed_actions: Vec<String> = match session.provider {
141            CloudProvider::Gcp => session
142                .policy
143                .actions
144                .iter()
145                .map(|a| a.to_gcp_permission())
146                .collect(),
147            CloudProvider::Azure => session
148                .policy
149                .actions
150                .iter()
151                .map(|a| a.to_azure_permission())
152                .collect(),
153            CloudProvider::Aws => session
154                .policy
155                .actions
156                .iter()
157                .map(|a| a.to_iam_action())
158                .collect(),
159        };
160
161        self.log_with_provider(
162            &session.id,
163            &session.provider.to_string(),
164            AuditEvent::SessionCreated {
165                role_arn: session.role_arn.clone(),
166                ttl_seconds: session.ttl_seconds,
167                budget: session.budget,
168                allowed_actions,
169                command: session.command.clone(),
170                agent_id: session.agent_id.clone(),
171            },
172        )
173    }
174
175    pub fn log_session_ended(&self, session: &Session, exit_code: Option<i32>) -> Result<()> {
176        let duration = (Utc::now() - session.created_at).num_seconds();
177        self.log_with_provider(
178            &session.id,
179            &session.provider.to_string(),
180            AuditEvent::SessionEnded {
181                status: session.status.to_string(),
182                duration_seconds: duration,
183                exit_code,
184            },
185        )
186    }
187
188    /// Read all audit entries, optionally filtered by session ID.
189    pub fn read(&self, session_id: Option<&str>) -> Result<Vec<AuditEntry>> {
190        self.read_filtered(session_id, None)
191    }
192
193    /// Read audit entries with optional session ID and provider filters.
194    pub fn read_filtered(
195        &self,
196        session_id: Option<&str>,
197        provider: Option<&str>,
198    ) -> Result<Vec<AuditEntry>> {
199        if !self.path.exists() {
200            return Ok(Vec::new());
201        }
202        let content = std::fs::read_to_string(&self.path)?;
203        let entries: Vec<AuditEntry> = content
204            .lines()
205            .filter(|l| !l.trim().is_empty())
206            .filter_map(|l| {
207                // Strip HMAC signature if present before parsing JSON
208                let (json_content, _) = crate::integrity::parse_line(l);
209                serde_json::from_str(json_content).ok()
210            })
211            .filter(|e: &AuditEntry| session_id.is_none_or(|id| e.session_id == id))
212            .filter(|e: &AuditEntry| provider.is_none_or(|p| e.provider == p))
213            .collect();
214        Ok(entries)
215    }
216
217    /// Verify the integrity of the audit log using chain HMACs.
218    pub fn verify(&self) -> Result<crate::integrity::VerificationResult> {
219        crate::integrity::verify_audit_log(&self.path)
220    }
221
222    pub fn path(&self) -> &PathBuf {
223        &self.path
224    }
225}