Skip to main content

roder_core/
policy_gate.rs

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        // Full Access disables approval prompts, including requests contributed
34        // by extensions. Explicit policy denials remain authoritative.
35        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        // Agent-control calls only mutate Roder's internal collaboration state. In
59        // particular, spawn_agent does not launch an OS process, despite its name.
60        // The child remains subject to the caller's inherited policy and tool filters.
61        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                // Allowed process-like tools (that don't write/edit files) are fully allowed in Plan mode.
87            } 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}