Skip to main content

act_runtime/audit/
mod.rs

1//! Structured audit trail for the ACT host.
2//!
3//! Two record kinds mirror the span/event split that OTLP wants: a tool call
4//! is a span, and the capability decisions it triggers are events inside it.
5
6pub mod emit;
7pub mod layer;
8pub mod record;
9pub mod render;
10
11pub use emit::{
12    emit_cap_decision, emit_ceiling_class, emit_credential_issue, finish_tool_call,
13    instantiation_span, tool_call_span,
14};
15pub use layer::{AuditLayer, Detail};
16pub use record::{
17    CapDecisionRecord, CeilingClassRecord, CredentialIssueRecord, Decision4, Outcome,
18    ToolCallStart, Transport, sha256_hex,
19};
20
21/// Target for host-authored audit records. The audit layer's filter is pinned
22/// to this and nothing else.
23pub const TARGET_AUDIT: &str = "act::audit";
24
25/// Target reserved for guest-emitted telemetry (`wasi:otel`, deferred — see
26/// the design doc §4.2). Guest events are untrusted input and must never
27/// reach the audit stream, so they are kept on a separate target that the
28/// audit layer's filter structurally excludes.
29pub const TARGET_GUEST: &str = "act::guest";
30
31/// A `fmt`-layer filter that lets ordinary logs through and drops both audit
32/// streams.
33///
34/// A host that installs its own `fmt` layer alongside [`AuditLayer`] needs
35/// this, or every audit record is printed twice — once as a raw tracing event
36/// with all its typed fields, once rendered. `act::guest` is excluded for a
37/// second reason: guest telemetry is untrusted input and has no business in
38/// the operator's log at all.
39pub fn fmt_filter<S>(
40    env_filter: tracing_subscriber::EnvFilter,
41) -> impl tracing_subscriber::layer::Filter<S> {
42    use tracing_subscriber::filter::{FilterExt, filter_fn};
43    env_filter.and(filter_fn(|meta: &tracing::Metadata<'_>| {
44        meta.target() != crate::audit::TARGET_AUDIT && meta.target() != crate::audit::TARGET_GUEST
45    }))
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn fmt_filter_excludes_audit_and_guest_targets() {
54        // Pins the fmt layer's filter behaviour without touching the real
55        // subscriber or stderr: a capturing layer records every target that
56        // makes it through `fmt_filter`, and only the ordinary one should.
57        use std::sync::{Arc, Mutex};
58        use tracing_subscriber::layer::{Context, Layer};
59        use tracing_subscriber::prelude::*;
60        use tracing_subscriber::registry::LookupSpan;
61
62        #[derive(Clone, Default)]
63        struct Capture(Arc<Mutex<Vec<String>>>);
64
65        impl<S> Layer<S> for Capture
66        where
67            S: tracing::Subscriber + for<'a> LookupSpan<'a>,
68        {
69            fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
70                self.0
71                    .lock()
72                    .unwrap()
73                    .push(event.metadata().target().to_string());
74            }
75        }
76
77        let cap = Capture::default();
78        let sink = cap.clone();
79        let env_filter: tracing_subscriber::EnvFilter = "act=info".parse().unwrap();
80        let sub = tracing_subscriber::registry().with(cap.with_filter(fmt_filter(env_filter)));
81
82        tracing::subscriber::with_default(sub, || {
83            tracing::info!(target: TARGET_AUDIT, "audit event");
84            tracing::info!(target: TARGET_GUEST, "guest event");
85            tracing::info!(target: "act::runtime", "ordinary event");
86        });
87
88        let got = sink.0.lock().unwrap().clone();
89        assert_eq!(got, vec!["act::runtime".to_string()]);
90    }
91}