Skip to main content

imp_core/
policy.rs

1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4/// Per-run policy for constraining tool execution.
5///
6/// This policy is intentionally narrower than [`crate::config::AgentMode`]:
7/// AgentMode establishes a coarse baseline role, while `RunPolicy` lets
8/// automation further constrain a single non-interactive worker run.
9#[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}