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 crate::agent_control_tools::is_agent_control_tool(&call.name) {
54 return PolicyDecision::Allowed;
55 }
56 if !config.allow_writes && looks_like_write(call) {
57 return PolicyDecision::Denied {
58 reason: "write-like tool calls are denied in the active policy mode".to_string(),
59 };
60 }
61 if !config.allow_process && looks_like_process(call) {
62 return PolicyDecision::Denied {
63 reason: "process-like tool calls are denied in the active policy mode".to_string(),
64 };
65 }
66 if !config.allow_network && looks_like_network(call) {
67 return PolicyDecision::Denied {
68 reason: "network-like tool calls are denied in the active policy mode".to_string(),
69 };
70 }
71 if config.auto_approve.contains_tool(&call.name) {
72 return PolicyDecision::AutoApproved {
73 matched_rule: matching_rule(&config, &call.name),
74 };
75 }
76 if looks_like_side_effect(call) {
77 if mode == PolicyMode::Plan && !looks_like_write(call) {
78 } else {
80 return PolicyDecision::RequiresApproval {
81 reason: Some("side-effecting tool call".to_string()),
82 };
83 }
84 }
85 PolicyDecision::Allowed
86 }
87}
88
89fn matching_rule(config: &PolicyModeConfig, tool_name: &str) -> Option<String> {
90 config
91 .auto_approve
92 .tools
93 .iter()
94 .find(|tool| tool.as_str() == "*" || tool.as_str() == tool_name)
95 .cloned()
96}
97
98fn merge_policy_decision(
99 current: PolicyDecision,
100 contributor_id: String,
101 contribution: PolicyContribution,
102) -> PolicyDecision {
103 match (current, contribution) {
104 (PolicyDecision::Denied { reason }, _) => PolicyDecision::Denied { reason },
105 (_, PolicyContribution::Deny { reason }) => PolicyDecision::Denied {
106 reason: format!("policy contributor {contributor_id} denied tool call: {reason}"),
107 },
108 (PolicyDecision::RequiresApproval { reason }, _) => {
109 PolicyDecision::RequiresApproval { reason }
110 }
111 (_, PolicyContribution::RequireApproval { reason }) => {
112 PolicyDecision::RequiresApproval { reason }
113 }
114 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Abstain) => decision,
115 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Allow { .. }) => {
116 decision
117 }
118 (
119 PolicyDecision::Allowed,
120 PolicyContribution::Abstain | PolicyContribution::Allow { .. },
121 ) => PolicyDecision::Allowed,
122 }
123}
124
125fn looks_like_side_effect(call: &ToolCall) -> bool {
126 looks_like_write(call) || looks_like_process(call)
127}
128
129fn looks_like_write(call: &ToolCall) -> bool {
130 if call.name == roder_api::computer::COMPUTER_TOOL_NAME {
131 return call.arguments["actions"].as_array().is_none_or(|actions| {
132 actions
133 .iter()
134 .any(|action| !matches!(action["type"].as_str(), Some("screenshot" | "wait")))
135 });
136 }
137 if matches!(
138 call.name.as_str(),
139 "roadmap_create"
140 | "roadmap_set_task_state"
141 | "roadmap_thread_attach"
142 | "vcs/select"
143 | "vcs/snapshot/create"
144 | "vcs/restore"
145 | "vcs/lines/switch"
146 ) {
147 return true;
148 }
149 if tool_name_contains_any(
150 call,
151 &[
152 "write", "edit", "patch", "delete", "mkdir", "move", "rename",
153 ],
154 ) {
155 return true;
156 }
157
158 if is_shell_tool(&call.name) {
159 if let Some(cmd) = extract_command_string(call) {
160 if command_writes_or_edits_files(&cmd) {
161 return true;
162 }
163 }
164 }
165
166 false
167}
168
169fn is_shell_tool(name: &str) -> bool {
170 let name = name.to_ascii_lowercase();
171 name == "shell" || name == "bash" || name == "exec" || name == "terminal" || name == "command"
172}
173
174fn extract_command_string(call: &ToolCall) -> Option<String> {
175 if let Some(cmd) = call.arguments.get("command").and_then(|v| v.as_str()) {
176 return Some(cmd.to_string());
177 }
178 if let Some(cmd) = call.arguments.get("cmd").and_then(|v| v.as_str()) {
179 return Some(cmd.to_string());
180 }
181 if !call.raw_arguments.is_empty() {
182 return Some(call.raw_arguments.clone());
183 }
184 None
185}
186
187fn command_writes_or_edits_files(cmd: &str) -> bool {
188 let cmd = cmd.to_ascii_lowercase();
189 let contains_redirect = cmd.contains('>') && {
190 let cleaned = cmd
191 .replace("2>&1", "")
192 .replace("1>&2", "")
193 .replace(">/dev/null", "")
194 .replace("> /dev/null", "");
195 cleaned.contains('>')
196 };
197
198 contains_redirect || cmd.contains("<<") || cmd.contains("sed -i") || cmd.contains("tee ")
199}
200
201fn looks_like_process(call: &ToolCall) -> bool {
202 if matches!(call.name.as_str(), "vcs/sync") {
203 return true;
204 }
205 tool_name_contains_any(
206 call,
207 &[
208 "process", "spawn", "shell", "bash", "exec", "terminal", "command",
209 ],
210 )
211}
212
213fn looks_like_network(call: &ToolCall) -> bool {
214 tool_name_contains_any(
215 call,
216 &["network", "web_search", "fetch", "download", "http", "url"],
217 )
218}
219
220fn tool_name_contains_any(call: &ToolCall, signals: &[&str]) -> bool {
221 let name = call.name.to_ascii_lowercase();
222 signals.iter().any(|signal| name.contains(signal))
223}
224
225#[cfg(test)]
226mod tests {
227 use roder_api::events::{ThreadId, TurnId};
228 use roder_api::tools::ToolExecutionContext;
229 use serde_json::json;
230
231 use super::*;
232
233 #[test]
234 fn plan_mode_allows_read_like_tool_with_write_like_arguments() {
235 let decision = DefaultPolicyGate::new().decide(
236 &call(
237 "read_metadata",
238 json!({ "operation": "fs.write", "path": "src/lib.rs" }),
239 ),
240 PolicyMode::Plan,
241 &context(),
242 );
243
244 assert!(matches!(decision, PolicyDecision::Allowed));
245 }
246
247 #[test]
248 fn grep_query_containing_destructive_words_is_allowed() {
249 let decision = DefaultPolicyGate::new().decide(
250 &call(
251 "grep",
252 json!({ "query": "edit command patch", "path": "." }),
253 ),
254 PolicyMode::Default,
255 &context(),
256 );
257
258 assert!(matches!(decision, PolicyDecision::Allowed));
259 }
260
261 #[test]
262 fn plan_mode_denies_write_tool_name() {
263 let decision = DefaultPolicyGate::new().decide(
264 &call("fs.write", json!({ "path": "src/lib.rs" })),
265 PolicyMode::Plan,
266 &context(),
267 );
268
269 assert!(matches!(decision, PolicyDecision::Denied { .. }));
270 }
271
272 #[test]
273 fn plan_mode_allows_safe_shell_tool_but_denies_write_shell_tool() {
274 let safe_decision = DefaultPolicyGate::new().decide(
275 &call("shell", json!({ "command": "cargo test" })),
276 PolicyMode::Plan,
277 &context(),
278 );
279 assert!(matches!(safe_decision, PolicyDecision::Allowed));
280
281 let unsafe_decision_1 = DefaultPolicyGate::new().decide(
282 &call("shell", json!({ "command": "cat << EOF > file.txt" })),
283 PolicyMode::Plan,
284 &context(),
285 );
286 assert!(matches!(unsafe_decision_1, PolicyDecision::Denied { .. }));
287
288 let unsafe_decision_2 = DefaultPolicyGate::new().decide(
289 &call("shell", json!({ "command": "echo foo >> config.json" })),
290 PolicyMode::Plan,
291 &context(),
292 );
293 assert!(matches!(unsafe_decision_2, PolicyDecision::Denied { .. }));
294 }
295
296 #[test]
297 fn default_mode_shell_still_requires_approval() {
298 let decision = DefaultPolicyGate::new().decide(
299 &call("shell", json!({ "command": "cargo test" })),
300 PolicyMode::Default,
301 &context(),
302 );
303
304 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
305 }
306
307 #[test]
308 fn default_mode_edit_still_requires_approval() {
309 let decision = DefaultPolicyGate::new().decide(
310 &call("fs.edit", json!({ "path": "src/lib.rs" })),
311 PolicyMode::Default,
312 &context(),
313 );
314
315 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
316 }
317
318 #[test]
319 fn agent_control_tools_are_internal_orchestration_not_os_processes() {
320 for tool in [
321 "spawn_agent",
322 "send_message",
323 "followup_task",
324 "wait_agent",
325 "list_agents",
326 "interrupt_agent",
327 ] {
328 let decision = DefaultPolicyGate::new().decide(
329 &call(tool, json!({})),
330 PolicyMode::Default,
331 &context(),
332 );
333 assert!(
334 matches!(decision, PolicyDecision::Allowed),
335 "{tool} should not be classified as an operating-system side effect"
336 );
337 }
338 }
339
340 #[test]
341 fn roadmap_mutating_tools_follow_write_policy() {
342 for tool in [
343 "roadmap_create",
344 "roadmap_patch",
345 "roadmap_set_task_state",
346 "roadmap_thread_attach",
347 ] {
348 let default_decision = DefaultPolicyGate::new().decide(
349 &call(tool, json!({})),
350 PolicyMode::Default,
351 &context(),
352 );
353 assert!(
354 matches!(default_decision, PolicyDecision::RequiresApproval { .. }),
355 "{tool} should require approval in default mode"
356 );
357
358 let plan_decision = DefaultPolicyGate::new().decide(
359 &call(tool, json!({})),
360 PolicyMode::Plan,
361 &context(),
362 );
363 assert!(
364 matches!(plan_decision, PolicyDecision::Denied { .. }),
365 "{tool} should be denied in plan mode"
366 );
367 }
368 }
369
370 #[test]
371 fn accept_all_auto_approves_process_spawn() {
372 let decision = DefaultPolicyGate::new().decide(
373 &call("process.spawn", json!({ "cmd": "cargo test" })),
374 PolicyMode::AcceptAll,
375 &context(),
376 );
377
378 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
379 }
380
381 #[test]
382 fn accept_all_auto_approves_shell_tool() {
383 let decision = DefaultPolicyGate::new().decide(
384 &call("shell", json!({ "command": "cargo test" })),
385 PolicyMode::AcceptAll,
386 &context(),
387 );
388
389 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
390 }
391
392 #[test]
393 fn bypass_auto_approves_tools_without_overriding_denies() {
394 let decision = DefaultPolicyGate::new().decide(
395 &call("process.spawn", json!({ "cmd": "cargo test" })),
396 PolicyMode::Bypass,
397 &context(),
398 );
399
400 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
401 }
402
403 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
404 ToolCall {
405 id: "call-1".to_string(),
406 name: name.to_string(),
407 raw_arguments: arguments.to_string(),
408 arguments,
409 thread_id: "thread-1".to_string(),
410 turn_id: "turn-1".to_string(),
411 }
412 }
413
414 fn context() -> ToolExecutionContext {
415 ToolExecutionContext::new(
416 ThreadId::from("thread-1"),
417 TurnId::from("turn-1"),
418 PolicyMode::Default,
419 )
420 }
421}
422
423#[cfg(test)]
424mod computer_policy_tests {
425 use super::*;
426 use serde_json::json;
427 #[test]
428 fn native_observations_and_input_follow_existing_policy_modes() {
429 let ctx = ToolExecutionContext::new("computer-policy", "turn", PolicyMode::Plan);
430 let call = |actions| ToolCall {
431 id: "native".into(),
432 name: "computer".into(),
433 arguments: json!({"actions":actions}),
434 raw_arguments: String::new(),
435 thread_id: ctx.thread_id.clone(),
436 turn_id: ctx.turn_id.clone(),
437 };
438 let gate = DefaultPolicyGate::new();
439 assert!(matches!(
440 gate.decide(
441 &call(json!([{"type":"screenshot"}])),
442 PolicyMode::Plan,
443 &ctx
444 ),
445 PolicyDecision::Allowed
446 ));
447 let input = call(json!([{"type":"click","button":"left","x":0,"y":0}]));
448 assert!(matches!(
449 gate.decide(&input, PolicyMode::Plan, &ctx),
450 PolicyDecision::Denied { .. }
451 ));
452 assert!(matches!(
453 gate.decide(&input, PolicyMode::Bypass, &ctx),
454 PolicyDecision::AutoApproved { .. }
455 ));
456 }
457}