Skip to main content

av_sandbox/
denial_code.rs

1//! Structured denial codes for tool-call authorization failures.
2//!
3//! Each code is a stable, machine-readable string that appears in JSON-RPC
4//! `error.data.code`, in the OCSF event's `denial_code` field, and in
5//! operator metrics. Consumers can match on these codes without parsing
6//! human-readable reason strings.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11/// Machine-readable denial code attached to every `ToolVerdict::Blocked`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum DenialCode {
15    /// JSON-RPC envelope did not parse (malformed JSON, wrong version, etc.).
16    #[serde(rename = "PARSE_ERROR")]
17    ParseError,
18    /// Method is not `tools/call` — the call was passed through or refused
19    /// at the protocol level.
20    #[serde(rename = "PASSTHROUGH_REFUSED")]
21    PassthroughRefused,
22    /// Tool arguments failed JSON Schema validation.
23    #[serde(rename = "SCHEMA_VIOLATION")]
24    SchemaViolation,
25    /// No argument schema configured for this tool and `require_schema` is on.
26    #[serde(rename = "SCHEMA_MISSING")]
27    SchemaMissing,
28    /// A named policy engine denied the call.
29    #[serde(rename = "POLICY_DENIED")]
30    PolicyDenied,
31    /// Session or principal action budget exhausted.
32    #[serde(rename = "BUDGET_EXCEEDED")]
33    BudgetExceeded,
34    /// Budget subsystem failed and the sandbox failed closed.
35    #[serde(rename = "BUDGET_ERROR")]
36    BudgetError,
37    /// Tool has no configured intent mapping (pillar 2: AuthZEN).
38    #[serde(rename = "UNMAPPED_TOOL")]
39    UnmappedTool,
40    /// The active mission has expired.
41    #[serde(rename = "MISSION_EXPIRED")]
42    MissionExpired,
43    /// The active mission does not authorize this intent.
44    #[serde(rename = "MISSION_DENIED")]
45    MissionDenied,
46    /// The caller's identity scopes do not cover the required tool scope.
47    #[serde(rename = "SCOPE_INSUFFICIENT")]
48    ScopeInsufficient,
49    /// Identity token was revoked (JTI revocation list).
50    #[serde(rename = "TOKEN_REVOKED")]
51    TokenRevoked,
52    /// No backend is configured for this tool.
53    #[serde(rename = "NO_BACKEND")]
54    NoBackend,
55    /// The intent token could not be issued (signing failure, clock skew, etc.).
56    #[serde(rename = "INTENT_TOKEN_ERROR")]
57    IntentTokenError,
58    /// Payout amount field could not be parsed or is negative.
59    #[serde(rename = "PAYOUT_INVALID")]
60    PayoutInvalid,
61    /// The external AuthZEN policy decision point denied the call.
62    #[serde(rename = "PDP_DENIED")]
63    PdpDenied,
64}
65
66impl DenialCode {
67    /// Stable wire string (matches the serde rename).
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::ParseError => "PARSE_ERROR",
71            Self::PassthroughRefused => "PASSTHROUGH_REFUSED",
72            Self::SchemaViolation => "SCHEMA_VIOLATION",
73            Self::SchemaMissing => "SCHEMA_MISSING",
74            Self::PolicyDenied => "POLICY_DENIED",
75            Self::BudgetExceeded => "BUDGET_EXCEEDED",
76            Self::BudgetError => "BUDGET_ERROR",
77            Self::UnmappedTool => "UNMAPPED_TOOL",
78            Self::MissionExpired => "MISSION_EXPIRED",
79            Self::MissionDenied => "MISSION_DENIED",
80            Self::ScopeInsufficient => "SCOPE_INSUFFICIENT",
81            Self::TokenRevoked => "TOKEN_REVOKED",
82            Self::NoBackend => "NO_BACKEND",
83            Self::IntentTokenError => "INTENT_TOKEN_ERROR",
84            Self::PayoutInvalid => "PAYOUT_INVALID",
85            Self::PdpDenied => "PDP_DENIED",
86        }
87    }
88}
89
90impl fmt::Display for DenialCode {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(self.as_str())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    #![allow(clippy::unwrap_used)]
99
100    use super::*;
101
102    #[test]
103    fn as_str_round_trips_through_serde() {
104        let codes = [
105            DenialCode::ParseError,
106            DenialCode::PassthroughRefused,
107            DenialCode::SchemaViolation,
108            DenialCode::SchemaMissing,
109            DenialCode::PolicyDenied,
110            DenialCode::BudgetExceeded,
111            DenialCode::BudgetError,
112            DenialCode::UnmappedTool,
113            DenialCode::MissionExpired,
114            DenialCode::MissionDenied,
115            DenialCode::ScopeInsufficient,
116            DenialCode::TokenRevoked,
117            DenialCode::NoBackend,
118            DenialCode::IntentTokenError,
119            DenialCode::PayoutInvalid,
120            DenialCode::PdpDenied,
121        ];
122        for code in codes {
123            let json = serde_json::to_string(&code).unwrap();
124            let back: DenialCode = serde_json::from_str(&json).unwrap();
125            assert_eq!(back, code);
126            assert_eq!(format!("\"{code}\""), json);
127        }
128    }
129
130    #[test]
131    fn display_matches_as_str() {
132        for code in [DenialCode::UnmappedTool, DenialCode::MissionExpired] {
133            assert_eq!(format!("{code}"), code.as_str());
134        }
135    }
136}