Skip to main content

roder_api/
policy_mode.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5
6#[derive(
7    Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
8)]
9#[serde(rename_all = "snake_case")]
10pub enum PolicyMode {
11    #[default]
12    Default,
13    #[serde(alias = "accept_edits", alias = "accept-edits")]
14    AcceptAll,
15    Plan,
16    Bypass,
17}
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(transparent)]
21pub struct AutoApproveSet {
22    pub tools: Vec<String>,
23}
24
25impl AutoApproveSet {
26    pub fn empty() -> Self {
27        Self::default()
28    }
29
30    pub fn all() -> Self {
31        Self {
32            tools: vec!["*".to_string()],
33        }
34    }
35
36    pub fn accept_all() -> Self {
37        Self {
38            tools: vec![
39                "fs.write".to_string(),
40                "fs.edit".to_string(),
41                "fs.multi_edit".to_string(),
42                "apply_patch".to_string(),
43                "write_file".to_string(),
44                "edit".to_string(),
45                "multi_edit".to_string(),
46                "process.spawn".to_string(),
47                "shell".to_string(),
48                "exec_command".to_string(),
49                "write_stdin".to_string(),
50                "vcs/select".to_string(),
51                "vcs/snapshot/create".to_string(),
52                "vcs/restore".to_string(),
53                "vcs/lines/switch".to_string(),
54                "vcs/sync".to_string(),
55            ],
56        }
57    }
58
59    pub fn contains_tool(&self, tool_name: &str) -> bool {
60        self.tools
61            .iter()
62            .any(|tool| tool == "*" || tool == tool_name)
63    }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct PolicyModeConfig {
68    pub auto_approve: AutoApproveSet,
69    pub denied_tools: Vec<String>,
70    pub allow_writes: bool,
71    pub allow_process: bool,
72    pub allow_network: bool,
73    pub requires_user_to_exit: bool,
74}
75
76impl PolicyModeConfig {
77    pub fn for_mode(mode: PolicyMode) -> Self {
78        match mode {
79            PolicyMode::Default => Self::default_mode(),
80            PolicyMode::AcceptAll => Self::accept_all(),
81            PolicyMode::Plan => Self::plan(),
82            PolicyMode::Bypass => Self::bypass(),
83        }
84    }
85
86    pub fn default_mode() -> Self {
87        Self {
88            auto_approve: AutoApproveSet::empty(),
89            denied_tools: Vec::new(),
90            allow_writes: true,
91            allow_process: true,
92            allow_network: true,
93            requires_user_to_exit: false,
94        }
95    }
96
97    pub fn accept_all() -> Self {
98        Self {
99            auto_approve: AutoApproveSet::accept_all(),
100            denied_tools: Vec::new(),
101            allow_writes: true,
102            allow_process: true,
103            allow_network: true,
104            requires_user_to_exit: false,
105        }
106    }
107
108    pub fn plan() -> Self {
109        Self {
110            auto_approve: AutoApproveSet::empty(),
111            denied_tools: Vec::new(),
112            allow_writes: false,
113            allow_process: false,
114            allow_network: true,
115            requires_user_to_exit: false,
116        }
117    }
118
119    pub fn bypass() -> Self {
120        Self {
121            auto_approve: AutoApproveSet::all(),
122            denied_tools: Vec::new(),
123            allow_writes: true,
124            allow_process: true,
125            allow_network: true,
126            requires_user_to_exit: false,
127        }
128    }
129}
130
131impl Default for PolicyModeConfig {
132    fn default() -> Self {
133        Self::default_mode()
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case", tag = "decision", content = "details")]
139pub enum PolicyDecision {
140    Allowed,
141    RequiresApproval { reason: Option<String> },
142    AutoApproved { matched_rule: Option<String> },
143    Denied { reason: String },
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct PolicyDecisionRecorded {
148    pub thread_id: ThreadId,
149    pub turn_id: TurnId,
150    pub tool_id: String,
151    pub tool_name: String,
152    pub mode: PolicyMode,
153    pub decision: PolicyDecision,
154    #[serde(with = "time::serde::rfc3339")]
155    pub timestamp: OffsetDateTime,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PolicyBypassActive {
160    pub thread_id: ThreadId,
161    pub turn_id: TurnId,
162    pub tool_id: String,
163    pub tool_name: String,
164    #[serde(with = "time::serde::rfc3339")]
165    pub timestamp: OffsetDateTime,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct PolicyModeChanged {
170    pub thread_id: ThreadId,
171    pub turn_id: Option<TurnId>,
172    pub previous_mode: PolicyMode,
173    pub new_mode: PolicyMode,
174    pub reason: Option<String>,
175    #[serde(with = "time::serde::rfc3339")]
176    pub timestamp: OffsetDateTime,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct PolicyExitPlanRequested {
181    pub thread_id: ThreadId,
182    pub turn_id: TurnId,
183    pub request_id: String,
184    pub target_mode: PolicyMode,
185    pub plan_summary: Option<String>,
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub next_steps: Vec<String>,
188    #[serde(with = "time::serde::rfc3339")]
189    pub timestamp: OffsetDateTime,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct PolicyExitPlanResolved {
194    pub thread_id: ThreadId,
195    pub turn_id: TurnId,
196    pub request_id: String,
197    pub approved: bool,
198    pub target_mode: PolicyMode,
199    pub resolved_mode: PolicyMode,
200    #[serde(with = "time::serde::rfc3339")]
201    pub timestamp: OffsetDateTime,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn policy_mode_serde_round_trips_as_snake_case() {
210        let serialized = serde_json::to_string(&PolicyMode::AcceptAll).unwrap();
211        assert_eq!(serialized, "\"accept_all\"");
212
213        let round_trip: PolicyMode = serde_json::from_str(&serialized).unwrap();
214        assert_eq!(round_trip, PolicyMode::AcceptAll);
215
216        let legacy: PolicyMode = serde_json::from_str("\"accept_edits\"").unwrap();
217        assert_eq!(legacy, PolicyMode::AcceptAll);
218    }
219
220    #[test]
221    fn policy_mode_config_serde_round_trips() {
222        let config = PolicyModeConfig {
223            auto_approve: AutoApproveSet {
224                tools: vec!["fs.write".to_string(), "network".to_string()],
225            },
226            denied_tools: vec!["process.spawn".to_string()],
227            allow_writes: true,
228            allow_process: false,
229            allow_network: true,
230            requires_user_to_exit: false,
231        };
232
233        let serialized = serde_json::to_string(&config).unwrap();
234        let round_trip: PolicyModeConfig = serde_json::from_str(&serialized).unwrap();
235
236        assert_eq!(round_trip, config);
237        assert!(round_trip.auto_approve.contains_tool("fs.write"));
238        assert!(!round_trip.auto_approve.contains_tool("fs.read"));
239    }
240
241    #[test]
242    fn policy_decision_serde_round_trips() {
243        let decision = PolicyDecision::RequiresApproval {
244            reason: Some("write access".to_string()),
245        };
246
247        let serialized = serde_json::to_string(&decision).unwrap();
248        let round_trip: PolicyDecision = serde_json::from_str(&serialized).unwrap();
249
250        assert_eq!(round_trip, decision);
251    }
252
253    #[test]
254    fn policy_event_payloads_round_trip() {
255        let event = PolicyExitPlanRequested {
256            thread_id: "thread-a".to_string(),
257            turn_id: "turn-a".to_string(),
258            request_id: "exit-1".to_string(),
259            target_mode: PolicyMode::Default,
260            plan_summary: Some("Implement the approved edits.".to_string()),
261            next_steps: vec!["edit files".to_string(), "run tests".to_string()],
262            timestamp: OffsetDateTime::UNIX_EPOCH,
263        };
264
265        let serialized = serde_json::to_string(&event).unwrap();
266        let round_trip: PolicyExitPlanRequested = serde_json::from_str(&serialized).unwrap();
267
268        assert_eq!(round_trip.request_id, "exit-1");
269        assert_eq!(round_trip.target_mode, PolicyMode::Default);
270        assert_eq!(
271            round_trip.plan_summary.as_deref(),
272            Some("Implement the approved edits.")
273        );
274        assert_eq!(round_trip.next_steps, ["edit files", "run tests"]);
275    }
276
277    #[test]
278    fn policy_mode_default_presets_match_contract() {
279        let default = PolicyModeConfig::for_mode(PolicyMode::Default);
280        assert_eq!(default.auto_approve, AutoApproveSet::empty());
281        assert!(default.allow_writes);
282        assert!(default.allow_process);
283        assert!(default.allow_network);
284        assert!(!default.requires_user_to_exit);
285
286        let accept_all = PolicyModeConfig::for_mode(PolicyMode::AcceptAll);
287        assert!(accept_all.auto_approve.contains_tool("fs.write"));
288        assert!(accept_all.auto_approve.contains_tool("fs.edit"));
289        assert!(accept_all.auto_approve.contains_tool("fs.multi_edit"));
290        assert!(accept_all.auto_approve.contains_tool("apply_patch"));
291        assert!(accept_all.auto_approve.contains_tool("write_file"));
292        assert!(accept_all.auto_approve.contains_tool("edit"));
293        assert!(accept_all.auto_approve.contains_tool("multi_edit"));
294        assert!(accept_all.auto_approve.contains_tool("process.spawn"));
295        assert!(accept_all.auto_approve.contains_tool("shell"));
296        assert!(accept_all.auto_approve.contains_tool("exec_command"));
297        assert!(accept_all.auto_approve.contains_tool("write_stdin"));
298        assert!(accept_all.allow_writes);
299        assert!(accept_all.allow_process);
300        assert!(accept_all.allow_network);
301
302        let plan = PolicyModeConfig::for_mode(PolicyMode::Plan);
303        assert_eq!(plan.auto_approve, AutoApproveSet::empty());
304        assert!(!plan.allow_writes);
305        assert!(!plan.allow_process);
306        assert!(plan.allow_network);
307        assert!(!plan.requires_user_to_exit);
308
309        let bypass = PolicyModeConfig::for_mode(PolicyMode::Bypass);
310        assert!(bypass.auto_approve.contains_tool("any.tool"));
311        assert!(bypass.allow_writes);
312        assert!(bypass.allow_process);
313        assert!(bypass.allow_network);
314        assert!(!bypass.requires_user_to_exit);
315    }
316}