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            if mode == PolicyMode::Plan && !looks_like_write(call) {
72                // Allowed process-like tools (that don't write/edit files) are fully allowed in Plan mode.
73            } 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}