act_runtime/audit/record.rs
1//! The audit record types. These are field carriers: `emit.rs` is the only
2//! module that turns them into `tracing` events.
3
4use std::fmt;
5use std::time::Duration;
6
7use sha2::{Digest, Sha256};
8
9/// Frozen attribute names. Every emitted field uses one of these constants.
10///
11/// These are a public contract — the OTLP exporter maps them straight onto
12/// span/event attributes, so renaming one invalidates existing dashboards.
13/// OpenTelemetry semantic conventions are used where one exists; everything
14/// else lives under the `act.*` namespace.
15pub mod attr {
16 pub const COMPONENT_REF: &str = "act.component.ref";
17 pub const COMPONENT_DIGEST: &str = "act.component.digest";
18 pub const TOOL_NAME: &str = "act.tool.name";
19 pub const TOOL_ARGS_SHA256: &str = "act.tool.args_sha256";
20 /// Full tool-argument values, only present when `--audit-args` is set.
21 /// `TOOL_ARGS_SHA256` above is still emitted alongside it — this field
22 /// widens the envelope, it never replaces the digest. Session args are
23 /// never carried by either field: `ToolCallStart` has no such member.
24 pub const TOOL_ARGS: &str = "act.tool.args";
25 pub const SESSION_ID: &str = "act.session.id";
26 // Caller and call identity. Key names come from ACT-CONSTANTS.md §5,
27 // which already reserves std:agent-id / std:request-id / std:traceparent
28 // / std:tracestate — this host is the first reader of any of them.
29 pub const AGENT_ID: &str = "act.agent.id";
30 pub const REQUEST_ID: &str = "act.request.id";
31 pub const TRACE_PARENT: &str = "act.trace.parent";
32 pub const TRACE_STATE: &str = "act.trace.state";
33 pub const TRANSPORT: &str = "act.transport";
34 pub const OUTCOME: &str = "act.outcome";
35 pub const DURATION_MS: &str = "act.duration_ms";
36 pub const CAPABILITY_ID: &str = "act.capability.id";
37 pub const RESOURCE_KEY: &str = "act.resource.key";
38 pub const RESOURCE_ACTION: &str = "act.resource.action";
39 pub const DECISION: &str = "act.decision";
40 pub const POLICY_MODE: &str = "act.policy.mode";
41 pub const POLICY_ACTOR: &str = "act.policy.actor";
42 pub const POLICY_REASON: &str = "act.policy.reason";
43 pub const POLICY_RULE: &str = "act.policy.rule";
44 /// Whether the component declared this capability class in `act.toml`.
45 pub const CAPABILITY_DECLARED: &str = "act.capability.declared";
46 /// Whether this decision must never fold into the per-call rollup, even
47 /// when it resolves to `Allow`. Set for `act:consent` decisions — see
48 /// `CapDecisionRecord::never_rollup`.
49 pub const NEVER_ROLLUP: &str = "act.decision.never_rollup";
50 /// The `kind` string of a credential that was handed to a component —
51 /// `std:fields` for everything this host writes, since a credential is a
52 /// set of named fields and nothing else (design §3.2). Recorded because it
53 /// is a required member of the published WIT `secret` record and a store
54 /// may hold one written elsewhere. Non-secret by construction: it names a
55 /// shape, never a value.
56 pub const CREDENTIAL_KIND: &str = "act.credential.kind";
57 /// Whether this run has any channel that can answer an interactive `ask`
58 /// prompt at all (a real TTY, or an MCP client offering elicitation) —
59 /// as opposed to headless / ACT-HTTP, where every `ask` decision
60 /// degrades to deny before a human is ever involved. A per-run fact,
61 /// repeated on every `act.ceiling_class` event so the layer never has to
62 /// infer it from anything but typed fields.
63 pub const CONSENT_PROMPT_CHANNEL: &str = "act.consent.prompt_channel";
64}
65
66/// Which transport dispatched the call.
67///
68/// `#[non_exhaustive]`: hosts embedding this crate serve over transports the
69/// CLI has no name for, and adding one for them must not be a breaking change.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71#[non_exhaustive]
72pub enum Transport {
73 #[default]
74 Cli,
75 Mcp,
76}
77
78impl fmt::Display for Transport {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(match self {
81 Transport::Cli => "cli",
82 Transport::Mcp => "mcp",
83 })
84 }
85}
86
87/// How the tool call ended.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum Outcome {
90 Ok,
91 /// The component returned a `tool-event::error` or an `err` result.
92 ToolError,
93 /// The host failed to run the call at all.
94 HostError,
95 /// Not yet wired up: the host drops the stream handle and wasmtime
96 /// unwinds on cancellation, but that path doesn't record this outcome
97 /// yet. Kept — with its `Display` arm and the assertion below — to
98 /// record the intent for when it is.
99 #[allow(dead_code)]
100 Cancelled,
101}
102
103impl fmt::Display for Outcome {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(match self {
106 Outcome::Ok => "ok",
107 Outcome::ToolError => "tool-error",
108 Outcome::HostError => "host-error",
109 Outcome::Cancelled => "cancelled",
110 })
111 }
112}
113
114/// The resolved verdict. Widens `act_policy::Decision`: the classifier is
115/// three-valued, but `Ask` settles into an allow or a deny once the operator
116/// (or the degrade-to-deny rule) answers.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Decision4 {
119 Allow,
120 Deny,
121 AskAllow,
122 AskDeny,
123}
124
125impl Decision4 {
126 /// True when this record must print the moment it resolves rather than
127 /// being folded into the per-call rollup.
128 pub fn is_exception(&self) -> bool {
129 !matches!(self, Decision4::Allow)
130 }
131}
132
133impl fmt::Display for Decision4 {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 f.write_str(match self {
136 Decision4::Allow => "allow",
137 Decision4::Deny => "deny",
138 Decision4::AskAllow => "ask-allow",
139 Decision4::AskDeny => "ask-deny",
140 })
141 }
142}
143
144/// Who decided.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum Actor {
147 /// Resolved from the static ceiling × grant intersection.
148 Static,
149 /// A human answered an `ask` prompt.
150 User,
151 /// An external policy engine decided (toolserver tier; unused in the CLI).
152 Policy,
153}
154
155impl fmt::Display for Actor {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 f.write_str(match self {
158 Actor::Static => "static",
159 Actor::User => "user",
160 Actor::Policy => "policy",
161 })
162 }
163}
164
165/// The fields known when a tool call begins. Outcome and duration are
166/// recorded onto the span when it finishes.
167#[derive(Debug, Clone)]
168pub struct ToolCallStart {
169 pub component_ref: String,
170 pub digest: String,
171 pub tool: String,
172 pub args_sha256: String,
173 /// Full arguments rendered as JSON, present only when `--audit-args` is
174 /// set. `None` by default, in which case only `args_sha256` is emitted —
175 /// this is the field that keeps the default path a digest, never values.
176 pub args_json: Option<String>,
177 pub session_id: Option<String>,
178 pub transport: Transport,
179 /// `std:agent-id` — informational caller identity, never a principal.
180 pub agent_id: Option<String>,
181 /// `std:request-id`, or a host-generated id. Always present, so an
182 /// operator can always join one audit line to one client log line.
183 pub request_id: String,
184 /// `std:traceparent` (W3C Trace Context) as received.
185 pub traceparent: Option<String>,
186 /// `std:tracestate` as received.
187 pub tracestate: Option<String>,
188}
189
190/// One capability decision, emitted as an event inside the tool-call span.
191#[derive(Debug, Clone)]
192pub struct CapDecisionRecord {
193 pub cap_id: String,
194 pub key: String,
195 pub action: String,
196 pub decision: Decision4,
197 pub mode: String,
198 pub actor: Actor,
199 pub reason: Option<String>,
200 /// The ceiling rule that matched, when the provider can attribute one.
201 /// Drives rollup grouping (Task 2).
202 pub rule: Option<String>,
203 /// True for a decision on a *semantic* class (`act:consent`), which must
204 /// never fold into the per-call rollup even when it resolves to `Allow`.
205 ///
206 /// A physical class's rollup is right for what it is: nobody wants a
207 /// line per filesystem `read`. A semantic class is the opposite — there
208 /// are few of them, each is a distinct, consequential act (`DROP
209 /// DATABASE analytics`), and *which subject* is the whole content of the
210 /// decision. Rolling one into `db:drop: 1 request` throws away the one
211 /// fact the line exists to carry, the same way folding a credential
212 /// issue into a count would (see `render_credential_issue`'s doc, which
213 /// states the identical rule for that record).
214 ///
215 /// An explicit flag, not `action == "request"`: `consent::gate::ACTION`
216 /// is a private constant, and string-matching it across the module
217 /// boundary between `consent::gate` and `audit::layer` is fragile —
218 /// renaming or repurposing the action string would silently start
219 /// folding consent decisions again with no test failing until someone
220 /// noticed the missing audit line.
221 ///
222 /// `false` for every physical-class decision (fs/http/sockets/
223 /// credentials); `true` only where `consent::gate::ConsentGate::decide`
224 /// sets it before emitting.
225 pub never_rollup: bool,
226}
227
228impl CapDecisionRecord {
229 /// A statically-resolved decision (ceiling x grant, no human involved).
230 /// Shared by every capability class so the record shape cannot drift
231 /// between providers.
232 pub fn statik(
233 cap_id: &str,
234 key: &str,
235 action: &str,
236 decision: Decision4,
237 mode: &str,
238 rule: Option<String>,
239 ) -> Self {
240 Self::statik_with_reason(cap_id, key, action, decision, mode, rule, None)
241 }
242
243 /// `statik`, but lets the caller override the default `Deny` reason
244 /// ("outside ceiling"). Some decision points deny for a reason other
245 /// than "the operation didn't match the ceiling" — a redirect hop
246 /// leaving the allowed host, a DNS-resolved address landing in a
247 /// deny-CIDR — and an operator reading "outside ceiling" for both would
248 /// not be able to tell them apart from an ordinary allow/deny-list
249 /// mismatch. `None` reproduces `statik`'s default exactly. Still the
250 /// same shared shape and still no per-class builder: any capability
251 /// class can call this, not just HTTP.
252 pub fn statik_with_reason(
253 cap_id: &str,
254 key: &str,
255 action: &str,
256 decision: Decision4,
257 mode: &str,
258 rule: Option<String>,
259 reason: Option<&str>,
260 ) -> Self {
261 Self {
262 cap_id: cap_id.to_string(),
263 key: key.to_string(),
264 action: action.to_string(),
265 decision,
266 mode: mode.to_string(),
267 actor: Actor::Static,
268 // Only a Deny carries a reason at all — same invariant `statik`
269 // enforces. A custom `reason` on a non-Deny call is dropped
270 // rather than surfaced, so an Allow record can never render as
271 // if something had been refused.
272 reason: (decision == Decision4::Deny)
273 .then(|| reason.map_or_else(|| "outside ceiling".to_string(), str::to_string)),
274 rule,
275 never_rollup: false,
276 }
277 }
278
279 /// An `ask` that has resolved — either a human actually answered it, or
280 /// there was no channel to ask on at all and it degraded to deny (§5).
281 ///
282 /// `has_channel` is what tells the two apart, and it changes both `actor`
283 /// and `reason`: a real human refusal is `actor: User, reason: "denied
284 /// by user"`, but a no-channel degrade never consulted anyone, so
285 /// recording it identically would make the trail lie about who decided.
286 /// `DenyPrompter` (the only prompter with `has_channel() == false`)
287 /// always resolves `allowed = false`, so `has_channel: false` in
288 /// practice always pairs with `allowed: false` — but the reason is
289 /// driven by `has_channel` alone, not inferred from `allowed`, so the
290 /// record stays correct even if that pairing ever changes.
291 pub fn answered(cap_id: &str, key: &str, allowed: bool, has_channel: bool) -> Self {
292 let (actor, reason) = if has_channel {
293 (
294 Actor::User,
295 if allowed {
296 "allowed by user"
297 } else {
298 "denied by user"
299 },
300 )
301 } else {
302 // Nobody was consulted — resolved the same way a statically
303 // ceiling-denied request is, so it is attributed the same way.
304 (Actor::Static, "no prompt channel")
305 };
306 Self {
307 cap_id: cap_id.to_string(),
308 key: key.to_string(),
309 action: String::new(),
310 decision: if allowed {
311 Decision4::AskAllow
312 } else {
313 Decision4::AskDeny
314 },
315 mode: "ask".to_string(),
316 actor,
317 reason: Some(reason.to_string()),
318 rule: None,
319 never_rollup: false,
320 }
321 }
322}
323
324/// One capability class as resolved at instantiation.
325#[derive(Debug, Clone)]
326pub struct CeilingClassRecord {
327 pub cap_id: String,
328 pub mode: String,
329 /// Whether the component declared this class in `act.toml`.
330 pub declared: bool,
331 /// Whether this run has an interactive prompt channel at all. `mode ==
332 /// "ask"` alone does not mean an `ask` will ever reach a human: headless
333 /// / ACT-HTTP has no channel, so every `ask` decision degrades to deny
334 /// before anyone is asked. Same value on every class in one
335 /// instantiation — it describes the run, not the capability — but
336 /// carried per-record so the layer never needs anything but this event's
337 /// own typed fields to decide whether to warn.
338 pub has_prompt_channel: bool,
339}
340
341/// One credential handed over to a component.
342///
343/// Deliberately **not** a `CapDecisionRecord`. That one records what policy
344/// decided; this one records that material actually crossed into the guest —
345/// design §9 requires an entry for "every `get-secret` that returns material:
346/// component, session, key, timestamp — never values", and the two answer
347/// different questions when read back.
348///
349/// `component_ref` and `session_id` are carried on the record itself rather
350/// than inherited from an enclosing span: an audit record must identify what
351/// it describes from its own fields, not from whatever happens to enclose the
352/// call site. That keeps the record intact wherever the host later chooses to
353/// serve a credential from, and keeps the layer's decoding a function of the
354/// event alone.
355///
356/// There is no field that could hold a value, and none is added: the type is
357/// the enforcement. `list-secrets` has no record of its own on purpose —
358/// design §9 again, "it would bury the one event that matters".
359#[derive(Debug, Clone)]
360pub struct CredentialIssueRecord {
361 pub component_ref: String,
362 /// The session the credential was issued under. Never empty in practice:
363 /// `get-secret` requires a session (design §3.3).
364 pub session_id: String,
365 /// The store lookup key, as the guest asked for it — untrusted input, so
366 /// every renderer escapes it.
367 pub key: String,
368 pub kind: String,
369}
370
371/// Lowercase hex SHA-256, no `sha256:` prefix.
372pub fn sha256_hex(data: &[u8]) -> String {
373 let mut hasher = Sha256::new();
374 hasher.update(data);
375 hasher
376 .finalize()
377 .iter()
378 .map(|b| format!("{b:02x}"))
379 .collect()
380}
381
382/// Milliseconds, saturating — used for the `act.duration_ms` field.
383pub fn duration_ms(d: Duration) -> u64 {
384 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn attribute_names_are_frozen() {
393 // These strings are a public contract: the OTLP exporter and any
394 // dashboard built on it key off them. Changing one is a breaking change.
395 assert_eq!(attr::COMPONENT_REF, "act.component.ref");
396 assert_eq!(attr::COMPONENT_DIGEST, "act.component.digest");
397 assert_eq!(attr::TOOL_NAME, "act.tool.name");
398 assert_eq!(attr::TOOL_ARGS_SHA256, "act.tool.args_sha256");
399 assert_eq!(attr::TOOL_ARGS, "act.tool.args");
400 assert_eq!(attr::SESSION_ID, "act.session.id");
401 assert_eq!(attr::AGENT_ID, "act.agent.id");
402 assert_eq!(attr::REQUEST_ID, "act.request.id");
403 assert_eq!(attr::TRACE_PARENT, "act.trace.parent");
404 assert_eq!(attr::TRACE_STATE, "act.trace.state");
405 assert_eq!(attr::TRANSPORT, "act.transport");
406 assert_eq!(attr::OUTCOME, "act.outcome");
407 assert_eq!(attr::DURATION_MS, "act.duration_ms");
408 assert_eq!(attr::CAPABILITY_ID, "act.capability.id");
409 assert_eq!(attr::RESOURCE_KEY, "act.resource.key");
410 assert_eq!(attr::RESOURCE_ACTION, "act.resource.action");
411 assert_eq!(attr::DECISION, "act.decision");
412 assert_eq!(attr::POLICY_MODE, "act.policy.mode");
413 assert_eq!(attr::POLICY_ACTOR, "act.policy.actor");
414 assert_eq!(attr::POLICY_REASON, "act.policy.reason");
415 assert_eq!(attr::POLICY_RULE, "act.policy.rule");
416 assert_eq!(attr::CAPABILITY_DECLARED, "act.capability.declared");
417 assert_eq!(attr::CONSENT_PROMPT_CHANNEL, "act.consent.prompt_channel");
418 }
419
420 #[test]
421 fn static_records_carry_a_reason_only_when_denied() {
422 let a = CapDecisionRecord::statik(
423 "wasi:filesystem",
424 "/data/x",
425 "read",
426 Decision4::Allow,
427 "allowlist",
428 Some("/data/**".into()),
429 );
430 assert!(a.reason.is_none());
431 assert_eq!(a.actor, Actor::Static);
432
433 let d =
434 CapDecisionRecord::statik("wasi:http", "evil:443", "GET", Decision4::Deny, "ask", None);
435 assert_eq!(d.reason.as_deref(), Some("outside ceiling"));
436 }
437
438 #[test]
439 fn statik_with_reason_overrides_the_default_deny_reason() {
440 let r = CapDecisionRecord::statik_with_reason(
441 "wasi:http",
442 "blocked.example:443",
443 "",
444 Decision4::Deny,
445 "allowlist",
446 None,
447 Some("redirect target outside ceiling"),
448 );
449 assert_eq!(r.reason.as_deref(), Some("redirect target outside ceiling"));
450 }
451
452 #[test]
453 fn statik_with_reason_none_falls_back_to_statiks_default() {
454 let with_none = CapDecisionRecord::statik_with_reason(
455 "wasi:http",
456 "k",
457 "",
458 Decision4::Deny,
459 "allowlist",
460 None,
461 None,
462 );
463 assert_eq!(with_none.reason.as_deref(), Some("outside ceiling"));
464
465 // An Allow is still never given a reason, even if the caller passes
466 // one — only Deny carries a reason at all, same invariant `statik`
467 // already enforces.
468 let allow_with_reason = CapDecisionRecord::statik_with_reason(
469 "wasi:http",
470 "k",
471 "",
472 Decision4::Allow,
473 "allowlist",
474 None,
475 Some("should be dropped"),
476 );
477 assert!(allow_with_reason.reason.is_none());
478 }
479
480 #[test]
481 fn answered_records_are_attributed_to_the_user() {
482 let r = CapDecisionRecord::answered("wasi:filesystem", "/k", false, true);
483 assert_eq!(r.decision, Decision4::AskDeny);
484 assert_eq!(r.actor, Actor::User);
485 assert_eq!(r.reason.as_deref(), Some("denied by user"));
486 assert_eq!(
487 CapDecisionRecord::answered("wasi:filesystem", "/k", true, true).decision,
488 Decision4::AskAllow
489 );
490 }
491
492 #[test]
493 fn a_no_channel_degrade_is_not_attributed_to_the_user() {
494 // M1: `DenyPrompter` resolves every `ask` to deny with nobody
495 // consulted. Before this fix `answered` always recorded `actor:
496 // User, reason: "denied by user"` regardless — indistinguishable
497 // from a human who was actually asked and said no. `has_channel:
498 // false` must produce a different actor and a reason that says so.
499 let r = CapDecisionRecord::answered("wasi:filesystem", "/k", false, false);
500 assert_eq!(r.decision, Decision4::AskDeny);
501 assert_ne!(
502 r.actor,
503 Actor::User,
504 "nobody was consulted, so this must not be attributed to a user"
505 );
506 assert_eq!(r.reason.as_deref(), Some("no prompt channel"));
507 }
508
509 #[test]
510 fn decision4_renders_the_wire_spellings() {
511 assert_eq!(Decision4::Allow.to_string(), "allow");
512 assert_eq!(Decision4::Deny.to_string(), "deny");
513 assert_eq!(Decision4::AskAllow.to_string(), "ask-allow");
514 assert_eq!(Decision4::AskDeny.to_string(), "ask-deny");
515 }
516
517 #[test]
518 fn decision4_marks_which_records_print_immediately() {
519 // Allows are batched into the rollup; everything else is an exception
520 // that must reach the operator the moment it resolves.
521 assert!(!Decision4::Allow.is_exception());
522 assert!(Decision4::Deny.is_exception());
523 assert!(Decision4::AskAllow.is_exception());
524 assert!(Decision4::AskDeny.is_exception());
525 }
526
527 #[test]
528 fn sha256_hex_matches_the_known_empty_digest() {
529 assert_eq!(
530 sha256_hex(b""),
531 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
532 );
533 }
534
535 #[test]
536 fn transport_and_outcome_render_lowercase_kebab() {
537 assert_eq!(Transport::Cli.to_string(), "cli");
538 assert_eq!(Transport::Mcp.to_string(), "mcp");
539 assert_eq!(Outcome::Ok.to_string(), "ok");
540 assert_eq!(Outcome::ToolError.to_string(), "tool-error");
541 assert_eq!(Outcome::HostError.to_string(), "host-error");
542 assert_eq!(Outcome::Cancelled.to_string(), "cancelled");
543 }
544}