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 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}