Skip to main content

platonic_core/
policy.rs

1//! Policy primitives for evaluating proposed side effects.
2
3use serde::{Deserialize, Serialize};
4
5/// High-level class of effect a tool may produce.
6#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum EffectClass {
9    /// Reads local or remote state without mutation.
10    ReadOnly,
11    /// Mutates files in an explicit workspace.
12    WorkspaceWrite,
13    /// Performs network IO without an external irreversible side effect.
14    Network,
15    /// Sends, publishes, charges, deploys, deletes, or otherwise affects the world.
16    ExternalSideEffect,
17    /// Requests access to credentials, secrets, or protected material.
18    SecretAccess,
19}
20
21impl EffectClass {
22    /// Returns the fail-closed baseline decision for this effect class.
23    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/// Policy decision for a proposed model or tool action.
37///
38/// The tagged JSON schema rejects unknown fields rather than discarding policy data.
39#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "decision")]
41pub enum PolicyDecision {
42    /// Action may proceed.
43    Allow,
44    /// Action may proceed only after approval.
45    RequireApproval {
46        /// Explanation presented to the approver and retained in run state.
47        reason: String,
48    },
49    /// Action must not proceed.
50    Deny {
51        /// Durable explanation for rejecting the action.
52        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}