1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEntry {
17 pub id: AuditEntryId,
19 pub timestamp: Timestamp,
21 pub session_id: SessionId,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub principal: Option<astrid_core::PrincipalId>,
27 pub action: AuditAction,
29 pub authorization: AuthorizationProof,
31 pub outcome: AuditOutcome,
33 pub previous_hash: ContentHash,
35 pub runtime_key: PublicKey,
37 pub signature: Signature,
39}
40
41impl AuditEntry {
42 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]), }
63 }
64
65 #[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 #[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 #[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 if let Some(ref p) = self.principal {
132 let bytes = p.as_str().as_bytes();
133 data.push(0xFF); #[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); }
142 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 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 #[must_use]
161 pub fn content_hash(&self) -> ContentHash {
162 ContentHash::hash(&self.signing_data())
163 }
164
165 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 #[must_use]
182 pub fn follows(&self, previous: &AuditEntry) -> bool {
183 self.previous_hash == previous.content_hash()
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(tag = "type", rename_all = "snake_case")]
190pub enum AuditAction {
191 McpToolCall {
193 server: String,
195 tool: String,
197 args_hash: ContentHash,
199 },
200
201 CapsuleToolCall {
203 capsule_id: String,
205 tool: String,
207 args_hash: ContentHash,
209 },
210
211 McpResourceRead {
213 server: String,
215 uri: String,
217 },
218
219 McpPromptGet {
221 server: String,
223 name: String,
225 },
226
227 McpElicitation {
229 request_id: String,
231 schema: String,
233 },
234
235 McpUrlElicitation {
237 url: String,
239 interaction_type: String,
241 },
242
243 McpSampling {
245 model: String,
247 prompt_tokens: usize,
249 },
250
251 FileRead {
253 path: String,
255 },
256
257 FileWrite {
259 path: String,
261 content_hash: ContentHash,
263 },
264
265 FileDelete {
267 path: String,
269 },
270
271 CapabilityCreated {
273 token_id: TokenId,
275 resource: String,
277 permissions: Vec<Permission>,
279 scope: ApprovalScope,
281 },
282
283 CapabilityRevoked {
285 token_id: TokenId,
287 reason: String,
289 },
290
291 ApprovalRequested {
293 action_type: String,
295 resource: String,
297 risk_level: RiskLevel,
299 },
300
301 ApprovalGranted {
303 action: String,
305 resource: Option<String>,
307 scope: ApprovalScope,
309 },
310
311 ApprovalDenied {
313 action: String,
315 reason: Option<String>,
317 },
318
319 SessionStarted {
321 user_id: [u8; 8],
323 platform: String,
325 },
326
327 SessionEnded {
329 reason: String,
331 duration_secs: u64,
333 },
334
335 ContextSummarized {
337 evicted_count: usize,
339 tokens_freed: usize,
341 },
342
343 LlmRequest {
345 model: String,
347 input_tokens: usize,
349 output_tokens: usize,
351 },
352
353 ServerStarted {
355 name: String,
357 transport: String,
359 binary_hash: Option<ContentHash>,
361 },
362
363 ServerStopped {
365 name: String,
367 reason: String,
369 },
370
371 ElicitationSent {
373 request_id: String,
375 server: String,
377 elicitation_type: String,
379 },
380
381 ElicitationReceived {
383 request_id: String,
385 action: String,
387 },
388
389 SecurityViolation {
391 violation_type: String,
393 details: String,
395 risk_level: RiskLevel,
397 },
398
399 SubAgentSpawned {
401 parent_session_id: String,
403 child_session_id: String,
405 description: String,
407 },
408
409 ConfigReloaded,
411}
412
413impl AuditAction {
414 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(tag = "type", rename_all = "snake_case")]
509pub enum AuthorizationProof {
510 User {
512 user_id: [u8; 8],
514 message_id: String,
516 },
517 Capability {
519 token_id: TokenId,
521 token_hash: ContentHash,
523 },
524 UserApproval {
526 user_id: [u8; 8],
528 approval_entry_id: Option<AuditEntryId>,
532 },
533 NotRequired {
535 reason: String,
537 },
538 System {
540 reason: String,
542 },
543 Denied {
545 reason: String,
547 },
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum ApprovalScope {
554 Once,
556 Session,
558 Workspace,
560 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#[derive(Debug, Clone, Serialize, Deserialize)]
577#[serde(tag = "status", rename_all = "snake_case")]
578pub enum AuditOutcome {
579 Success {
581 details: Option<String>,
583 },
584 Failure {
586 error: String,
588 },
589}
590
591impl AuditOutcome {
592 #[must_use]
594 pub fn success() -> Self {
595 Self::Success { details: None }
596 }
597
598 #[must_use]
600 pub fn success_with(details: impl Into<String>) -> Self {
601 Self::Success {
602 details: Some(details.into()),
603 }
604 }
605
606 #[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 assert!(entry.verify_signature().is_ok());
705
706 entry.action = AuditAction::SessionEnded {
708 reason: "tampered".to_string(),
709 duration_secs: 0,
710 };
711
712 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}