Skip to main content

astrid_audit/
entry.rs

1//! Audit entry types and actions.
2//!
3//! Every security-relevant operation is recorded as an audit entry.
4//! Entries are chain-linked (each contains the hash of the previous)
5//! and signed by the runtime.
6
7use astrid_capabilities::AuditEntryId;
8use astrid_core::{Permission, RiskLevel, SessionId, Timestamp, TokenId};
9use astrid_crypto::{ContentHash, KeyPair, PublicKey, Signature};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{AuditError, AuditResult};
13
14/// A single audit log entry.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEntry {
17    /// Unique entry identifier.
18    pub id: AuditEntryId,
19    /// When this entry was created.
20    pub timestamp: Timestamp,
21    /// Session this entry belongs to.
22    pub session_id: SessionId,
23    /// The principal (user identity) this action was performed on behalf of.
24    /// `None` for system actions that have no user context.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub principal: Option<astrid_core::PrincipalId>,
27    /// The action being audited.
28    pub action: AuditAction,
29    /// Authorization proof for this action.
30    pub authorization: AuthorizationProof,
31    /// Outcome of the action.
32    pub outcome: AuditOutcome,
33    /// Hash of the previous entry (chain linking).
34    pub previous_hash: ContentHash,
35    /// Runtime public key that signed this entry.
36    pub runtime_key: PublicKey,
37    /// Signature over entry contents.
38    pub signature: Signature,
39}
40
41impl AuditEntry {
42    /// Create a new audit entry (unsigned).
43    fn new_unsigned(
44        session_id: SessionId,
45        action: AuditAction,
46        authorization: AuthorizationProof,
47        outcome: AuditOutcome,
48        previous_hash: ContentHash,
49        runtime_key: PublicKey,
50    ) -> Self {
51        Self {
52            id: AuditEntryId::new(),
53            timestamp: Timestamp::now(),
54            session_id,
55            principal: None,
56            action,
57            authorization,
58            outcome,
59            previous_hash,
60            runtime_key,
61            signature: Signature::from_bytes([0u8; 64]), // Placeholder
62        }
63    }
64
65    /// Create and sign a new audit entry.
66    #[must_use]
67    pub fn create(
68        session_id: SessionId,
69        action: AuditAction,
70        authorization: AuthorizationProof,
71        outcome: AuditOutcome,
72        previous_hash: ContentHash,
73        runtime_key: &KeyPair,
74    ) -> Self {
75        let mut entry = Self::new_unsigned(
76            session_id,
77            action,
78            authorization,
79            outcome,
80            previous_hash,
81            runtime_key.export_public_key(),
82        );
83
84        let signing_data = entry.signing_data();
85        entry.signature = runtime_key.sign(&signing_data);
86
87        entry
88    }
89
90    /// Create and sign a new audit entry with a principal.
91    ///
92    /// Used when audit entries need to record which principal an action
93    /// was performed on behalf of. Call sites will be wired when the
94    /// kernel audit integration is updated.
95    #[must_use]
96    pub fn create_with_principal(
97        session_id: SessionId,
98        principal: astrid_core::PrincipalId,
99        action: AuditAction,
100        authorization: AuthorizationProof,
101        outcome: AuditOutcome,
102        previous_hash: ContentHash,
103        runtime_key: &KeyPair,
104    ) -> Self {
105        let mut entry = Self::new_unsigned(
106            session_id,
107            action,
108            authorization,
109            outcome,
110            previous_hash,
111            runtime_key.export_public_key(),
112        );
113        entry.principal = Some(principal);
114
115        let signing_data = entry.signing_data();
116        entry.signature = runtime_key.sign(&signing_data);
117
118        entry
119    }
120
121    /// Get the data used for signing.
122    #[must_use]
123    pub fn signing_data(&self) -> Vec<u8> {
124        let mut data = Vec::new();
125        data.extend_from_slice(self.id.0.as_bytes());
126        data.extend_from_slice(&self.timestamp.0.timestamp().to_le_bytes());
127        data.extend_from_slice(self.session_id.0.as_bytes());
128        // Include principal in signing data with length-delimited encoding
129        // to prevent ambiguity between None and adjacent field boundaries.
130        // 0xFF marker + 4-byte length + bytes for Some, 0x00 marker for None.
131        if let Some(ref p) = self.principal {
132            let bytes = p.as_str().as_bytes();
133            data.push(0xFF); // presence marker
134            // PrincipalId is max 64 bytes — safe truncation.
135            #[expect(clippy::cast_possible_truncation)]
136            let len = bytes.len() as u32;
137            data.extend_from_slice(&len.to_le_bytes());
138            data.extend_from_slice(bytes);
139        } else {
140            data.push(0x00); // absence marker
141        }
142        // Action is serialized to JSON for consistent hashing
143        if let Ok(action_json) = serde_json::to_vec(&self.action) {
144            data.extend_from_slice(&action_json);
145        }
146        if let Ok(auth_json) = serde_json::to_vec(&self.authorization) {
147            data.extend_from_slice(&auth_json);
148        }
149        // Outcome: include success/failure indicator
150        data.push(u8::from(matches!(
151            self.outcome,
152            AuditOutcome::Success { .. }
153        )));
154        data.extend_from_slice(self.previous_hash.as_bytes());
155        data.extend_from_slice(self.runtime_key.as_bytes());
156        data
157    }
158
159    /// Compute the content hash of this entry.
160    #[must_use]
161    pub fn content_hash(&self) -> ContentHash {
162        ContentHash::hash(&self.signing_data())
163    }
164
165    /// Verify the entry's signature.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`AuditError::InvalidSignature`] if the signature does not match
170    /// the entry contents.
171    pub fn verify_signature(&self) -> AuditResult<()> {
172        let signing_data = self.signing_data();
173        self.runtime_key
174            .verify(&signing_data, &self.signature)
175            .map_err(|_| AuditError::InvalidSignature {
176                entry_id: self.id.to_string(),
177            })
178    }
179
180    /// Check if this entry follows another (chain linking).
181    #[must_use]
182    pub fn follows(&self, previous: &AuditEntry) -> bool {
183        self.previous_hash == previous.content_hash()
184    }
185}
186
187/// Actions that can be audited.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(tag = "type", rename_all = "snake_case")]
190pub enum AuditAction {
191    /// MCP tool was called.
192    McpToolCall {
193        /// Server name.
194        server: String,
195        /// Tool name.
196        tool: String,
197        /// Hash of the arguments (not the args themselves for privacy).
198        args_hash: ContentHash,
199    },
200
201    /// Capsule tool was called.
202    CapsuleToolCall {
203        /// Capsule ID.
204        capsule_id: String,
205        /// Tool name.
206        tool: String,
207        /// Hash of the arguments (not the args themselves for privacy).
208        args_hash: ContentHash,
209    },
210
211    /// MCP resource was read.
212    McpResourceRead {
213        /// Server name.
214        server: String,
215        /// Resource URI.
216        uri: String,
217    },
218
219    /// MCP prompt was retrieved.
220    McpPromptGet {
221        /// Server name.
222        server: String,
223        /// Prompt name.
224        name: String,
225    },
226
227    /// MCP elicitation (server requested user input).
228    McpElicitation {
229        /// Request ID.
230        request_id: String,
231        /// Schema type (text, select, confirm, etc.).
232        schema: String,
233    },
234
235    /// MCP URL elicitation (OAuth, payments).
236    McpUrlElicitation {
237        /// URL presented to user.
238        url: String,
239        /// Interaction type (oauth, payment, verification, custom).
240        interaction_type: String,
241    },
242
243    /// MCP sampling (server-initiated LLM call).
244    McpSampling {
245        /// Model used.
246        model: String,
247        /// Prompt token count.
248        prompt_tokens: usize,
249    },
250
251    /// File was read.
252    FileRead {
253        /// File path.
254        path: String,
255    },
256
257    /// File was written.
258    FileWrite {
259        /// File path.
260        path: String,
261        /// Hash of the written content.
262        content_hash: ContentHash,
263    },
264
265    /// File was deleted.
266    FileDelete {
267        /// File path.
268        path: String,
269    },
270
271    /// Capability token was created.
272    CapabilityCreated {
273        /// Token ID.
274        token_id: TokenId,
275        /// Resource pattern.
276        resource: String,
277        /// Permissions granted.
278        permissions: Vec<Permission>,
279        /// Token scope.
280        scope: ApprovalScope,
281    },
282
283    /// Capability token was revoked.
284    CapabilityRevoked {
285        /// Token ID.
286        token_id: TokenId,
287        /// Reason for revocation.
288        reason: String,
289    },
290
291    /// Approval was requested from the user.
292    ApprovalRequested {
293        /// Type of action being requested.
294        action_type: String,
295        /// Resource being accessed.
296        resource: String,
297        /// Risk level of the action.
298        risk_level: RiskLevel,
299    },
300
301    /// User granted approval.
302    ApprovalGranted {
303        /// What was approved.
304        action: String,
305        /// Resource being accessed.
306        resource: Option<String>,
307        /// Scope of approval.
308        scope: ApprovalScope,
309    },
310
311    /// User denied approval.
312    ApprovalDenied {
313        /// What was denied.
314        action: String,
315        /// Reason given.
316        reason: Option<String>,
317    },
318
319    /// Session started.
320    SessionStarted {
321        /// User ID (key ID bytes).
322        user_id: [u8; 8],
323        /// Platform the session started from.
324        platform: String,
325    },
326
327    /// Session ended.
328    SessionEnded {
329        /// Reason for ending.
330        reason: String,
331        /// Duration in seconds.
332        duration_secs: u64,
333    },
334
335    /// Context was summarized (messages evicted).
336    ContextSummarized {
337        /// Number of messages evicted.
338        evicted_count: usize,
339        /// Approximate tokens freed.
340        tokens_freed: usize,
341    },
342
343    /// LLM request was made.
344    LlmRequest {
345        /// Model used.
346        model: String,
347        /// Input token count.
348        input_tokens: usize,
349        /// Output token count.
350        output_tokens: usize,
351    },
352
353    /// Server was started.
354    ServerStarted {
355        /// Server name.
356        name: String,
357        /// Transport type.
358        transport: String,
359        /// Binary hash (if verified).
360        binary_hash: Option<ContentHash>,
361    },
362
363    /// Server was stopped.
364    ServerStopped {
365        /// Server name.
366        name: String,
367        /// Reason.
368        reason: String,
369    },
370
371    /// Elicitation request sent to user.
372    ElicitationSent {
373        /// Request ID.
374        request_id: String,
375        /// Server requesting.
376        server: String,
377        /// Type of elicitation.
378        elicitation_type: String,
379    },
380
381    /// Elicitation response received.
382    ElicitationReceived {
383        /// Request ID.
384        request_id: String,
385        /// Action taken (submit/cancel/dismiss).
386        action: String,
387    },
388
389    /// Security policy violation detected.
390    SecurityViolation {
391        /// Type of violation.
392        violation_type: String,
393        /// Details.
394        details: String,
395        /// Risk level.
396        risk_level: RiskLevel,
397    },
398
399    /// Sub-agent was spawned (parent→child linkage).
400    SubAgentSpawned {
401        /// Parent session ID.
402        parent_session_id: String,
403        /// Child session ID.
404        child_session_id: String,
405        /// Task description.
406        description: String,
407    },
408
409    /// Configuration was reloaded.
410    ConfigReloaded,
411}
412
413impl AuditAction {
414    /// Get a human-readable description of the action.
415    #[must_use]
416    pub fn description(&self) -> String {
417        match self {
418            Self::McpToolCall { server, tool, .. } => {
419                format!("Called tool {server}:{tool}")
420            },
421            Self::CapsuleToolCall {
422                capsule_id, tool, ..
423            } => {
424                format!("Called capsule tool {capsule_id}:{tool}")
425            },
426            Self::McpResourceRead { server, uri } => {
427                format!("Read resource {server}:{uri}")
428            },
429            Self::McpPromptGet { server, name } => {
430                format!("Got prompt {server}:{name}")
431            },
432            Self::McpElicitation { request_id, schema } => {
433                format!("Elicitation {request_id} ({schema})")
434            },
435            Self::McpUrlElicitation {
436                interaction_type, ..
437            } => {
438                format!("URL elicitation ({interaction_type})")
439            },
440            Self::McpSampling { model, .. } => {
441                format!("Sampling request to {model}")
442            },
443            Self::FileRead { path } => {
444                format!("Read file {path}")
445            },
446            Self::FileWrite { path, .. } => {
447                format!("Wrote file {path}")
448            },
449            Self::FileDelete { path } => {
450                format!("Deleted file {path}")
451            },
452            Self::CapabilityCreated { resource, .. } => {
453                format!("Created capability for {resource}")
454            },
455            Self::CapabilityRevoked { token_id, .. } => {
456                format!("Revoked capability {token_id}")
457            },
458            Self::ApprovalRequested {
459                action_type,
460                resource,
461                ..
462            } => {
463                format!("Approval requested: {action_type} on {resource}")
464            },
465            Self::ApprovalGranted { action, .. } => {
466                format!("Approved: {action}")
467            },
468            Self::ApprovalDenied { action, .. } => {
469                format!("Denied: {action}")
470            },
471            Self::SessionStarted { platform, .. } => {
472                format!("Session started via {platform}")
473            },
474            Self::SessionEnded { reason, .. } => {
475                format!("Session ended: {reason}")
476            },
477            Self::ContextSummarized { evicted_count, .. } => {
478                format!("Summarized {evicted_count} messages")
479            },
480            Self::LlmRequest { model, .. } => {
481                format!("LLM request to {model}")
482            },
483            Self::ServerStarted { name, .. } => {
484                format!("Started server {name}")
485            },
486            Self::ServerStopped { name, .. } => {
487                format!("Stopped server {name}")
488            },
489            Self::ElicitationSent { server, .. } => {
490                format!("Elicitation from {server}")
491            },
492            Self::ElicitationReceived { action, .. } => {
493                format!("Elicitation response: {action}")
494            },
495            Self::SecurityViolation { violation_type, .. } => {
496                format!("Security violation: {violation_type}")
497            },
498            Self::SubAgentSpawned { description, .. } => {
499                format!("Spawned sub-agent: {description}")
500            },
501            Self::ConfigReloaded => "Configuration reloaded".to_string(),
502        }
503    }
504}
505
506/// How an action was authorized.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(tag = "type", rename_all = "snake_case")]
509pub enum AuthorizationProof {
510    /// Authorized by a verified user message.
511    User {
512        /// User ID (key ID).
513        user_id: [u8; 8],
514        /// The message that triggered the action.
515        message_id: String,
516    },
517    /// Authorized by capability token.
518    Capability {
519        /// Token ID.
520        token_id: TokenId,
521        /// Token content hash.
522        token_hash: ContentHash,
523    },
524    /// Authorized by user approval.
525    UserApproval {
526        /// User ID (key ID).
527        user_id: [u8; 8],
528        /// Audit entry ID of the prior approval decision that authorized this
529        /// action. `None` when this entry IS the root approval decision
530        /// (i.e. the user just said "yes" — there is no earlier entry).
531        approval_entry_id: Option<AuditEntryId>,
532    },
533    /// No authorization required (low-risk operation).
534    NotRequired {
535        /// Reason no auth needed.
536        reason: String,
537    },
538    /// System-initiated action.
539    System {
540        /// Reason for system action.
541        reason: String,
542    },
543    /// Authorization was denied.
544    Denied {
545        /// Reason for denial.
546        reason: String,
547    },
548}
549
550/// Scope of an approval.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum ApprovalScope {
554    /// This one time only.
555    Once,
556    /// For the current session.
557    Session,
558    /// For the current workspace (persists beyond session).
559    Workspace,
560    /// Persistent (creates capability).
561    Always,
562}
563
564impl std::fmt::Display for ApprovalScope {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        match self {
567            Self::Once => write!(f, "once"),
568            Self::Session => write!(f, "session"),
569            Self::Workspace => write!(f, "workspace"),
570            Self::Always => write!(f, "always"),
571        }
572    }
573}
574
575/// Outcome of an audited action.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577#[serde(tag = "status", rename_all = "snake_case")]
578pub enum AuditOutcome {
579    /// Action succeeded.
580    Success {
581        /// Optional details.
582        details: Option<String>,
583    },
584    /// Action failed.
585    Failure {
586        /// Error message.
587        error: String,
588    },
589}
590
591impl AuditOutcome {
592    /// Create a success outcome.
593    #[must_use]
594    pub fn success() -> Self {
595        Self::Success { details: None }
596    }
597
598    /// Create a success outcome with details.
599    #[must_use]
600    pub fn success_with(details: impl Into<String>) -> Self {
601        Self::Success {
602            details: Some(details.into()),
603        }
604    }
605
606    /// Create a failure outcome.
607    #[must_use]
608    pub fn failure(error: impl Into<String>) -> Self {
609        Self::Failure {
610            error: error.into(),
611        }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use astrid_crypto::KeyPair;
619
620    fn test_keypair() -> KeyPair {
621        KeyPair::generate()
622    }
623
624    #[test]
625    fn test_entry_creation() {
626        let keypair = test_keypair();
627        let session_id = SessionId::new();
628
629        let entry = AuditEntry::create(
630            session_id,
631            AuditAction::SessionStarted {
632                user_id: keypair.key_id(),
633                platform: "cli".to_string(),
634            },
635            AuthorizationProof::System {
636                reason: "session start".to_string(),
637            },
638            AuditOutcome::success(),
639            ContentHash::zero(),
640            &keypair,
641        );
642
643        assert!(entry.verify_signature().is_ok());
644    }
645
646    #[test]
647    fn test_chain_linking() {
648        let keypair = test_keypair();
649        let session_id = SessionId::new();
650
651        let entry1 = AuditEntry::create(
652            session_id.clone(),
653            AuditAction::SessionStarted {
654                user_id: keypair.key_id(),
655                platform: "cli".to_string(),
656            },
657            AuthorizationProof::System {
658                reason: "session start".to_string(),
659            },
660            AuditOutcome::success(),
661            ContentHash::zero(),
662            &keypair,
663        );
664
665        let entry2 = AuditEntry::create(
666            session_id,
667            AuditAction::McpToolCall {
668                server: "test".to_string(),
669                tool: "tool".to_string(),
670                args_hash: ContentHash::hash(b"args"),
671            },
672            AuthorizationProof::NotRequired {
673                reason: "test".to_string(),
674            },
675            AuditOutcome::success(),
676            entry1.content_hash(),
677            &keypair,
678        );
679
680        assert!(entry2.follows(&entry1));
681        assert!(!entry1.follows(&entry2));
682    }
683
684    #[test]
685    fn test_signature_tampering() {
686        let keypair = test_keypair();
687        let session_id = SessionId::new();
688
689        let mut entry = AuditEntry::create(
690            session_id,
691            AuditAction::SessionStarted {
692                user_id: keypair.key_id(),
693                platform: "cli".to_string(),
694            },
695            AuthorizationProof::System {
696                reason: "session start".to_string(),
697            },
698            AuditOutcome::success(),
699            ContentHash::zero(),
700            &keypair,
701        );
702
703        // Valid signature
704        assert!(entry.verify_signature().is_ok());
705
706        // Tamper with the entry
707        entry.action = AuditAction::SessionEnded {
708            reason: "tampered".to_string(),
709            duration_secs: 0,
710        };
711
712        // Signature should now fail
713        assert!(entry.verify_signature().is_err());
714    }
715
716    #[test]
717    fn test_action_description() {
718        let action = AuditAction::McpToolCall {
719            server: "filesystem".to_string(),
720            tool: "read_file".to_string(),
721            args_hash: ContentHash::zero(),
722        };
723
724        assert!(action.description().contains("filesystem:read_file"));
725    }
726}