Skip to main content

exeora_cli/
policy.rs

1use crate::protocol::ToolName;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::{collections::HashSet, fs, path::Path};
5
6pub const POLICY_FILENAME: &str = "exeora.toml";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum PolicyMode {
11    AllowAll,
12    AllowList,
13    ReadOnly,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct CommandPolicy {
19    pub mode: PolicyMode,
20    #[serde(default)]
21    pub allow: Vec<String>,
22    #[serde(default)]
23    pub deny: Vec<String>,
24    #[serde(default)]
25    pub shell: bool,
26    #[serde(default)]
27    pub approve: bool,
28    #[serde(default)]
29    pub tools: Option<Vec<ToolName>>,
30}
31
32impl Default for CommandPolicy {
33    fn default() -> Self {
34        Self {
35            mode: PolicyMode::AllowAll,
36            allow: vec![],
37            deny: vec![],
38            shell: false,
39            approve: false,
40            tools: None,
41        }
42    }
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct LocalCommandPolicy {
47    pub mode: Option<PolicyMode>,
48    pub allow: Option<Vec<String>>,
49    pub deny: Option<Vec<String>>,
50    pub shell: Option<bool>,
51    pub approve: Option<bool>,
52    pub tools: Option<Vec<ToolName>>,
53}
54
55#[derive(Debug, Clone)]
56pub struct PolicyVerdict {
57    pub allowed: bool,
58    pub reason: Option<String>,
59}
60
61impl PolicyVerdict {
62    fn yes() -> Self {
63        Self {
64            allowed: true,
65            reason: None,
66        }
67    }
68    fn no(reason: impl Into<String>) -> Self {
69        Self {
70            allowed: false,
71            reason: Some(reason.into()),
72        }
73    }
74}
75
76pub fn effective_policy(
77    root: &Path,
78    remote: Option<CommandPolicy>,
79) -> (CommandPolicy, Option<String>) {
80    let account = remote.unwrap_or_default();
81    let path = root.join(POLICY_FILENAME);
82    let text = match fs::read_to_string(&path) {
83        Ok(text) => text,
84        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (account, None),
85        Err(_) => {
86            return (
87                account,
88                Some(format!(
89                    "{POLICY_FILENAME} could not be read; the account's policy applies."
90                )),
91            );
92        }
93    };
94    let local: LocalCommandPolicy = match toml::from_str(&text) {
95        Ok(policy) => policy,
96        Err(_) => {
97            return (
98                account,
99                Some(format!(
100                    "{POLICY_FILENAME} is not valid TOML; the account's policy applies."
101                )),
102            );
103        }
104    };
105    (narrow_policy(&account, &local), None)
106}
107
108pub fn narrow_policy(remote: &CommandPolicy, local: &LocalCommandPolicy) -> CommandPolicy {
109    let mode = local
110        .mode
111        .map_or(remote.mode, |mode| stricter(remote.mode, mode));
112    let approve = remote.approve || local.approve.unwrap_or(false);
113    let mut deny = remote.deny.clone();
114    for rule in local.deny.as_deref().unwrap_or_default() {
115        if !deny.contains(rule) {
116            deny.push(rule.clone());
117        }
118    }
119    let tools = match (&remote.tools, &local.tools) {
120        (remote, None) => remote.clone(),
121        (None, Some(local)) => Some(local.clone()),
122        (Some(remote), Some(local)) => Some(
123            remote
124                .iter()
125                .copied()
126                .filter(|tool| local.contains(tool))
127                .collect(),
128        ),
129    };
130    let shell = local
131        .shell
132        .map_or(remote.shell, |shell| remote.shell && shell);
133    let allow = if mode != PolicyMode::AllowList {
134        Vec::new()
135    } else {
136        let mut lists: Vec<&[String]> = Vec::new();
137        if remote.mode == PolicyMode::AllowList {
138            lists.push(&remote.allow);
139        }
140        if local.mode == Some(PolicyMode::AllowList)
141            && let Some(list) = &local.allow
142        {
143            lists.push(list);
144        }
145        lists.split_first().map_or_else(Vec::new, |(first, rest)| {
146            first
147                .iter()
148                .filter(|item| rest.iter().all(|list| list.contains(item)))
149                .cloned()
150                .collect()
151        })
152    };
153    CommandPolicy {
154        mode,
155        allow,
156        deny,
157        shell,
158        approve,
159        tools,
160    }
161}
162
163pub fn policy_allows(policy: &CommandPolicy, tool: ToolName, args: &Value) -> PolicyVerdict {
164    if let Some(tools) = &policy.tools
165        && !tools.contains(&tool)
166    {
167        let permitted = if tools.is_empty() {
168            "nothing".to_owned()
169        } else {
170            tools
171                .iter()
172                .map(ToString::to_string)
173                .collect::<Vec<_>>()
174                .join(", ")
175        };
176        return PolicyVerdict::no(format!(
177            "This project does not offer `{tool}`. Permitted: {permitted}."
178        ));
179    }
180    if !tool.read_only() && policy.mode == PolicyMode::ReadOnly {
181        return PolicyVerdict::no("This project is read only. It allows no tool that changes it.");
182    }
183    if !matches!(tool, ToolName::RunCommand | ToolName::StartCommand) {
184        return PolicyVerdict::yes();
185    }
186    let Some(command) = args.get("command").and_then(Value::as_str) else {
187        return PolicyVerdict::no("No command was given.");
188    };
189    command_allowed(policy, command)
190}
191
192pub fn command_allowed(policy: &CommandPolicy, command: &str) -> PolicyVerdict {
193    if policy.mode == PolicyMode::ReadOnly {
194        return PolicyVerdict::no("This project is read only. It runs no commands.");
195    }
196    let words = tokenize(command);
197    let Some(program) = words.first() else {
198        return PolicyVerdict::no("No command was given.");
199    };
200    let readable = policy.mode == PolicyMode::AllowList || !policy.deny.is_empty();
201    if readable && !policy.shell && command.chars().any(is_shell_syntax) {
202        return PolicyVerdict::no(
203            "This project allows only plain commands. Shell syntax (pipes, redirection, substitution, chaining) is not permitted.",
204        );
205    }
206    if policy.deny.iter().any(|rule| matches_rule(rule, &words)) {
207        return PolicyVerdict::no(format!("`{program}` is on this project's deny list."));
208    }
209    if policy.mode == PolicyMode::AllowAll {
210        return PolicyVerdict::yes();
211    }
212    if !policy.allow.iter().any(|rule| matches_rule(rule, &words)) {
213        let permitted = if policy.allow.is_empty() {
214            "nothing".to_owned()
215        } else {
216            policy.allow.join(", ")
217        };
218        return PolicyVerdict::no(format!(
219            "`{program}` is not on this project's allow list. Permitted: {permitted}."
220        ));
221    }
222    PolicyVerdict::yes()
223}
224
225pub fn matches_rule(rule: &str, words: &[&str]) -> bool {
226    let parts = tokenize(rule);
227    if parts.is_empty() {
228        return false;
229    }
230    let wildcard = parts.last() == Some(&"*");
231    let fixed = if wildcard {
232        &parts[..parts.len() - 1]
233    } else {
234        &parts[..]
235    };
236    if !wildcard && fixed.len() == 1 {
237        return words.first() == fixed.first();
238    }
239    if words.len() < fixed.len() || (!wildcard && words.len() != fixed.len()) {
240        return false;
241    }
242    fixed.iter().zip(words).all(|(a, b)| a == b)
243}
244
245pub fn render_policy_toml(local: &LocalCommandPolicy) -> String {
246    let mut lines = vec![
247        "# What agents may do in this project, on this machine.".to_owned(),
248        "#".to_owned(),
249        "# This file can only narrow what the project's policy already allows,".to_owned(),
250        "# never widen it. Every key is optional: leaving one out means this file".to_owned(),
251        "# has no opinion about it, which is not the same as asking for the".to_owned(),
252        "# strictest value.".to_owned(),
253        String::new(),
254    ];
255    if let Some(mode) = local.mode {
256        lines.push(format!("mode = \"{}\"", mode_name(mode)));
257    }
258    if let Some(values) = &local.allow {
259        lines.push(format!("allow = {}", render_list(values)));
260    }
261    if let Some(values) = &local.deny {
262        lines.push(format!("deny = {}", render_list(values)));
263    }
264    if let Some(value) = local.shell {
265        lines.push(format!("shell = {value}"));
266    }
267    if let Some(value) = local.approve {
268        lines.push(format!("approve = {value}"));
269    }
270    if let Some(values) = &local.tools {
271        lines.push(format!(
272            "tools = {}",
273            render_list(&values.iter().map(ToString::to_string).collect::<Vec<_>>())
274        ));
275    }
276    format!("{}\n", lines.join("\n"))
277}
278
279fn render_list(values: &[String]) -> String {
280    format!(
281        "[{}]",
282        values
283            .iter()
284            .map(|value| format!("{:?}", value))
285            .collect::<Vec<_>>()
286            .join(", ")
287    )
288}
289fn mode_name(mode: PolicyMode) -> &'static str {
290    match mode {
291        PolicyMode::AllowAll => "allow_all",
292        PolicyMode::AllowList => "allow_list",
293        PolicyMode::ReadOnly => "read_only",
294    }
295}
296fn stricter(a: PolicyMode, b: PolicyMode) -> PolicyMode {
297    if rank(a) >= rank(b) { a } else { b }
298}
299fn rank(mode: PolicyMode) -> u8 {
300    match mode {
301        PolicyMode::AllowAll => 0,
302        PolicyMode::AllowList => 1,
303        PolicyMode::ReadOnly => 2,
304    }
305}
306fn tokenize(value: &str) -> Vec<&str> {
307    value.split_whitespace().collect()
308}
309fn is_shell_syntax(c: char) -> bool {
310    ";&|<>$`(){}[]!*?~\n\r\\\"'".contains(c)
311}
312
313pub fn validate_tool_set(tools: &[ToolName]) -> bool {
314    let unique: HashSet<_> = tools.iter().collect();
315    unique.len() == tools.len()
316}