Skip to main content

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}