1use 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
17pub 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
39pub 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
46pub fn emit_cap_decision(r: &CapDecisionRecord) {
48 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
70pub 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
84pub 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
100pub 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 #[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 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 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 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 assert!(!by("message").contains("/data/app.db"));
244 }
245}