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}