use astrid_capabilities::AuditEntryId;
use astrid_core::{Permission, RiskLevel, SessionId, Timestamp, TokenId};
use astrid_crypto::{ContentHash, KeyPair, PublicKey, Signature};
use serde::{Deserialize, Serialize};
use crate::error::{AuditError, AuditResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: AuditEntryId,
pub timestamp: Timestamp,
pub session_id: SessionId,
pub action: AuditAction,
pub authorization: AuthorizationProof,
pub outcome: AuditOutcome,
pub previous_hash: ContentHash,
pub runtime_key: PublicKey,
pub signature: Signature,
}
impl AuditEntry {
fn new_unsigned(
session_id: SessionId,
action: AuditAction,
authorization: AuthorizationProof,
outcome: AuditOutcome,
previous_hash: ContentHash,
runtime_key: PublicKey,
) -> Self {
Self {
id: AuditEntryId::new(),
timestamp: Timestamp::now(),
session_id,
action,
authorization,
outcome,
previous_hash,
runtime_key,
signature: Signature::from_bytes([0u8; 64]), }
}
#[must_use]
pub fn create(
session_id: SessionId,
action: AuditAction,
authorization: AuthorizationProof,
outcome: AuditOutcome,
previous_hash: ContentHash,
runtime_key: &KeyPair,
) -> Self {
let mut entry = Self::new_unsigned(
session_id,
action,
authorization,
outcome,
previous_hash,
runtime_key.export_public_key(),
);
let signing_data = entry.signing_data();
entry.signature = runtime_key.sign(&signing_data);
entry
}
#[must_use]
pub fn signing_data(&self) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(self.id.0.as_bytes());
data.extend_from_slice(&self.timestamp.0.timestamp().to_le_bytes());
data.extend_from_slice(self.session_id.0.as_bytes());
if let Ok(action_json) = serde_json::to_vec(&self.action) {
data.extend_from_slice(&action_json);
}
if let Ok(auth_json) = serde_json::to_vec(&self.authorization) {
data.extend_from_slice(&auth_json);
}
data.push(u8::from(matches!(
self.outcome,
AuditOutcome::Success { .. }
)));
data.extend_from_slice(self.previous_hash.as_bytes());
data.extend_from_slice(self.runtime_key.as_bytes());
data
}
#[must_use]
pub fn content_hash(&self) -> ContentHash {
ContentHash::hash(&self.signing_data())
}
pub fn verify_signature(&self) -> AuditResult<()> {
let signing_data = self.signing_data();
self.runtime_key
.verify(&signing_data, &self.signature)
.map_err(|_| AuditError::InvalidSignature {
entry_id: self.id.to_string(),
})
}
#[must_use]
pub fn follows(&self, previous: &AuditEntry) -> bool {
self.previous_hash == previous.content_hash()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuditAction {
McpToolCall {
server: String,
tool: String,
args_hash: ContentHash,
},
CapsuleToolCall {
capsule_id: String,
tool: String,
args_hash: ContentHash,
},
McpResourceRead {
server: String,
uri: String,
},
McpPromptGet {
server: String,
name: String,
},
McpElicitation {
request_id: String,
schema: String,
},
McpUrlElicitation {
url: String,
interaction_type: String,
},
McpSampling {
model: String,
prompt_tokens: usize,
},
FileRead {
path: String,
},
FileWrite {
path: String,
content_hash: ContentHash,
},
FileDelete {
path: String,
},
CapabilityCreated {
token_id: TokenId,
resource: String,
permissions: Vec<Permission>,
scope: ApprovalScope,
},
CapabilityRevoked {
token_id: TokenId,
reason: String,
},
ApprovalRequested {
action_type: String,
resource: String,
risk_level: RiskLevel,
},
ApprovalGranted {
action: String,
resource: Option<String>,
scope: ApprovalScope,
},
ApprovalDenied {
action: String,
reason: Option<String>,
},
SessionStarted {
user_id: [u8; 8],
frontend: String,
},
SessionEnded {
reason: String,
duration_secs: u64,
},
ContextSummarized {
evicted_count: usize,
tokens_freed: usize,
},
LlmRequest {
model: String,
input_tokens: usize,
output_tokens: usize,
},
ServerStarted {
name: String,
transport: String,
binary_hash: Option<ContentHash>,
},
ServerStopped {
name: String,
reason: String,
},
ElicitationSent {
request_id: String,
server: String,
elicitation_type: String,
},
ElicitationReceived {
request_id: String,
action: String,
},
SecurityViolation {
violation_type: String,
details: String,
risk_level: RiskLevel,
},
SubAgentSpawned {
parent_session_id: String,
child_session_id: String,
description: String,
},
ConfigReloaded,
}
impl AuditAction {
#[must_use]
pub fn description(&self) -> String {
match self {
Self::McpToolCall { server, tool, .. } => {
format!("Called tool {server}:{tool}")
},
Self::CapsuleToolCall {
capsule_id, tool, ..
} => {
format!("Called capsule tool {capsule_id}:{tool}")
},
Self::McpResourceRead { server, uri } => {
format!("Read resource {server}:{uri}")
},
Self::McpPromptGet { server, name } => {
format!("Got prompt {server}:{name}")
},
Self::McpElicitation { request_id, schema } => {
format!("Elicitation {request_id} ({schema})")
},
Self::McpUrlElicitation {
interaction_type, ..
} => {
format!("URL elicitation ({interaction_type})")
},
Self::McpSampling { model, .. } => {
format!("Sampling request to {model}")
},
Self::FileRead { path } => {
format!("Read file {path}")
},
Self::FileWrite { path, .. } => {
format!("Wrote file {path}")
},
Self::FileDelete { path } => {
format!("Deleted file {path}")
},
Self::CapabilityCreated { resource, .. } => {
format!("Created capability for {resource}")
},
Self::CapabilityRevoked { token_id, .. } => {
format!("Revoked capability {token_id}")
},
Self::ApprovalRequested {
action_type,
resource,
..
} => {
format!("Approval requested: {action_type} on {resource}")
},
Self::ApprovalGranted { action, .. } => {
format!("Approved: {action}")
},
Self::ApprovalDenied { action, .. } => {
format!("Denied: {action}")
},
Self::SessionStarted { frontend, .. } => {
format!("Session started via {frontend}")
},
Self::SessionEnded { reason, .. } => {
format!("Session ended: {reason}")
},
Self::ContextSummarized { evicted_count, .. } => {
format!("Summarized {evicted_count} messages")
},
Self::LlmRequest { model, .. } => {
format!("LLM request to {model}")
},
Self::ServerStarted { name, .. } => {
format!("Started server {name}")
},
Self::ServerStopped { name, .. } => {
format!("Stopped server {name}")
},
Self::ElicitationSent { server, .. } => {
format!("Elicitation from {server}")
},
Self::ElicitationReceived { action, .. } => {
format!("Elicitation response: {action}")
},
Self::SecurityViolation { violation_type, .. } => {
format!("Security violation: {violation_type}")
},
Self::SubAgentSpawned { description, .. } => {
format!("Spawned sub-agent: {description}")
},
Self::ConfigReloaded => "Configuration reloaded".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthorizationProof {
User {
user_id: [u8; 8],
message_id: String,
},
Capability {
token_id: TokenId,
token_hash: ContentHash,
},
UserApproval {
user_id: [u8; 8],
approval_entry_id: Option<AuditEntryId>,
},
NotRequired {
reason: String,
},
System {
reason: String,
},
Denied {
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalScope {
Once,
Session,
Workspace,
Always,
}
impl std::fmt::Display for ApprovalScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Once => write!(f, "once"),
Self::Session => write!(f, "session"),
Self::Workspace => write!(f, "workspace"),
Self::Always => write!(f, "always"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum AuditOutcome {
Success {
details: Option<String>,
},
Failure {
error: String,
},
}
impl AuditOutcome {
#[must_use]
pub fn success() -> Self {
Self::Success { details: None }
}
#[must_use]
pub fn success_with(details: impl Into<String>) -> Self {
Self::Success {
details: Some(details.into()),
}
}
#[must_use]
pub fn failure(error: impl Into<String>) -> Self {
Self::Failure {
error: error.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use astrid_crypto::KeyPair;
fn test_keypair() -> KeyPair {
KeyPair::generate()
}
#[test]
fn test_entry_creation() {
let keypair = test_keypair();
let session_id = SessionId::new();
let entry = AuditEntry::create(
session_id,
AuditAction::SessionStarted {
user_id: keypair.key_id(),
frontend: "cli".to_string(),
},
AuthorizationProof::System {
reason: "session start".to_string(),
},
AuditOutcome::success(),
ContentHash::zero(),
&keypair,
);
assert!(entry.verify_signature().is_ok());
}
#[test]
fn test_chain_linking() {
let keypair = test_keypair();
let session_id = SessionId::new();
let entry1 = AuditEntry::create(
session_id.clone(),
AuditAction::SessionStarted {
user_id: keypair.key_id(),
frontend: "cli".to_string(),
},
AuthorizationProof::System {
reason: "session start".to_string(),
},
AuditOutcome::success(),
ContentHash::zero(),
&keypair,
);
let entry2 = AuditEntry::create(
session_id,
AuditAction::McpToolCall {
server: "test".to_string(),
tool: "tool".to_string(),
args_hash: ContentHash::hash(b"args"),
},
AuthorizationProof::NotRequired {
reason: "test".to_string(),
},
AuditOutcome::success(),
entry1.content_hash(),
&keypair,
);
assert!(entry2.follows(&entry1));
assert!(!entry1.follows(&entry2));
}
#[test]
fn test_signature_tampering() {
let keypair = test_keypair();
let session_id = SessionId::new();
let mut entry = AuditEntry::create(
session_id,
AuditAction::SessionStarted {
user_id: keypair.key_id(),
frontend: "cli".to_string(),
},
AuthorizationProof::System {
reason: "session start".to_string(),
},
AuditOutcome::success(),
ContentHash::zero(),
&keypair,
);
assert!(entry.verify_signature().is_ok());
entry.action = AuditAction::SessionEnded {
reason: "tampered".to_string(),
duration_secs: 0,
};
assert!(entry.verify_signature().is_err());
}
#[test]
fn test_action_description() {
let action = AuditAction::McpToolCall {
server: "filesystem".to_string(),
tool: "read_file".to_string(),
args_hash: ContentHash::zero(),
};
assert!(action.description().contains("filesystem:read_file"));
}
}