1use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum EffectClass {
9 ReadOnly,
11 WorkspaceWrite,
13 Network,
15 ExternalSideEffect,
17 SecretAccess,
19}
20
21impl EffectClass {
22 pub fn default_policy(&self) -> PolicyDecision {
24 match self {
25 Self::ReadOnly => PolicyDecision::Allow,
26 Self::WorkspaceWrite | Self::Network => PolicyDecision::RequireApproval {
27 reason: "mutable or networked tool call requires explicit policy allowance".into(),
28 },
29 Self::ExternalSideEffect | Self::SecretAccess => PolicyDecision::Deny {
30 reason: "external side effects and secret access fail closed by default".into(),
31 },
32 }
33 }
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "decision")]
41pub enum PolicyDecision {
42 Allow,
44 RequireApproval {
46 reason: String,
48 },
49 Deny {
51 reason: String,
53 },
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59
60 const ACCEPTED_V0_2_0_POLICY_DECISION_JSON: &str =
61 r#"{"decision":"require_approval","reason":"operator confirmation required"}"#;
62 const REJECTED_UNKNOWN_FIELD_POLICY_DECISION_JSON: &str = r#"{"decision":"require_approval","reason":"operator confirmation required","future_field":true}"#;
63
64 #[test]
65 fn policy_decision_json_schema_is_fail_closed_and_v0_2_0_compatible() {
66 let accepted: PolicyDecision =
67 serde_json::from_str(ACCEPTED_V0_2_0_POLICY_DECISION_JSON).unwrap();
68 assert_eq!(
69 accepted,
70 PolicyDecision::RequireApproval {
71 reason: "operator confirmation required".into(),
72 }
73 );
74
75 let error =
76 serde_json::from_str::<PolicyDecision>(REJECTED_UNKNOWN_FIELD_POLICY_DECISION_JSON)
77 .unwrap_err();
78 assert!(error.to_string().contains("unknown field `future_field`"));
79 }
80
81 #[test]
82 fn external_side_effects_fail_closed_by_default() {
83 assert!(matches!(
84 EffectClass::ExternalSideEffect.default_policy(),
85 PolicyDecision::Deny { .. }
86 ));
87 }
88
89 #[test]
90 fn read_only_is_allowed_by_default() {
91 assert!(matches!(
92 EffectClass::ReadOnly.default_policy(),
93 PolicyDecision::Allow
94 ));
95 }
96
97 #[test]
98 fn workspace_writes_require_approval_by_default() {
99 assert!(matches!(
100 EffectClass::WorkspaceWrite.default_policy(),
101 PolicyDecision::RequireApproval { .. }
102 ));
103 }
104}