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 if mode == PolicyMode::Plan && !looks_like_write(call) {
72 } else {
74 return PolicyDecision::RequiresApproval {
75 reason: Some("side-effecting tool call".to_string()),
76 };
77 }
78 }
79 PolicyDecision::Allowed
80 }
81}
82
83fn matching_rule(config: &PolicyModeConfig, tool_name: &str) -> Option<String> {
84 config
85 .auto_approve
86 .tools
87 .iter()
88 .find(|tool| tool.as_str() == "*" || tool.as_str() == tool_name)
89 .cloned()
90}
91
92fn merge_policy_decision(
93 current: PolicyDecision,
94 contributor_id: String,
95 contribution: PolicyContribution,
96) -> PolicyDecision {
97 match (current, contribution) {
98 (PolicyDecision::Denied { reason }, _) => PolicyDecision::Denied { reason },
99 (_, PolicyContribution::Deny { reason }) => PolicyDecision::Denied {
100 reason: format!("policy contributor {contributor_id} denied tool call: {reason}"),
101 },
102 (PolicyDecision::RequiresApproval { reason }, _) => {
103 PolicyDecision::RequiresApproval { reason }
104 }
105 (_, PolicyContribution::RequireApproval { reason }) => {
106 PolicyDecision::RequiresApproval { reason }
107 }
108 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Abstain) => decision,
109 (decision @ PolicyDecision::AutoApproved { .. }, PolicyContribution::Allow { .. }) => {
110 decision
111 }
112 (
113 PolicyDecision::Allowed,
114 PolicyContribution::Abstain | PolicyContribution::Allow { .. },
115 ) => PolicyDecision::Allowed,
116 }
117}
118
119fn looks_like_side_effect(call: &ToolCall) -> bool {
120 looks_like_write(call) || looks_like_process(call)
121}
122
123fn looks_like_write(call: &ToolCall) -> bool {
124 if matches!(
125 call.name.as_str(),
126 "roadmap_create"
127 | "roadmap_set_task_state"
128 | "roadmap_thread_attach"
129 | "vcs/select"
130 | "vcs/snapshot/create"
131 | "vcs/restore"
132 | "vcs/lines/switch"
133 ) {
134 return true;
135 }
136 if tool_name_contains_any(
137 call,
138 &[
139 "write", "edit", "patch", "delete", "mkdir", "move", "rename",
140 ],
141 ) {
142 return true;
143 }
144
145 if is_shell_tool(&call.name) {
146 if let Some(cmd) = extract_command_string(call) {
147 if command_writes_or_edits_files(&cmd) {
148 return true;
149 }
150 }
151 }
152
153 false
154}
155
156fn is_shell_tool(name: &str) -> bool {
157 let name = name.to_ascii_lowercase();
158 name == "shell" || name == "bash" || name == "exec" || name == "terminal" || name == "command"
159}
160
161fn extract_command_string(call: &ToolCall) -> Option<String> {
162 if let Some(cmd) = call.arguments.get("command").and_then(|v| v.as_str()) {
163 return Some(cmd.to_string());
164 }
165 if let Some(cmd) = call.arguments.get("cmd").and_then(|v| v.as_str()) {
166 return Some(cmd.to_string());
167 }
168 if !call.raw_arguments.is_empty() {
169 return Some(call.raw_arguments.clone());
170 }
171 None
172}
173
174fn command_writes_or_edits_files(cmd: &str) -> bool {
175 let cmd = cmd.to_ascii_lowercase();
176 let contains_redirect = cmd.contains('>') && {
177 let cleaned = cmd
178 .replace("2>&1", "")
179 .replace("1>&2", "")
180 .replace(">/dev/null", "")
181 .replace("> /dev/null", "");
182 cleaned.contains('>')
183 };
184
185 contains_redirect
186 || cmd.contains("<<")
187 || cmd.contains("sed -i")
188 || cmd.contains("tee ")
189}
190
191fn looks_like_process(call: &ToolCall) -> bool {
192 if matches!(call.name.as_str(), "vcs/sync") {
193 return true;
194 }
195 tool_name_contains_any(
196 call,
197 &[
198 "process", "spawn", "shell", "bash", "exec", "terminal", "command",
199 ],
200 )
201}
202
203fn looks_like_network(call: &ToolCall) -> bool {
204 tool_name_contains_any(
205 call,
206 &["network", "web_search", "fetch", "download", "http", "url"],
207 )
208}
209
210fn tool_name_contains_any(call: &ToolCall, signals: &[&str]) -> bool {
211 let name = call.name.to_ascii_lowercase();
212 signals.iter().any(|signal| name.contains(signal))
213}
214
215#[cfg(test)]
216mod tests {
217 use roder_api::events::{ThreadId, TurnId};
218 use roder_api::tools::ToolExecutionContext;
219 use serde_json::json;
220
221 use super::*;
222
223 #[test]
224 fn plan_mode_allows_read_like_tool_with_write_like_arguments() {
225 let decision = DefaultPolicyGate::new().decide(
226 &call(
227 "read_metadata",
228 json!({ "operation": "fs.write", "path": "src/lib.rs" }),
229 ),
230 PolicyMode::Plan,
231 &context(),
232 );
233
234 assert!(matches!(decision, PolicyDecision::Allowed));
235 }
236
237 #[test]
238 fn grep_query_containing_destructive_words_is_allowed() {
239 let decision = DefaultPolicyGate::new().decide(
240 &call(
241 "grep",
242 json!({ "query": "edit command patch", "path": "." }),
243 ),
244 PolicyMode::Default,
245 &context(),
246 );
247
248 assert!(matches!(decision, PolicyDecision::Allowed));
249 }
250
251 #[test]
252 fn plan_mode_denies_write_tool_name() {
253 let decision = DefaultPolicyGate::new().decide(
254 &call("fs.write", json!({ "path": "src/lib.rs" })),
255 PolicyMode::Plan,
256 &context(),
257 );
258
259 assert!(matches!(decision, PolicyDecision::Denied { .. }));
260 }
261
262 #[test]
263 fn plan_mode_allows_safe_shell_tool_but_denies_write_shell_tool() {
264 let safe_decision = DefaultPolicyGate::new().decide(
265 &call("shell", json!({ "command": "cargo test" })),
266 PolicyMode::Plan,
267 &context(),
268 );
269 assert!(matches!(safe_decision, PolicyDecision::Allowed));
270
271 let unsafe_decision_1 = DefaultPolicyGate::new().decide(
272 &call("shell", json!({ "command": "cat << EOF > file.txt" })),
273 PolicyMode::Plan,
274 &context(),
275 );
276 assert!(matches!(unsafe_decision_1, PolicyDecision::Denied { .. }));
277
278 let unsafe_decision_2 = DefaultPolicyGate::new().decide(
279 &call("shell", json!({ "command": "echo foo >> config.json" })),
280 PolicyMode::Plan,
281 &context(),
282 );
283 assert!(matches!(unsafe_decision_2, PolicyDecision::Denied { .. }));
284 }
285
286 #[test]
287 fn default_mode_shell_still_requires_approval() {
288 let decision = DefaultPolicyGate::new().decide(
289 &call("shell", json!({ "command": "cargo test" })),
290 PolicyMode::Default,
291 &context(),
292 );
293
294 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
295 }
296
297 #[test]
298 fn default_mode_edit_still_requires_approval() {
299 let decision = DefaultPolicyGate::new().decide(
300 &call("fs.edit", json!({ "path": "src/lib.rs" })),
301 PolicyMode::Default,
302 &context(),
303 );
304
305 assert!(matches!(decision, PolicyDecision::RequiresApproval { .. }));
306 }
307
308 #[test]
309 fn roadmap_mutating_tools_follow_write_policy() {
310 for tool in [
311 "roadmap_create",
312 "roadmap_patch",
313 "roadmap_set_task_state",
314 "roadmap_thread_attach",
315 ] {
316 let default_decision = DefaultPolicyGate::new().decide(
317 &call(tool, json!({})),
318 PolicyMode::Default,
319 &context(),
320 );
321 assert!(
322 matches!(default_decision, PolicyDecision::RequiresApproval { .. }),
323 "{tool} should require approval in default mode"
324 );
325
326 let plan_decision = DefaultPolicyGate::new().decide(
327 &call(tool, json!({})),
328 PolicyMode::Plan,
329 &context(),
330 );
331 assert!(
332 matches!(plan_decision, PolicyDecision::Denied { .. }),
333 "{tool} should be denied in plan mode"
334 );
335 }
336 }
337
338 #[test]
339 fn accept_all_auto_approves_process_spawn() {
340 let decision = DefaultPolicyGate::new().decide(
341 &call("process.spawn", json!({ "cmd": "cargo test" })),
342 PolicyMode::AcceptAll,
343 &context(),
344 );
345
346 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
347 }
348
349 #[test]
350 fn accept_all_auto_approves_shell_tool() {
351 let decision = DefaultPolicyGate::new().decide(
352 &call("shell", json!({ "command": "cargo test" })),
353 PolicyMode::AcceptAll,
354 &context(),
355 );
356
357 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
358 }
359
360 #[test]
361 fn bypass_auto_approves_tools_without_overriding_denies() {
362 let decision = DefaultPolicyGate::new().decide(
363 &call("process.spawn", json!({ "cmd": "cargo test" })),
364 PolicyMode::Bypass,
365 &context(),
366 );
367
368 assert!(matches!(decision, PolicyDecision::AutoApproved { .. }));
369 }
370
371 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
372 ToolCall {
373 id: "call-1".to_string(),
374 name: name.to_string(),
375 raw_arguments: arguments.to_string(),
376 arguments,
377 thread_id: "thread-1".to_string(),
378 turn_id: "turn-1".to_string(),
379 }
380 }
381
382 fn context() -> ToolExecutionContext {
383 ToolExecutionContext::new(
384 ThreadId::from("thread-1"),
385 TurnId::from("turn-1"),
386 PolicyMode::Default,
387 )
388 }
389}