Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7d99709
feat(audit): add entry kinds for HTTP exchanges, grants and capsule l…
MastaP Sep 28, 2026
16edc2f
feat(audit): attribute host-call entries to the capsule and hash file…
MastaP Sep 28, 2026
a202a8c
feat(audit): record kernel-mediated HTTP exchanges with a pre-commit
MastaP Sep 28, 2026
beaa7fa
feat(http): inject provider credentials host-side
MastaP Sep 28, 2026
3d6f3a0
feat(audit): record capsule tool calls and approval decisions
MastaP Sep 28, 2026
686a41e
feat(audit): record capability grants, revocations and grant-on-use
MastaP Sep 28, 2026
59bfe07
feat(audit): bind capsule installs and loads to their code identity
MastaP Sep 28, 2026
d9fcd8d
docs(changes): add the #1998 changelog fragment
MastaP Sep 28, 2026
e7b302c
fix(http): redact every revealed secret in request commitments
MastaP Sep 28, 2026
9510cd6
fix(audit): scope HTTP request numbers to the kernel run
MastaP Sep 28, 2026
828e761
fix(http): record https-only refusals as denied requests
MastaP Sep 28, 2026
81181dc
fix(audit): write HTTP completions and answered approvals durably
MastaP Sep 28, 2026
42f2c97
fix(audit): commit grant-on-use prompts before they are published
MastaP Sep 28, 2026
66cca94
fix(http): check every secret placeholder before omitting a header
MastaP Sep 28, 2026
1b15f65
docs(models): state what happens when a request entry cannot be written
MastaP Sep 28, 2026
7aa1f73
fix(audit): record a grant-on-use approval before the grant it causes
MastaP Sep 29, 2026
c0f4052
fix(audit): integrate coverage with ordered host recording
joshuajbouw Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions changes/1998.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Record the events that determine what an agent did on the signed audit log,
without changing the v1 entry format. Kernel-mediated HTTP requests are
pre-committed before they leave the host (method, host, redirect hop, BLAKE3
commitments to the path, headers and body with credentials redacted, and a
per-principal sequence number), and each is completed with the status,
provider request ids and a hash of the response body as it was read. Capsule
tool calls record hashes of their arguments and result. Approval prompts are
committed before they are shown, and every decision is linked to its prompt.
Capability tokens, principal grant changes and grant-on-use grants record
what was applied. Capsule installs and runtime loads bind the wasm hash,
manifest hash and engine profile. File, network, process and HTTP entries
name the capsule and wasm hash that acted, and file writes record the hash
of the written bytes. Install and upgrade hooks run by the daemon are
audited like running capsules.

A capsule can now name a manifest-declared secret in an HTTP header as
`{{secret:NAME}}`; the host injects the value so it never enters guest
memory or any audit commitment. See `docs/models.md`.

Closes #1998
503 changes: 221 additions & 282 deletions crates/astrid-audit/src/entry.rs

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions crates/astrid-audit/src/entry/coverage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Supporting types for the host-observed audit actions: the code identity a
//! host-call entry is attributed to, and provider request ids on HTTP
//! completions.

use astrid_crypto::ContentHash;
use serde::{Deserialize, Serialize};

/// Code identity of the capsule a host-observed entry is attributed to.
///
/// Stamped by the host from the capsule it loaded, never taken from the
/// guest: `capsule_id` is the manifest package name and `wasm_hash` is the
/// BLAKE3 hash of the wasm component the host verified before loading it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleActor {
/// Capsule id (manifest package name).
pub capsule_id: String,
/// BLAKE3 of the verified wasm component. `None` for capsules without a
/// wasm component.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm_hash: Option<ContentHash>,
}

/// A provider request id taken from an HTTP response header.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderRequestId {
/// Lower-case response header name (for example `x-request-id`).
pub header: String,
/// Header value, truncated to a bounded length.
pub value: String,
}
206 changes: 206 additions & 0 deletions crates/astrid-audit/src/entry/describe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//! Human-readable descriptions of audit actions.

use super::AuditAction;

impl AuditAction {
/// Describe the MCP-prefixed actions (tool/capsule call, resource,
/// prompt, elicitation, sampling). Returns `None` for non-MCP actions so
/// [`description`](Self::description) can fall through. Factored out to
/// keep `description` under the function-length lint.
fn describe_mcp(&self) -> Option<String> {
let s = 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}")
},
_ => return None,
};
Some(s)
}

/// Describe the host-call actions (file, network, process, HTTP).
/// Returns `None` for other actions.
fn describe_host_call(&self) -> Option<String> {
let s = match self {
Self::FileRead { path, .. } => format!("Read file {path}"),
Self::FileWrite { path, .. } => format!("Wrote file {path}"),
Self::FileDelete { path, .. } => format!("Deleted file {path}"),
Self::NetConnect { host, port, .. } => format!("Connected to {host}:{port}"),
Self::NetBind { addr, .. } => format!("Bound socket {addr}"),
Self::NetAccept {
local_addr,
peer_addr,
..
} => format!("Accepted connection from {peer_addr} on {local_addr}"),
Self::ProcessSpawn { command, .. } => format!("Spawned process {command}"),
Self::HostCallRun { calls } => format!("Recorded {} host calls", calls.count),
Self::HostCallLoss { calls, reason } => {
format!("Lost {} host calls ({reason})", calls.count)
},
Self::HostCallGap { epoch, reason, .. } => {
format!("Host-audit gap after lane {epoch} ({reason})")
},
Self::HostCallAdmitted { call } => format!("Admitted: {}", call.description()),
Self::HttpRequest {
sequence,
method,
host,
port,
..
} => format!("HTTP request #{sequence} {method} {host}:{port}"),
Self::HttpResponse {
sequence, status, ..
} => match status {
Some(status) => format!("HTTP response #{sequence} status {status}"),
None => format!("HTTP response #{sequence} without status"),
},
_ => return None,
};
Some(s)
}

/// Describe authority and code-identity changes (capabilities,
/// approvals, capsule install/load). Returns `None` for other actions.
fn describe_authority(&self) -> Option<String> {
let s = match self {
Self::CapabilityCreated { resource, .. } => {
format!("Created capability for {resource}")
},
Self::CapabilityRevoked { token_id, .. } => {
format!("Revoked capability {token_id}")
},
Self::CapabilityChanged {
target_principal,
kind,
via,
..
} => format!("Changed {kind} grants of {target_principal} via {via}"),
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::CapsuleInstalled {
capsule_id,
version,
..
} => format!("Installed capsule {capsule_id}@{version}"),
Self::CapsuleLoaded {
capsule_id,
version,
trigger,
..
} => format!("Loaded capsule {capsule_id}@{version} ({trigger})"),
_ => return None,
};
Some(s)
}

/// Get a human-readable description of the action.
///
/// Grouped actions are described by the `describe_*` helpers; each helper
/// returns `Some` for exactly the variants routed to it, so the fallbacks
/// are never taken — they keep the call total instead of panicking. Split
/// this way to stay under the function-length lint.
#[must_use]
pub fn description(&self) -> String {
match self {
Self::McpToolCall { .. }
| Self::CapsuleToolCall { .. }
| Self::McpResourceRead { .. }
| Self::McpPromptGet { .. }
| Self::McpElicitation { .. }
| Self::McpUrlElicitation { .. }
| Self::McpSampling { .. } => self.describe_mcp().unwrap_or_default(),
Self::FileRead { .. }
| Self::FileWrite { .. }
| Self::FileDelete { .. }
| Self::NetConnect { .. }
| Self::NetBind { .. }
| Self::NetAccept { .. }
| Self::ProcessSpawn { .. }
| Self::HostCallRun { .. }
| Self::HostCallLoss { .. }
| Self::HostCallGap { .. }
| Self::HostCallAdmitted { .. }
| Self::HttpRequest { .. }
| Self::HttpResponse { .. } => self.describe_host_call().unwrap_or_default(),
Self::CapabilityCreated { .. }
| Self::CapabilityRevoked { .. }
| Self::CapabilityChanged { .. }
| Self::ApprovalRequested { .. }
| Self::ApprovalGranted { .. }
| Self::ApprovalDenied { .. }
| Self::CapsuleInstalled { .. }
| Self::CapsuleLoaded { .. } => self.describe_authority().unwrap_or_default(),
Self::SessionStarted { platform, .. } => {
format!("Session started via {platform}")
},
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(),
Self::AdminRequest {
method,
required_capability,
target_principal,
params: _,
device_key_id: _,
} => match target_principal {
Some(target) => {
format!("Admin {method} on {target} (capability {required_capability})")
},
None => format!("Admin {method} (capability {required_capability})"),
},
}
}
}
Loading
Loading