use std::fmt;
use std::time::Duration;
use sha2::{Digest, Sha256};
pub mod attr {
pub const COMPONENT_REF: &str = "act.component.ref";
pub const COMPONENT_DIGEST: &str = "act.component.digest";
pub const TOOL_NAME: &str = "act.tool.name";
pub const TOOL_ARGS_SHA256: &str = "act.tool.args_sha256";
pub const TOOL_ARGS: &str = "act.tool.args";
pub const SESSION_ID: &str = "act.session.id";
pub const AGENT_ID: &str = "act.agent.id";
pub const REQUEST_ID: &str = "act.request.id";
pub const TRACE_PARENT: &str = "act.trace.parent";
pub const TRACE_STATE: &str = "act.trace.state";
pub const TRANSPORT: &str = "act.transport";
pub const OUTCOME: &str = "act.outcome";
pub const DURATION_MS: &str = "act.duration_ms";
pub const CAPABILITY_ID: &str = "act.capability.id";
pub const RESOURCE_KEY: &str = "act.resource.key";
pub const RESOURCE_ACTION: &str = "act.resource.action";
pub const DECISION: &str = "act.decision";
pub const POLICY_MODE: &str = "act.policy.mode";
pub const POLICY_ACTOR: &str = "act.policy.actor";
pub const POLICY_REASON: &str = "act.policy.reason";
pub const POLICY_RULE: &str = "act.policy.rule";
pub const CAPABILITY_DECLARED: &str = "act.capability.declared";
pub const NEVER_ROLLUP: &str = "act.decision.never_rollup";
pub const CREDENTIAL_KIND: &str = "act.credential.kind";
pub const CONSENT_PROMPT_CHANNEL: &str = "act.consent.prompt_channel";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Transport {
#[default]
Cli,
Mcp,
}
impl fmt::Display for Transport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Transport::Cli => "cli",
Transport::Mcp => "mcp",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Ok,
ToolError,
HostError,
#[allow(dead_code)]
Cancelled,
}
impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Outcome::Ok => "ok",
Outcome::ToolError => "tool-error",
Outcome::HostError => "host-error",
Outcome::Cancelled => "cancelled",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision4 {
Allow,
Deny,
AskAllow,
AskDeny,
}
impl Decision4 {
pub fn is_exception(&self) -> bool {
!matches!(self, Decision4::Allow)
}
}
impl fmt::Display for Decision4 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Decision4::Allow => "allow",
Decision4::Deny => "deny",
Decision4::AskAllow => "ask-allow",
Decision4::AskDeny => "ask-deny",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Actor {
Static,
User,
Policy,
}
impl fmt::Display for Actor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Actor::Static => "static",
Actor::User => "user",
Actor::Policy => "policy",
})
}
}
#[derive(Debug, Clone)]
pub struct ToolCallStart {
pub component_ref: String,
pub digest: String,
pub tool: String,
pub args_sha256: String,
pub args_json: Option<String>,
pub session_id: Option<String>,
pub transport: Transport,
pub agent_id: Option<String>,
pub request_id: String,
pub traceparent: Option<String>,
pub tracestate: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CapDecisionRecord {
pub cap_id: String,
pub key: String,
pub action: String,
pub decision: Decision4,
pub mode: String,
pub actor: Actor,
pub reason: Option<String>,
pub rule: Option<String>,
pub never_rollup: bool,
}
impl CapDecisionRecord {
pub fn statik(
cap_id: &str,
key: &str,
action: &str,
decision: Decision4,
mode: &str,
rule: Option<String>,
) -> Self {
Self::statik_with_reason(cap_id, key, action, decision, mode, rule, None)
}
pub fn statik_with_reason(
cap_id: &str,
key: &str,
action: &str,
decision: Decision4,
mode: &str,
rule: Option<String>,
reason: Option<&str>,
) -> Self {
Self {
cap_id: cap_id.to_string(),
key: key.to_string(),
action: action.to_string(),
decision,
mode: mode.to_string(),
actor: Actor::Static,
reason: (decision == Decision4::Deny)
.then(|| reason.map_or_else(|| "outside ceiling".to_string(), str::to_string)),
rule,
never_rollup: false,
}
}
pub fn answered(cap_id: &str, key: &str, allowed: bool, has_channel: bool) -> Self {
let (actor, reason) = if has_channel {
(
Actor::User,
if allowed {
"allowed by user"
} else {
"denied by user"
},
)
} else {
(Actor::Static, "no prompt channel")
};
Self {
cap_id: cap_id.to_string(),
key: key.to_string(),
action: String::new(),
decision: if allowed {
Decision4::AskAllow
} else {
Decision4::AskDeny
},
mode: "ask".to_string(),
actor,
reason: Some(reason.to_string()),
rule: None,
never_rollup: false,
}
}
}
#[derive(Debug, Clone)]
pub struct CeilingClassRecord {
pub cap_id: String,
pub mode: String,
pub declared: bool,
pub has_prompt_channel: bool,
}
#[derive(Debug, Clone)]
pub struct CredentialIssueRecord {
pub component_ref: String,
pub session_id: String,
pub key: String,
pub kind: String,
}
pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn duration_ms(d: Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attribute_names_are_frozen() {
assert_eq!(attr::COMPONENT_REF, "act.component.ref");
assert_eq!(attr::COMPONENT_DIGEST, "act.component.digest");
assert_eq!(attr::TOOL_NAME, "act.tool.name");
assert_eq!(attr::TOOL_ARGS_SHA256, "act.tool.args_sha256");
assert_eq!(attr::TOOL_ARGS, "act.tool.args");
assert_eq!(attr::SESSION_ID, "act.session.id");
assert_eq!(attr::AGENT_ID, "act.agent.id");
assert_eq!(attr::REQUEST_ID, "act.request.id");
assert_eq!(attr::TRACE_PARENT, "act.trace.parent");
assert_eq!(attr::TRACE_STATE, "act.trace.state");
assert_eq!(attr::TRANSPORT, "act.transport");
assert_eq!(attr::OUTCOME, "act.outcome");
assert_eq!(attr::DURATION_MS, "act.duration_ms");
assert_eq!(attr::CAPABILITY_ID, "act.capability.id");
assert_eq!(attr::RESOURCE_KEY, "act.resource.key");
assert_eq!(attr::RESOURCE_ACTION, "act.resource.action");
assert_eq!(attr::DECISION, "act.decision");
assert_eq!(attr::POLICY_MODE, "act.policy.mode");
assert_eq!(attr::POLICY_ACTOR, "act.policy.actor");
assert_eq!(attr::POLICY_REASON, "act.policy.reason");
assert_eq!(attr::POLICY_RULE, "act.policy.rule");
assert_eq!(attr::CAPABILITY_DECLARED, "act.capability.declared");
assert_eq!(attr::CONSENT_PROMPT_CHANNEL, "act.consent.prompt_channel");
}
#[test]
fn static_records_carry_a_reason_only_when_denied() {
let a = CapDecisionRecord::statik(
"wasi:filesystem",
"/data/x",
"read",
Decision4::Allow,
"allowlist",
Some("/data/**".into()),
);
assert!(a.reason.is_none());
assert_eq!(a.actor, Actor::Static);
let d =
CapDecisionRecord::statik("wasi:http", "evil:443", "GET", Decision4::Deny, "ask", None);
assert_eq!(d.reason.as_deref(), Some("outside ceiling"));
}
#[test]
fn statik_with_reason_overrides_the_default_deny_reason() {
let r = CapDecisionRecord::statik_with_reason(
"wasi:http",
"blocked.example:443",
"",
Decision4::Deny,
"allowlist",
None,
Some("redirect target outside ceiling"),
);
assert_eq!(r.reason.as_deref(), Some("redirect target outside ceiling"));
}
#[test]
fn statik_with_reason_none_falls_back_to_statiks_default() {
let with_none = CapDecisionRecord::statik_with_reason(
"wasi:http",
"k",
"",
Decision4::Deny,
"allowlist",
None,
None,
);
assert_eq!(with_none.reason.as_deref(), Some("outside ceiling"));
let allow_with_reason = CapDecisionRecord::statik_with_reason(
"wasi:http",
"k",
"",
Decision4::Allow,
"allowlist",
None,
Some("should be dropped"),
);
assert!(allow_with_reason.reason.is_none());
}
#[test]
fn answered_records_are_attributed_to_the_user() {
let r = CapDecisionRecord::answered("wasi:filesystem", "/k", false, true);
assert_eq!(r.decision, Decision4::AskDeny);
assert_eq!(r.actor, Actor::User);
assert_eq!(r.reason.as_deref(), Some("denied by user"));
assert_eq!(
CapDecisionRecord::answered("wasi:filesystem", "/k", true, true).decision,
Decision4::AskAllow
);
}
#[test]
fn a_no_channel_degrade_is_not_attributed_to_the_user() {
let r = CapDecisionRecord::answered("wasi:filesystem", "/k", false, false);
assert_eq!(r.decision, Decision4::AskDeny);
assert_ne!(
r.actor,
Actor::User,
"nobody was consulted, so this must not be attributed to a user"
);
assert_eq!(r.reason.as_deref(), Some("no prompt channel"));
}
#[test]
fn decision4_renders_the_wire_spellings() {
assert_eq!(Decision4::Allow.to_string(), "allow");
assert_eq!(Decision4::Deny.to_string(), "deny");
assert_eq!(Decision4::AskAllow.to_string(), "ask-allow");
assert_eq!(Decision4::AskDeny.to_string(), "ask-deny");
}
#[test]
fn decision4_marks_which_records_print_immediately() {
assert!(!Decision4::Allow.is_exception());
assert!(Decision4::Deny.is_exception());
assert!(Decision4::AskAllow.is_exception());
assert!(Decision4::AskDeny.is_exception());
}
#[test]
fn sha256_hex_matches_the_known_empty_digest() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn transport_and_outcome_render_lowercase_kebab() {
assert_eq!(Transport::Cli.to_string(), "cli");
assert_eq!(Transport::Mcp.to_string(), "mcp");
assert_eq!(Outcome::Ok.to_string(), "ok");
assert_eq!(Outcome::ToolError.to_string(), "tool-error");
assert_eq!(Outcome::HostError.to_string(), "host-error");
assert_eq!(Outcome::Cancelled.to_string(), "cancelled");
}
}