Skip to main content

act_runtime/audit/
emit.rs

1//! The only module that calls `tracing` macros.
2//!
3//! Every value is passed as a typed field. Nothing here formats a human
4//! sentence — that is the layer's job (`render.rs`), and doing it at the
5//! emission site would collapse the OTLP export into one opaque attribute.
6
7use std::time::Duration;
8
9use tracing::field::Empty;
10
11use crate::audit::TARGET_AUDIT;
12use crate::audit::record::{
13    CapDecisionRecord, CeilingClassRecord, CredentialIssueRecord, Outcome, ToolCallStart, attr,
14    duration_ms,
15};
16
17/// Open the envelope span for one tool call. `act.outcome` and
18/// `act.duration_ms` are declared empty and filled by `finish_tool_call`.
19pub fn tool_call_span(start: &ToolCallStart) -> tracing::Span {
20    tracing::info_span!(
21        target: TARGET_AUDIT,
22        "act.tool_call",
23        { attr::COMPONENT_REF } = %start.component_ref,
24        { attr::COMPONENT_DIGEST } = %start.digest,
25        { attr::TOOL_NAME } = %start.tool,
26        { attr::TOOL_ARGS_SHA256 } = %start.args_sha256,
27        { attr::TOOL_ARGS } = start.args_json.as_deref().unwrap_or(""),
28        { attr::SESSION_ID } = start.session_id.as_deref().unwrap_or(""),
29        { attr::AGENT_ID } = start.agent_id.as_deref().unwrap_or(""),
30        { attr::REQUEST_ID } = %start.request_id,
31        { attr::TRACE_PARENT } = start.traceparent.as_deref().unwrap_or(""),
32        { attr::TRACE_STATE } = start.tracestate.as_deref().unwrap_or(""),
33        { attr::TRANSPORT } = %start.transport,
34        { attr::OUTCOME } = Empty,
35        { attr::DURATION_MS } = Empty,
36    )
37}
38
39/// Record the terminal fields on an open tool-call span. The layer flushes
40/// the rollup when the span closes, which happens when the caller drops it.
41pub fn finish_tool_call(span: &tracing::Span, outcome: Outcome, elapsed: Duration) {
42    span.record(attr::OUTCOME, tracing::field::display(outcome));
43    span.record(attr::DURATION_MS, duration_ms(elapsed));
44}
45
46/// Emit one capability decision as an event inside the current span.
47pub fn emit_cap_decision(r: &CapDecisionRecord) {
48    // `tracing::info!`'s `target: .., { fields }, args` arm treats a single
49    // leading brace-group as the *whole* field list, so a leading
50    // `{ attr::CONST } = val` field gets misread as that marker instead of
51    // one field. Wrapping every field below in this outer `{ }` is required
52    // — do not remove it (`tool_call_span` has no such arm, hence no wrap).
53    tracing::info!(
54        target: TARGET_AUDIT,
55        {
56            { attr::CAPABILITY_ID } = %r.cap_id,
57            { attr::RESOURCE_KEY } = %r.key,
58            { attr::RESOURCE_ACTION } = %r.action,
59            { attr::DECISION } = %r.decision,
60            { attr::POLICY_MODE } = %r.mode,
61            { attr::POLICY_ACTOR } = %r.actor,
62            { attr::POLICY_REASON } = r.reason.as_deref().unwrap_or(""),
63            { attr::POLICY_RULE } = r.rule.as_deref().unwrap_or(""),
64            { attr::NEVER_ROLLUP } = r.never_rollup,
65        },
66        "act.cap_decision",
67    );
68}
69
70/// Open the envelope span for one component instantiation. One span per
71/// component load; one `emit_ceiling_class` event per capability class inside
72/// it. Modelled exactly like `tool_call_span` / `emit_cap_decision` so the
73/// same layer machinery renders it and an OTLP exporter gets queryable
74/// per-class attributes instead of a pre-formatted sentence.
75pub fn instantiation_span(component_ref: &str, digest: &str) -> tracing::Span {
76    tracing::info_span!(
77        target: TARGET_AUDIT,
78        "act.instantiation",
79        { attr::COMPONENT_REF } = %component_ref,
80        { attr::COMPONENT_DIGEST } = %digest,
81    )
82}
83
84/// One resolved capability class, as seen at instantiation. Carries no
85/// decision — that is what distinguishes it from a `CapDecisionRecord` event
86/// at the layer, which decodes strictly by presence of `act.decision`.
87pub fn emit_ceiling_class(r: &CeilingClassRecord) {
88    tracing::info!(
89        target: TARGET_AUDIT,
90        {
91            { attr::CAPABILITY_ID } = %r.cap_id,
92            { attr::POLICY_MODE } = %r.mode,
93            { attr::CAPABILITY_DECLARED } = r.declared,
94            { attr::CONSENT_PROMPT_CHANNEL } = r.has_prompt_channel,
95        },
96        "act.ceiling_class",
97    );
98}
99
100/// One credential handed to a component. Carries no decision and no
101/// capability id, which is how the layer tells it apart from the two event
102/// shapes above — `act.credential.kind` is present on this event and on
103/// nothing else.
104///
105/// Emitted on the audit target, not this crate's default log target, and that
106/// is the point: `RUST_LOG` / `-v` must not be able to hide the moment a
107/// secret crossed into a sandbox. Only `--no-audit` silences it.
108pub fn emit_credential_issue(r: &CredentialIssueRecord) {
109    tracing::info!(
110        target: TARGET_AUDIT,
111        {
112            { attr::COMPONENT_REF } = %r.component_ref,
113            { attr::SESSION_ID } = %r.session_id,
114            { attr::RESOURCE_KEY } = %r.key,
115            { attr::CREDENTIAL_KIND } = %r.kind,
116        },
117        "act.credential_issue",
118    );
119}
120
121#[cfg(test)]
122mod tests {
123    use std::sync::{Arc, Mutex};
124
125    use tracing_subscriber::layer::{Context, Layer};
126    use tracing_subscriber::prelude::*;
127    use tracing_subscriber::registry::LookupSpan;
128
129    use super::*;
130    use crate::audit::record::*;
131
132    /// Captures `(field_name, value)` pairs off every event on the audit target.
133    #[derive(Clone, Default)]
134    struct Capture(Arc<Mutex<Vec<(String, String)>>>);
135
136    impl tracing::field::Visit for Capture {
137        fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
138            self.0
139                .lock()
140                .unwrap()
141                .push((f.name().to_string(), format!("{v:?}")));
142        }
143        fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
144            self.0
145                .lock()
146                .unwrap()
147                .push((f.name().to_string(), v.to_string()));
148        }
149    }
150
151    impl<S> Layer<S> for Capture
152    where
153        S: tracing::Subscriber + for<'a> LookupSpan<'a>,
154    {
155        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
156            if event.metadata().target() == crate::audit::TARGET_AUDIT {
157                let mut v = self.clone();
158                event.record(&mut v);
159            }
160        }
161    }
162
163    // Every field gets its own value, and no two fields share a value, so a
164    // copy-paste mapping swap (e.g. `r.actor` written to `attr::POLICY_MODE`
165    // and `r.mode` to `attr::POLICY_ACTOR`) fails an exact-match assertion
166    // instead of silently passing.
167    fn cap_record() -> CapDecisionRecord {
168        CapDecisionRecord {
169            cap_id: "wasi:filesystem".into(),
170            key: "/data/app.db".into(),
171            action: "read".into(),
172            decision: Decision4::Allow,
173            mode: "allowlist".into(),
174            actor: Actor::Static,
175            reason: Some("no-exception".into()),
176            rule: Some("/data/**".into()),
177            never_rollup: false,
178        }
179    }
180
181    #[test]
182    fn cap_decision_emits_every_frozen_field_name() {
183        let cap = Capture::default();
184        let sink = cap.clone();
185        let sub = tracing_subscriber::registry().with(cap);
186
187        tracing::subscriber::with_default(sub, || {
188            emit_cap_decision(&cap_record());
189        });
190
191        let got = sink.0.lock().unwrap().clone();
192        let names: Vec<&str> = got.iter().map(|(n, _)| n.as_str()).collect();
193        for expected in [
194            attr::CAPABILITY_ID,
195            attr::RESOURCE_KEY,
196            attr::RESOURCE_ACTION,
197            attr::DECISION,
198            attr::POLICY_MODE,
199            attr::POLICY_ACTOR,
200            attr::POLICY_REASON,
201            attr::POLICY_RULE,
202            attr::NEVER_ROLLUP,
203        ] {
204            assert!(
205                names.contains(&expected),
206                "missing field {expected} in {names:?}"
207            );
208        }
209    }
210
211    #[test]
212    fn cap_decision_emits_values_not_a_rendered_sentence() {
213        // Guards the global constraint: no field may carry a pre-formatted
214        // human string, because the OTLP exporter would then see one opaque blob.
215        let cap = Capture::default();
216        let sink = cap.clone();
217        let sub = tracing_subscriber::registry().with(cap);
218
219        tracing::subscriber::with_default(sub, || {
220            emit_cap_decision(&cap_record());
221        });
222
223        let got = sink.0.lock().unwrap().clone();
224        let by = |n: &str| {
225            got.iter()
226                .find(|(k, _)| k == n)
227                .map(|(_, v)| v.clone())
228                .unwrap_or_default()
229        };
230        // Pins the complete eight-field map, one assertion per field, each
231        // against a fixture value found nowhere else in the record — a
232        // mapping swap between any two fields fails at least one of these.
233        assert_eq!(by(attr::CAPABILITY_ID), "wasi:filesystem");
234        assert_eq!(by(attr::RESOURCE_KEY), "/data/app.db");
235        assert_eq!(by(attr::RESOURCE_ACTION), "read");
236        assert_eq!(by(attr::DECISION), "allow");
237        assert_eq!(by(attr::POLICY_MODE), "allowlist");
238        assert_eq!(by(attr::POLICY_ACTOR), "static");
239        assert_eq!(by(attr::POLICY_REASON), "no-exception");
240        assert_eq!(by(attr::POLICY_RULE), "/data/**");
241        assert_eq!(by(attr::NEVER_ROLLUP), "false");
242        // The message field must be a stable event name, never a sentence.
243        assert!(!by("message").contains("/data/app.db"));
244    }
245}