1use std::sync::Arc;
2
3use roder_api::context::{PolicyContribution, PolicyContributor, PolicyGate, PolicyReview};
4use roder_api::policy_mode::{PolicyDecision, PolicyMode, PolicyModeConfig};
5use roder_api::tools::{ToolCall, ToolExecutionContext};
6
7#[derive(Debug, Clone, Default)]
8pub struct DefaultPolicyGate;
9
10impl DefaultPolicyGate {
11 pub fn new() -> Self {
12 Self
13 }
14
15 pub async fn decide_with_contributors(
16 &self,
17 call: &ToolCall,
18 mode: PolicyMode,
19 context: &ToolExecutionContext,
20 contributors: &[Arc<dyn PolicyContributor>],
21 ) -> anyhow::Result<PolicyDecision> {
22 let mut decision = self.decide(call, mode, context);
23 for contributor in contributors {
24 let contribution = contributor
25 .review_tool(PolicyReview {
26 call: call.clone(),
27 mode,
28 context: context.clone(),
29 })
30 .await?;
31 decision = merge_policy_decision(decision, contributor.id(), contribution);
32 }
33 Ok(decision)
34 }
35}
36
37impl PolicyGate for DefaultPolicyGate {
38 fn decide(
39 &self,
40 call: &ToolCall,
41 mode: PolicyMode,
42 _context: &ToolExecutionContext,
43 ) -> PolicyDecision {
44 let config = PolicyModeConfig::for_mode(mode);
45 if config.denied_tools.iter().any(|tool| tool == &call.name) {
46 return PolicyDecision::Denied {
47 reason: format!("tool {:?} is denied by policy", call.name),
48 };
49 }
50 if !config.allow_writes && looks_like_write(call) {
51 return PolicyDecision::Denied {
52 reason: "write-like tool calls are denied in the active policy mode".to_string(),
53 };
54 }
55 if !config.allow_process && looks_like_process(call) {
56 return PolicyDecision::Denied {
57 reason: "process-like tool calls are denied in the active policy mode".to_string(),
58 };
59 }
60 if !config.allow_network && looks_like_network(call) {
61 return PolicyDecision::Denied {
62 reason: "network-like tool calls are denied in the active policy mode".to_string(),
63 };
64 }
65 if config.auto_approve.contains_tool(&call.name) {
66 return PolicyDecision::AutoApproved {
67 matched_rule: matching_rule(&config, &call.name),
68 };
69 }
70 if looks_like_side_effect(call) {
71 return PolicyDecision::RequiresApproval {
72 reason: Some("side-effecting tool call".to_string()),
73 };
74 }
75 PolicyDecision::Allowed
76 }
77}
78
79fn matching_rule(config: &PolicyModeConfig, tool_name: &str) -> Option<String> {
80 config
81 .auto_approve
82 .tools
83 .iter()
84 .find(|tool| tool.as_str() == "*" || tool.as_str() == tool_name)
85 .cloned()
86}
87
88fn merge_policy_decision(
89 current: PolicyDecision,
90 contributor_id: String,
91 contribution: PolicyContribution,
92) -> PolicyDecision {
93 match (current, contribution) {
94 (PolicyDecision::Denied { reason }, _) => PolicyDecision::Denied { reason },
95 (_, PolicyContribution::Deny { reason }) => PolicyDecision::Denied {
96 reason: format!("policy contributor {contributor_id} denied tool call: {reason}"),
97 },
98 (PolicyDecision::RequiresApproval { reason }, _) => {
99 PolicyDecision::RequiresApproval { reason }
100 }
101 (_, PolicyContribution::RequireApproval { reason }) => {
102 PolicyDecision::RequiresApproval { reason }
103 }
104 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Abstain) => decision,
105 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Allow { .. }) => {
106 decision
107 }
108 (
109 PolicyDecision::Allowed,
110 PolicyContribution::Abstain | PolicyContribution::Allow { .. },
111 ) => PolicyDecision::Allowed,
112 }
113}
114
115fn looks_like_side_effect(call: &ToolCall) -> bool {
116 looks_like_write(call) || looks_like_process(call)
117}
118
119fn looks_like_write(call: &ToolCall) -> bool {
120 if matches!(
121 call.name.as_str(),
122 "roadmap_create"
123 | "roadmap_set_task_state"
124 | "roadmap_thread_attach"
125 | "vcs/select"
126 | "vcs/snapshot/create"
127 | "vcs/restore"
128 | "vcs/lines/switch"
129 ) {
130 return true;
131 }
132 tool_name_contains_any(
133 call,
134 &[
135 "write", "edit", "patch", "delete", "mkdir", "move", "rename",
136 ],
137 )
138}
139
140fn looks_like_process(call: &ToolCall) -> bool {
141 if matches!(call.name.as_str(), "vcs/sync") {
142 return true;
143 }
144 tool_name_contains_any(
145 call,
146 &[
147 "process", "spawn", "shell", "bash", "exec", "terminal", "command",
148 ],
149 )
150}
151
152fn looks_like_network(call: &ToolCall) -> bool {
153 tool_name_contains_any(
154 call,
155 &["network", "web_search", "fetch", "download", "http", "url"],
156 )
157}
158
159fn tool_name_contains_any(call: &ToolCall, signals: &[&str]) -> bool {
160 let name = call.name.to_ascii_lowercase();
161 signals.iter().any(|signal| name.contains(signal))
162}
163
164#[cfg(test)]
165mod tests {
166 use roder_api::events::{ThreadId, TurnId};
167 use roder_api::tools::ToolExecutionContext;
168 use serde_json::json;
169
170 use super::*;
171
172 #[test]
173 fn plan_mode_allows_read_like_tool_with_write_like_arguments() {
174 let decision = DefaultPolicyGate::new().decide(
175 &call(
176 "read_metadata",
177 json!({ "operation": "fs.write", "path": "src/lib.rs" }),
178 ),
179 PolicyMode::Plan,
180 &context(),
181 );
182
183 assert!(matches!(decision, PolicyDecision::Allowed));
184 }
185
186 #[test]
187 fn grep_query_containing_destructive_words_is_allowed() {
188 let decision = DefaultPolicyGate::new().decide(
189 &call(
190 "grep",
191 json!({ "query": "edit command patch", "path": "." }),
192 ),
193 PolicyMode::Default,
194 &context(),
195 );
196
197 assert!(matches!(decision, PolicyDecision::Allowed));
198 }
199
200 #[test]
201 fn plan_mode_denies_write_tool_name() {
202 let decision = DefaultPolicyGate::new().decide(
203 &call("fs.write", json!({ "path": "src/lib.rs" })),
204 PolicyMode::Plan,
205 &context(),
206 );
207
208 assert!(matches!(decision, PolicyDecision::Denied { .. }));
209 }
210
211 #[test]
212 fn plan_mode_denies_shell_tool_name() {
213 let decision = DefaultPolicyGate::new().decide(
214 &call("shell", json!({ "command": "cargo test" })),
215 PolicyMode::Plan,
216 &context(),
217 );
218
219 assert!(matches!(decision, PolicyDecision::Denied { .. }));
220 }
221
222 #[test]
223 fn default_mode_shell_still_requires_approval() {
224 let decision = DefaultPolicyGate::new().decide(
225 &call("shell", json!({ "command": "cargo test" })),
226 PolicyMode::Default,
227 &context(),
228 );
229
230 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
231 }
232
233 #[test]
234 fn default_mode_edit_still_requires_approval() {
235 let decision = DefaultPolicyGate::new().decide(
236 &call("fs.edit", json!({ "path": "src/lib.rs" })),
237 PolicyMode::Default,
238 &context(),
239 );
240
241 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
242 }
243
244 #[test]
245 fn roadmap_mutating_tools_follow_write_policy() {
246 for tool in [
247 "roadmap_create",
248 "roadmap_patch",
249 "roadmap_set_task_state",
250 "roadmap_thread_attach",
251 ] {
252 let default_decision = DefaultPolicyGate::new().decide(
253 &call(tool, json!({})),
254 PolicyMode::Default,
255 &context(),
256 );
257 assert!(
258 matches!(default_decision, PolicyDecision::RequiresApproval { .. }),
259 "{tool} should require approval in default mode"
260 );
261
262 let plan_decision = DefaultPolicyGate::new().decide(
263 &call(tool, json!({})),
264 PolicyMode::Plan,
265 &context(),
266 );
267 assert!(
268 matches!(plan_decision, PolicyDecision::Denied { .. }),
269 "{tool} should be denied in plan mode"
270 );
271 }
272 }
273
274 #[test]
275 fn accept_all_auto_approves_process_spawn() {
276 let decision = DefaultPolicyGate::new().decide(
277 &call("process.spawn", json!({ "cmd": "cargo test" })),
278 PolicyMode::AcceptAll,
279 &context(),
280 );
281
282 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
283 }
284
285 #[test]
286 fn accept_all_auto_approves_shell_tool() {
287 let decision = DefaultPolicyGate::new().decide(
288 &call("shell", json!({ "command": "cargo test" })),
289 PolicyMode::AcceptAll,
290 &context(),
291 );
292
293 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
294 }
295
296 #[test]
297 fn bypass_auto_approves_tools_without_overriding_denies() {
298 let decision = DefaultPolicyGate::new().decide(
299 &call("process.spawn", json!({ "cmd": "cargo test" })),
300 PolicyMode::Bypass,
301 &context(),
302 );
303
304 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
305 }
306
307 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
308 ToolCall {
309 id: "call-1".to_string(),
310 name: name.to_string(),
311 raw_arguments: arguments.to_string(),
312 arguments,
313 thread_id: "thread-1".to_string(),
314 turn_id: "turn-1".to_string(),
315 }
316 }
317
318 fn context() -> ToolExecutionContext {
319 ToolExecutionContext::new(
320 ThreadId::from("thread-1"),
321 TurnId::from("turn-1"),
322 PolicyMode::Default,
323 )
324 }
325}