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