Skip to main content

chio_http_core/
verdict.rs

1//! HTTP-layer verdict type, consistent with the existing Chio Decision enum
2//! in chio-core-types but specialized for the HTTP substrate.
3//!
4//! The `Deny` variant carries optional structured context (tool identity,
5//! required vs granted scope, guard name, a stable reason code, and a
6//! next-steps hint) so the HTTP sidecar can tell an SDK exactly what scope
7//! to request. All detail fields default to `None` on serde, preserving
8//! wire and constructor back-compat.
9
10use serde::{Deserialize, Serialize};
11
12/// Structured deny context attached to [`Verdict::Deny`].
13///
14/// Every field is optional. Callers that only know a reason and a guard
15/// can continue to use [`Verdict::deny`]; callers with richer context
16/// should prefer [`Verdict::deny_detailed`] or build a [`DenyDetails`]
17/// directly and pass it to the struct variant.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct DenyDetails {
20    /// Tool name that was denied, e.g. `"write_file"`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub tool_name: Option<String>,
23
24    /// Tool server that hosts the denied tool, e.g. `"filesystem"`.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub tool_server: Option<String>,
27
28    /// Short human-readable summary of the attempted action, suitable for
29    /// inclusion in an error line. Example: `write_file(path=".env")`.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub requested_action: Option<String>,
32
33    /// Scope the kernel says is required to perform the action, rendered
34    /// as the SDK's canonical `ToolGrant(...)` string.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub required_scope: Option<String>,
37
38    /// Scope the presented capability actually had, same rendering as
39    /// `required_scope`. `None` when no capability was presented.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub granted_scope: Option<String>,
42
43    /// Stable machine-readable code for this denial, e.g.
44    /// `"scope.missing"`, `"guard.prompt_injection"`, `"tenant.mismatch"`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub reason_code: Option<String>,
47
48    /// Receipt id that captures this denial, for audit correlation.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub receipt_id: Option<String>,
51
52    /// Next-steps sentence shown to the developer. Example: `"Request
53    /// scope filesystem::write_file from the capability authority."`.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub hint: Option<String>,
56
57    /// Link to the docs page that explains this deny code.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub docs_url: Option<String>,
60}
61
62impl DenyDetails {
63    /// True when every field is `None`. Used to keep the default-path
64    /// serialized form identical to the pre-0.5 wire shape.
65    #[must_use]
66    pub fn is_empty(&self) -> bool {
67        self.tool_name.is_none()
68            && self.tool_server.is_none()
69            && self.requested_action.is_none()
70            && self.required_scope.is_none()
71            && self.granted_scope.is_none()
72            && self.reason_code.is_none()
73            && self.receipt_id.is_none()
74            && self.hint.is_none()
75            && self.docs_url.is_none()
76    }
77}
78
79/// The verdict for an HTTP request evaluation.
80/// Consistent with `chio_core_types::Decision` but carries HTTP-specific context.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "verdict", rename_all = "snake_case")]
83pub enum Verdict {
84    /// Request is allowed. Proceed to upstream.
85    Allow,
86
87    /// Request is denied. Return a structured error response.
88    Deny {
89        /// Human-readable reason for denial.
90        reason: String,
91        /// The guard or policy rule that triggered the denial.
92        guard: String,
93        /// Suggested HTTP status code for the error response (default 403).
94        #[serde(default = "default_deny_status")]
95        http_status: u16,
96        /// Structured deny context: tool identity, required vs granted
97        /// scope, a stable reason code, receipt id, and a next-steps
98        /// hint. All fields are optional and default to `None`, so this
99        /// field is transparent to wire clients that ignore deny details.
100        ///
101        /// Boxed to keep the [`Verdict`] enum compact on the hot allow
102        /// path; the structured deny context is only populated on the
103        /// (comparatively rare) deny path.
104        #[serde(default, skip_serializing_if = "deny_details_is_empty")]
105        details: Box<DenyDetails>,
106    },
107
108    /// Request evaluation was cancelled (e.g., timeout, circuit breaker).
109    Cancel {
110        /// Reason for cancellation.
111        reason: String,
112    },
113
114    /// Request evaluation did not reach a terminal state.
115    Incomplete {
116        /// Reason for incomplete evaluation.
117        reason: String,
118    },
119}
120
121fn default_deny_status() -> u16 {
122    403
123}
124
125fn deny_details_is_empty(details: &DenyDetails) -> bool {
126    details.is_empty()
127}
128
129impl Verdict {
130    /// Deny with a 403 status.
131    #[must_use]
132    pub fn deny(reason: impl Into<String>, guard: impl Into<String>) -> Self {
133        Self::Deny {
134            reason: reason.into(),
135            guard: guard.into(),
136            http_status: 403,
137            details: Box::new(DenyDetails::default()),
138        }
139    }
140
141    /// Deny with a custom HTTP status code.
142    #[must_use]
143    pub fn deny_with_status(
144        reason: impl Into<String>,
145        guard: impl Into<String>,
146        http_status: u16,
147    ) -> Self {
148        Self::Deny {
149            reason: reason.into(),
150            guard: guard.into(),
151            http_status,
152            details: Box::new(DenyDetails::default()),
153        }
154    }
155
156    /// Deny with a full structured context block.
157    ///
158    /// Prefer this constructor when the kernel already knows what scope
159    /// was needed versus granted, which guard fired, and a hint for
160    /// the developer. The HTTP status defaults to 403.
161    #[must_use]
162    pub fn deny_detailed(
163        reason: impl Into<String>,
164        guard: impl Into<String>,
165        details: DenyDetails,
166    ) -> Self {
167        Self::Deny {
168            reason: reason.into(),
169            guard: guard.into(),
170            http_status: 403,
171            details: Box::new(details),
172        }
173    }
174
175    /// Attach (or overwrite) the structured deny context on an existing
176    /// `Deny` verdict. No-op for non-`Deny` variants. Useful when the
177    /// guard pipeline constructs a plain deny and a later enrichment
178    /// stage populates the details.
179    pub fn with_deny_details(mut self, new_details: DenyDetails) -> Self {
180        if let Self::Deny { details, .. } = &mut self {
181            **details = new_details;
182        }
183        self
184    }
185
186    #[must_use]
187    pub fn is_allowed(&self) -> bool {
188        matches!(self, Self::Allow)
189    }
190
191    #[must_use]
192    pub fn is_denied(&self) -> bool {
193        matches!(self, Self::Deny { .. })
194    }
195
196    /// Convert to the core Decision type for receipt signing.
197    #[must_use]
198    pub fn to_decision(&self) -> chio_core_types::Decision {
199        match self {
200            Self::Allow => chio_core_types::Decision::Allow,
201            Self::Deny { reason, guard, .. } => chio_core_types::Decision::Deny {
202                reason: reason.clone(),
203                guard: guard.clone(),
204            },
205            Self::Cancel { reason } => chio_core_types::Decision::Cancelled {
206                reason: reason.clone(),
207            },
208            Self::Incomplete { reason } => chio_core_types::Decision::Incomplete {
209                reason: reason.clone(),
210            },
211        }
212    }
213}
214
215impl From<chio_core_types::Decision> for Verdict {
216    fn from(decision: chio_core_types::Decision) -> Self {
217        match decision {
218            chio_core_types::Decision::Allow => Self::Allow,
219            chio_core_types::Decision::Deny { reason, guard } => Self::Deny {
220                reason,
221                guard,
222                http_status: 403,
223                details: Box::new(DenyDetails::default()),
224            },
225            chio_core_types::Decision::Cancelled { reason } => Self::Cancel { reason },
226            chio_core_types::Decision::Incomplete { reason } => Self::Incomplete { reason },
227        }
228    }
229}
230
231#[cfg(test)]
232#[allow(clippy::expect_used, clippy::unwrap_used)]
233mod tests {
234    use super::*;
235
236    fn expect_deny(v: Verdict) -> (String, String, u16, DenyDetails) {
237        match v {
238            Verdict::Deny {
239                reason,
240                guard,
241                http_status,
242                details,
243            } => (reason, guard, http_status, *details),
244            other => panic!("expected Deny, got {other:?}"),
245        }
246    }
247
248    #[test]
249    fn verdict_deny_default_status() {
250        let v = Verdict::deny("no capability", "CapabilityGuard");
251        assert!(v.is_denied());
252        assert!(!v.is_allowed());
253        let (_, _, http_status, details) = expect_deny(v);
254        assert_eq!(http_status, 403);
255        assert!(details.is_empty());
256    }
257
258    #[test]
259    fn verdict_to_decision_roundtrip() {
260        let v = Verdict::deny("blocked", "TestGuard");
261        let d = v.to_decision();
262        let v2 = Verdict::from(d);
263        assert!(v2.is_denied());
264    }
265
266    #[test]
267    fn serde_roundtrip() {
268        let v = Verdict::Allow;
269        let json = serde_json::to_string(&v).expect("allow serializes");
270        let back: Verdict = serde_json::from_str(&json).expect("allow deserializes");
271        assert_eq!(back, v);
272    }
273
274    #[test]
275    fn deny_serde_includes_status() {
276        let v = Verdict::deny_with_status("rate limited", "RateGuard", 429);
277        let json = serde_json::to_string(&v).expect("serializes");
278        assert!(json.contains("429"));
279        let back: Verdict = serde_json::from_str(&json).expect("deserializes");
280        let (_, _, http_status, _) = expect_deny(back);
281        assert_eq!(http_status, 429);
282    }
283
284    #[test]
285    fn cancel_verdict_conversion() {
286        let v = Verdict::Cancel {
287            reason: "timed out".to_string(),
288        };
289        assert!(!v.is_allowed());
290        assert!(!v.is_denied());
291        let decision = v.to_decision();
292        assert!(matches!(
293            decision,
294            chio_core_types::Decision::Cancelled { .. }
295        ));
296        let v2 = Verdict::from(decision);
297        assert!(matches!(v2, Verdict::Cancel { reason } if reason == "timed out"));
298    }
299
300    #[test]
301    fn incomplete_verdict_conversion() {
302        let v = Verdict::Incomplete {
303            reason: "partial evaluation".to_string(),
304        };
305        assert!(!v.is_allowed());
306        assert!(!v.is_denied());
307        let decision = v.to_decision();
308        assert!(matches!(
309            decision,
310            chio_core_types::Decision::Incomplete { .. }
311        ));
312        let v2 = Verdict::from(decision);
313        assert!(matches!(v2, Verdict::Incomplete { reason } if reason == "partial evaluation"));
314    }
315
316    #[test]
317    fn cancel_serde_roundtrip() {
318        let v = Verdict::Cancel {
319            reason: "circuit breaker".to_string(),
320        };
321        let json = serde_json::to_string(&v).expect("serializes");
322        let back: Verdict = serde_json::from_str(&json).expect("deserializes");
323        assert_eq!(back, v);
324    }
325
326    #[test]
327    fn incomplete_serde_roundtrip() {
328        let v = Verdict::Incomplete {
329            reason: "pending approval".to_string(),
330        };
331        let json = serde_json::to_string(&v).expect("serializes");
332        let back: Verdict = serde_json::from_str(&json).expect("deserializes");
333        assert_eq!(back, v);
334    }
335
336    #[test]
337    fn deny_default_status_via_serde_default() {
338        // When deserializing a Deny variant without the http_status field,
339        // the default should be 403.
340        let json = r#"{"verdict":"deny","reason":"blocked","guard":"TestGuard"}"#;
341        let v: Verdict = serde_json::from_str(json).expect("deserializes");
342        let (_, _, http_status, details) = expect_deny(v);
343        assert_eq!(http_status, 403);
344        // A pre-0.5 wire payload with no details field deserializes into
345        // an empty DenyDetails block, preserving back-compat.
346        assert!(details.is_empty());
347    }
348
349    #[test]
350    fn allow_roundtrip_through_decision() {
351        let v = Verdict::Allow;
352        let decision = v.to_decision();
353        assert!(matches!(decision, chio_core_types::Decision::Allow));
354        let v2 = Verdict::from(decision);
355        assert!(v2.is_allowed());
356    }
357
358    #[test]
359    fn deny_detailed_carries_structured_fields() {
360        let details = DenyDetails {
361            tool_name: Some("write_file".into()),
362            tool_server: Some("filesystem".into()),
363            requested_action: Some("write_file(path=.env)".into()),
364            required_scope: Some("ToolGrant(server_id=filesystem, tool_name=write_file)".into()),
365            granted_scope: Some("ToolGrant(server_id=filesystem, tool_name=read_file)".into()),
366            reason_code: Some("scope.missing".into()),
367            receipt_id: Some("chio-receipt-7f3a9b2c".into()),
368            hint: Some("Request scope filesystem::write_file from the authority.".into()),
369            docs_url: Some("https://docs.chio-protocol.dev/errors/Chio-DENIED".into()),
370        };
371        let v = Verdict::deny_detailed("scope check failed", "ScopeGuard", details);
372        let (reason, guard, http_status, details) = expect_deny(v);
373        assert_eq!(reason, "scope check failed");
374        assert_eq!(guard, "ScopeGuard");
375        assert_eq!(http_status, 403);
376        assert_eq!(details.tool_name.as_deref(), Some("write_file"));
377        assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
378    }
379
380    #[test]
381    fn deny_details_empty_is_omitted_on_the_wire() {
382        // The plain `deny(...)` path must serialize to the pre-0.5 shape
383        // so that older SDKs keep parsing the payload.
384        let v = Verdict::deny("no capability", "CapabilityGuard");
385        let json = serde_json::to_string(&v).expect("serializes");
386        assert!(
387            !json.contains("details"),
388            "unexpected details in JSON: {json}"
389        );
390        assert!(json.contains("\"verdict\":\"deny\""));
391        assert!(json.contains("\"reason\":\"no capability\""));
392        assert!(json.contains("\"guard\":\"CapabilityGuard\""));
393    }
394
395    #[test]
396    fn with_deny_details_attaches_context() {
397        let details = DenyDetails {
398            tool_name: Some("read_file".into()),
399            reason_code: Some("scope.missing".into()),
400            ..DenyDetails::default()
401        };
402        let v = Verdict::deny("missing scope", "ScopeGuard").with_deny_details(details);
403        let (_, _, _, details) = expect_deny(v);
404        assert_eq!(details.tool_name.as_deref(), Some("read_file"));
405        assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
406    }
407
408    #[test]
409    fn with_deny_details_is_noop_for_non_deny() {
410        let v = Verdict::Allow.with_deny_details(DenyDetails {
411            tool_name: Some("should_be_ignored".into()),
412            ..DenyDetails::default()
413        });
414        assert!(v.is_allowed());
415    }
416}