use std::time::Duration;
use tracing::field::Empty;
use crate::audit::TARGET_AUDIT;
use crate::audit::record::{
CapDecisionRecord, CeilingClassRecord, CredentialIssueRecord, Outcome, ToolCallStart, attr,
duration_ms,
};
pub fn tool_call_span(start: &ToolCallStart) -> tracing::Span {
tracing::info_span!(
target: TARGET_AUDIT,
"act.tool_call",
{ attr::COMPONENT_REF } = %start.component_ref,
{ attr::COMPONENT_DIGEST } = %start.digest,
{ attr::TOOL_NAME } = %start.tool,
{ attr::TOOL_ARGS_SHA256 } = %start.args_sha256,
{ attr::TOOL_ARGS } = start.args_json.as_deref().unwrap_or(""),
{ attr::SESSION_ID } = start.session_id.as_deref().unwrap_or(""),
{ attr::AGENT_ID } = start.agent_id.as_deref().unwrap_or(""),
{ attr::REQUEST_ID } = %start.request_id,
{ attr::TRACE_PARENT } = start.traceparent.as_deref().unwrap_or(""),
{ attr::TRACE_STATE } = start.tracestate.as_deref().unwrap_or(""),
{ attr::TRANSPORT } = %start.transport,
{ attr::OUTCOME } = Empty,
{ attr::DURATION_MS } = Empty,
)
}
pub fn finish_tool_call(span: &tracing::Span, outcome: Outcome, elapsed: Duration) {
span.record(attr::OUTCOME, tracing::field::display(outcome));
span.record(attr::DURATION_MS, duration_ms(elapsed));
}
pub fn emit_cap_decision(r: &CapDecisionRecord) {
tracing::info!(
target: TARGET_AUDIT,
{
{ attr::CAPABILITY_ID } = %r.cap_id,
{ attr::RESOURCE_KEY } = %r.key,
{ attr::RESOURCE_ACTION } = %r.action,
{ attr::DECISION } = %r.decision,
{ attr::POLICY_MODE } = %r.mode,
{ attr::POLICY_ACTOR } = %r.actor,
{ attr::POLICY_REASON } = r.reason.as_deref().unwrap_or(""),
{ attr::POLICY_RULE } = r.rule.as_deref().unwrap_or(""),
{ attr::NEVER_ROLLUP } = r.never_rollup,
},
"act.cap_decision",
);
}
pub fn instantiation_span(component_ref: &str, digest: &str) -> tracing::Span {
tracing::info_span!(
target: TARGET_AUDIT,
"act.instantiation",
{ attr::COMPONENT_REF } = %component_ref,
{ attr::COMPONENT_DIGEST } = %digest,
)
}
pub fn emit_ceiling_class(r: &CeilingClassRecord) {
tracing::info!(
target: TARGET_AUDIT,
{
{ attr::CAPABILITY_ID } = %r.cap_id,
{ attr::POLICY_MODE } = %r.mode,
{ attr::CAPABILITY_DECLARED } = r.declared,
{ attr::CONSENT_PROMPT_CHANNEL } = r.has_prompt_channel,
},
"act.ceiling_class",
);
}
pub fn emit_credential_issue(r: &CredentialIssueRecord) {
tracing::info!(
target: TARGET_AUDIT,
{
{ attr::COMPONENT_REF } = %r.component_ref,
{ attr::SESSION_ID } = %r.session_id,
{ attr::RESOURCE_KEY } = %r.key,
{ attr::CREDENTIAL_KIND } = %r.kind,
},
"act.credential_issue",
);
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan;
use super::*;
use crate::audit::record::*;
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<(String, String)>>>);
impl tracing::field::Visit for Capture {
fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
self.0
.lock()
.unwrap()
.push((f.name().to_string(), format!("{v:?}")));
}
fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
self.0
.lock()
.unwrap()
.push((f.name().to_string(), v.to_string()));
}
}
impl<S> Layer<S> for Capture
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() == crate::audit::TARGET_AUDIT {
let mut v = self.clone();
event.record(&mut v);
}
}
}
fn cap_record() -> CapDecisionRecord {
CapDecisionRecord {
cap_id: "wasi:filesystem".into(),
key: "/data/app.db".into(),
action: "read".into(),
decision: Decision4::Allow,
mode: "allowlist".into(),
actor: Actor::Static,
reason: Some("no-exception".into()),
rule: Some("/data/**".into()),
never_rollup: false,
}
}
#[test]
fn cap_decision_emits_every_frozen_field_name() {
let cap = Capture::default();
let sink = cap.clone();
let sub = tracing_subscriber::registry().with(cap);
tracing::subscriber::with_default(sub, || {
emit_cap_decision(&cap_record());
});
let got = sink.0.lock().unwrap().clone();
let names: Vec<&str> = got.iter().map(|(n, _)| n.as_str()).collect();
for expected in [
attr::CAPABILITY_ID,
attr::RESOURCE_KEY,
attr::RESOURCE_ACTION,
attr::DECISION,
attr::POLICY_MODE,
attr::POLICY_ACTOR,
attr::POLICY_REASON,
attr::POLICY_RULE,
attr::NEVER_ROLLUP,
] {
assert!(
names.contains(&expected),
"missing field {expected} in {names:?}"
);
}
}
#[test]
fn cap_decision_emits_values_not_a_rendered_sentence() {
let cap = Capture::default();
let sink = cap.clone();
let sub = tracing_subscriber::registry().with(cap);
tracing::subscriber::with_default(sub, || {
emit_cap_decision(&cap_record());
});
let got = sink.0.lock().unwrap().clone();
let by = |n: &str| {
got.iter()
.find(|(k, _)| k == n)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
assert_eq!(by(attr::CAPABILITY_ID), "wasi:filesystem");
assert_eq!(by(attr::RESOURCE_KEY), "/data/app.db");
assert_eq!(by(attr::RESOURCE_ACTION), "read");
assert_eq!(by(attr::DECISION), "allow");
assert_eq!(by(attr::POLICY_MODE), "allowlist");
assert_eq!(by(attr::POLICY_ACTOR), "static");
assert_eq!(by(attr::POLICY_REASON), "no-exception");
assert_eq!(by(attr::POLICY_RULE), "/data/**");
assert_eq!(by(attr::NEVER_ROLLUP), "false");
assert!(!by("message").contains("/data/app.db"));
}
}