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