1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct RunPolicy {
11 allowed_tools: BTreeSet<String>,
12 denied_tools: BTreeSet<String>,
13 allowed_write_patterns: Vec<String>,
14 denied_write_patterns: Vec<String>,
15}
16
17impl RunPolicy {
18 pub fn new() -> Self {
19 Self::default()
20 }
21
22 pub fn allow_tool(mut self, name: impl AsRef<str>) -> Self {
23 self.allowed_tools.insert(normalize_tool_name(name));
24 self
25 }
26
27 pub fn deny_tool(mut self, name: impl AsRef<str>) -> Self {
28 self.denied_tools.insert(normalize_tool_name(name));
29 self
30 }
31
32 pub fn allowed_tools(&self) -> &BTreeSet<String> {
33 &self.allowed_tools
34 }
35
36 pub fn denied_tools(&self) -> &BTreeSet<String> {
37 &self.denied_tools
38 }
39
40 pub fn allow_write(mut self, pattern: impl Into<String>) -> Self {
41 self.allowed_write_patterns.push(pattern.into());
42 self
43 }
44
45 pub fn deny_write(mut self, pattern: impl Into<String>) -> Self {
46 self.denied_write_patterns.push(pattern.into());
47 self
48 }
49
50 pub fn allowed_write_patterns(&self) -> &[String] {
51 &self.allowed_write_patterns
52 }
53
54 pub fn denied_write_patterns(&self) -> &[String] {
55 &self.denied_write_patterns
56 }
57
58 pub fn is_empty(&self) -> bool {
59 self.allowed_tools.is_empty()
60 && self.denied_tools.is_empty()
61 && self.allowed_write_patterns.is_empty()
62 && self.denied_write_patterns.is_empty()
63 }
64
65 pub fn check_tool(&self, tool_name: &str) -> ToolPolicyDecision {
66 let normalized = normalize_tool_name(tool_name);
67 if self.denied_tools.contains(&normalized) {
68 return ToolPolicyDecision::Denied(format!("Tool `{tool_name}` denied by run policy."));
69 }
70
71 if !self.allowed_tools.is_empty() && !self.allowed_tools.contains(&normalized) {
72 return ToolPolicyDecision::Denied(format!(
73 "Tool `{tool_name}` is not in the run policy allowlist."
74 ));
75 }
76
77 ToolPolicyDecision::Allowed
78 }
79
80 pub fn check_write_path(&self, cwd: &Path, path: &Path) -> WritePolicyDecision {
81 if self.allowed_write_patterns.is_empty() && self.denied_write_patterns.is_empty() {
82 return WritePolicyDecision::Allowed;
83 }
84
85 let Ok(relative) = normalize_relative_path(cwd, path) else {
86 return WritePolicyDecision::Denied(format!(
87 "Write to `{}` denied by run policy because the path is outside the worker root `{}`.",
88 path.display(),
89 cwd.display()
90 ));
91 };
92 let display = relative.to_string_lossy().replace('\\', "/");
93
94 if matches_any(&display, &self.denied_write_patterns) {
95 return WritePolicyDecision::Denied(format!(
96 "Write to `{display}` denied by run policy denylist."
97 ));
98 }
99
100 if !self.allowed_write_patterns.is_empty()
101 && !matches_any(&display, &self.allowed_write_patterns)
102 {
103 return WritePolicyDecision::Denied(format!(
104 "Write to `{display}` is not in the run policy write allowlist."
105 ));
106 }
107
108 WritePolicyDecision::Allowed
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum WritePolicyDecision {
114 Allowed,
115 Denied(String),
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum ToolPolicyDecision {
120 Allowed,
121 Denied(String),
122}
123
124fn normalize_tool_name(name: impl AsRef<str>) -> String {
125 name.as_ref().trim().to_ascii_lowercase()
126}
127
128fn normalize_relative_path(cwd: &Path, path: &Path) -> Result<PathBuf, ()> {
129 let root = normalize_path(cwd);
130 let candidate = if path.is_absolute() {
131 normalize_path(path)
132 } else {
133 normalize_path(&cwd.join(path))
134 };
135 candidate
136 .strip_prefix(&root)
137 .map(Path::to_path_buf)
138 .map_err(|_| ())
139}
140
141fn normalize_path(path: &Path) -> PathBuf {
142 let mut normalized = PathBuf::new();
143 for component in path.components() {
144 match component {
145 Component::CurDir => {}
146 Component::ParentDir => {
147 normalized.pop();
148 }
149 Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
150 normalized.push(component.as_os_str());
151 }
152 }
153 }
154 normalized
155}
156
157fn matches_any(path: &str, patterns: &[String]) -> bool {
158 patterns
159 .iter()
160 .any(|pattern| path_matches_pattern(path, pattern))
161}
162
163fn path_matches_pattern(path: &str, pattern: &str) -> bool {
164 let pattern = pattern.trim().replace('\\', "/");
165 if pattern == path {
166 return true;
167 }
168 glob::Pattern::new(&pattern).is_ok_and(|glob| glob.matches(path))
169}
170
171#[cfg(test)]
172mod tests {
173 use super::{RunPolicy, ToolPolicyDecision};
174
175 #[test]
176 fn empty_policy_allows_tools() {
177 assert_eq!(
178 RunPolicy::new().check_tool("bash"),
179 ToolPolicyDecision::Allowed
180 );
181 }
182
183 #[test]
184 fn deny_tool_blocks_even_when_allowed() {
185 let policy = RunPolicy::new().allow_tool("bash").deny_tool("bash");
186 assert!(matches!(
187 policy.check_tool("bash"),
188 ToolPolicyDecision::Denied(reason) if reason.contains("denied")
189 ));
190 }
191
192 #[test]
193 fn allowlist_blocks_unlisted_tools() {
194 let policy = RunPolicy::new().allow_tool("read");
195 assert_eq!(policy.check_tool("read"), ToolPolicyDecision::Allowed);
196 assert!(matches!(
197 policy.check_tool("write"),
198 ToolPolicyDecision::Denied(reason) if reason.contains("allowlist")
199 ));
200 }
201
202 #[test]
203 fn tool_names_are_normalized() {
204 let policy = RunPolicy::new().allow_tool(" Read ").deny_tool(" Git ");
205 assert_eq!(policy.check_tool("read"), ToolPolicyDecision::Allowed);
206 assert!(matches!(
207 policy.check_tool("git"),
208 ToolPolicyDecision::Denied(_)
209 ));
210 }
211}