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 matches!(
131 call.name.as_str(),
132 "roadmap_create"
133 | "roadmap_set_task_state"
134 | "roadmap_thread_attach"
135 | "vcs/select"
136 | "vcs/snapshot/create"
137 | "vcs/restore"
138 | "vcs/lines/switch"
139 ) {
140 return true;
141 }
142 if tool_name_contains_any(
143 call,
144 &[
145 "write", "edit", "patch", "delete", "mkdir", "move", "rename",
146 ],
147 ) {
148 return true;
149 }
150
151 if is_shell_tool(&call.name) {
152 if let Some(cmd) = extract_command_string(call) {
153 if command_writes_or_edits_files(&cmd) {
154 return true;
155 }
156 }
157 }
158
159 false
160}
161
162fn is_shell_tool(name: &str) -> bool {
163 let name = name.to_ascii_lowercase();
164 name == "shell" || name == "bash" || name == "exec" || name == "terminal" || name == "command"
165}
166
167fn extract_command_string(call: &ToolCall) -> Option<String> {
168 if let Some(cmd) = call.arguments.get("command").and_then(|v| v.as_str()) {
169 return Some(cmd.to_string());
170 }
171 if let Some(cmd) = call.arguments.get("cmd").and_then(|v| v.as_str()) {
172 return Some(cmd.to_string());
173 }
174 if !call.raw_arguments.is_empty() {
175 return Some(call.raw_arguments.clone());
176 }
177 None
178}
179
180fn command_writes_or_edits_files(cmd: &str) -> bool {
181 let cmd = cmd.to_ascii_lowercase();
182 let contains_redirect = cmd.contains('>') && {
183 let cleaned = cmd
184 .replace("2>&1", "")
185 .replace("1>&2", "")
186 .replace(">/dev/null", "")
187 .replace("> /dev/null", "");
188 cleaned.contains('>')
189 };
190
191 contains_redirect || cmd.contains("<<") || cmd.contains("sed -i") || cmd.contains("tee ")
192}
193
194fn looks_like_process(call: &ToolCall) -> bool {
195 if matches!(call.name.as_str(), "vcs/sync") {
196 return true;
197 }
198 tool_name_contains_any(
199 call,
200 &[
201 "process", "spawn", "shell", "bash", "exec", "terminal", "command",
202 ],
203 )
204}
205
206fn looks_like_network(call: &ToolCall) -> bool {
207 tool_name_contains_any(
208 call,
209 &["network", "web_search", "fetch", "download", "http", "url"],
210 )
211}
212
213fn tool_name_contains_any(call: &ToolCall, signals: &[&str]) -> bool {
214 let name = call.name.to_ascii_lowercase();
215 signals.iter().any(|signal| name.contains(signal))
216}
217
218#[cfg(test)]
219mod tests {
220 use roder_api::events::{ThreadId, TurnId};
221 use roder_api::tools::ToolExecutionContext;
222 use serde_json::json;
223
224 use super::*;
225
226 #[test]
227 fn plan_mode_allows_read_like_tool_with_write_like_arguments() {
228 let decision = DefaultPolicyGate::new().decide(
229 &call(
230 "read_metadata",
231 json!({ "operation": "fs.write", "path": "src/lib.rs" }),
232 ),
233 PolicyMode::Plan,
234 &context(),
235 );
236
237 assert!(matches!(decision, PolicyDecision::Allowed));
238 }
239
240 #[test]
241 fn grep_query_containing_destructive_words_is_allowed() {
242 let decision = DefaultPolicyGate::new().decide(
243 &call(
244 "grep",
245 json!({ "query": "edit command patch", "path": "." }),
246 ),
247 PolicyMode::Default,
248 &context(),
249 );
250
251 assert!(matches!(decision, PolicyDecision::Allowed));
252 }
253
254 #[test]
255 fn plan_mode_denies_write_tool_name() {
256 let decision = DefaultPolicyGate::new().decide(
257 &call("fs.write", json!({ "path": "src/lib.rs" })),
258 PolicyMode::Plan,
259 &context(),
260 );
261
262 assert!(matches!(decision, PolicyDecision::Denied { .. }));
263 }
264
265 #[test]
266 fn plan_mode_allows_safe_shell_tool_but_denies_write_shell_tool() {
267 let safe_decision = DefaultPolicyGate::new().decide(
268 &call("shell", json!({ "command": "cargo test" })),
269 PolicyMode::Plan,
270 &context(),
271 );
272 assert!(matches!(safe_decision, PolicyDecision::Allowed));
273
274 let unsafe_decision_1 = DefaultPolicyGate::new().decide(
275 &call("shell", json!({ "command": "cat << EOF > file.txt" })),
276 PolicyMode::Plan,
277 &context(),
278 );
279 assert!(matches!(unsafe_decision_1, PolicyDecision::Denied { .. }));
280
281 let unsafe_decision_2 = DefaultPolicyGate::new().decide(
282 &call("shell", json!({ "command": "echo foo >> config.json" })),
283 PolicyMode::Plan,
284 &context(),
285 );
286 assert!(matches!(unsafe_decision_2, PolicyDecision::Denied { .. }));
287 }
288
289 #[test]
290 fn default_mode_shell_still_requires_approval() {
291 let decision = DefaultPolicyGate::new().decide(
292 &call("shell", json!({ "command": "cargo test" })),
293 PolicyMode::Default,
294 &context(),
295 );
296
297 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
298 }
299
300 #[test]
301 fn default_mode_edit_still_requires_approval() {
302 let decision = DefaultPolicyGate::new().decide(
303 &call("fs.edit", json!({ "path": "src/lib.rs" })),
304 PolicyMode::Default,
305 &context(),
306 );
307
308 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
309 }
310
311 #[test]
312 fn agent_control_tools_are_internal_orchestration_not_os_processes() {
313 for tool in [
314 "spawn_agent",
315 "send_message",
316 "followup_task",
317 "wait_agent",
318 "list_agents",
319 "interrupt_agent",
320 ] {
321 let decision = DefaultPolicyGate::new().decide(
322 &call(tool, json!({})),
323 PolicyMode::Default,
324 &context(),
325 );
326 assert!(
327 matches!(decision, PolicyDecision::Allowed),
328 "{tool} should not be classified as an operating-system side effect"
329 );
330 }
331 }
332
333 #[test]
334 fn roadmap_mutating_tools_follow_write_policy() {
335 for tool in [
336 "roadmap_create",
337 "roadmap_patch",
338 "roadmap_set_task_state",
339 "roadmap_thread_attach",
340 ] {
341 let default_decision = DefaultPolicyGate::new().decide(
342 &call(tool, json!({})),
343 PolicyMode::Default,
344 &context(),
345 );
346 assert!(
347 matches!(default_decision, PolicyDecision::RequiresApproval { .. }),
348 "{tool} should require approval in default mode"
349 );
350
351 let plan_decision = DefaultPolicyGate::new().decide(
352 &call(tool, json!({})),
353 PolicyMode::Plan,
354 &context(),
355 );
356 assert!(
357 matches!(plan_decision, PolicyDecision::Denied { .. }),
358 "{tool} should be denied in plan mode"
359 );
360 }
361 }
362
363 #[test]
364 fn accept_all_auto_approves_process_spawn() {
365 let decision = DefaultPolicyGate::new().decide(
366 &call("process.spawn", json!({ "cmd": "cargo test" })),
367 PolicyMode::AcceptAll,
368 &context(),
369 );
370
371 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
372 }
373
374 #[test]
375 fn accept_all_auto_approves_shell_tool() {
376 let decision = DefaultPolicyGate::new().decide(
377 &call("shell", json!({ "command": "cargo test" })),
378 PolicyMode::AcceptAll,
379 &context(),
380 );
381
382 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
383 }
384
385 #[test]
386 fn bypass_auto_approves_tools_without_overriding_denies() {
387 let decision = DefaultPolicyGate::new().decide(
388 &call("process.spawn", json!({ "cmd": "cargo test" })),
389 PolicyMode::Bypass,
390 &context(),
391 );
392
393 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
394 }
395
396 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
397 ToolCall {
398 id: "call-1".to_string(),
399 name: name.to_string(),
400 raw_arguments: arguments.to_string(),
401 arguments,
402 thread_id: "thread-1".to_string(),
403 turn_id: "turn-1".to_string(),
404 }
405 }
406
407 fn context() -> ToolExecutionContext {
408 ToolExecutionContext::new(
409 ThreadId::from("thread-1"),
410 TurnId::from("turn-1"),
411 PolicyMode::Default,
412 )
413 }
414}