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        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        // Agent-control calls only mutate Roder's internal collaboration state. In
51        // particular, spawn_agent does not launch an OS process, despite its name.
52        // The child remains subject to the caller's inherited policy and tool filters.
53        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                // Allowed process-like tools (that don't write/edit files) are fully allowed in Plan mode.
79            } 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}