1use std::path::{Component, Path, PathBuf};
4
5use termesh_core::TerminalSpec;
6use termesh_filesystem::{FileSystemService, FsError};
7use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
8
9const SETTINGS_DIR: &str = ".termesh";
10const SETTINGS_FILE: &str = "workspace.toml";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CommandGrant {
14 pub program: String,
15 pub args: Vec<String>,
16 pub cwd: PathBuf,
18}
19
20impl CommandGrant {
21 pub fn from_spec(root: &Path, spec: &TerminalSpec) -> Option<Self> {
22 if spec.program.is_empty() || !spec.env.is_empty() {
23 return None;
24 }
25 let root = clean_absolute(root)?;
26 let cwd = clean_absolute(&spec.cwd)?;
27 let relative = cwd.strip_prefix(&root).ok()?;
28 let relative = if relative.as_os_str().is_empty() {
29 PathBuf::from(".")
30 } else {
31 relative.to_path_buf()
32 };
33 Some(Self { program: spec.program.clone(), args: spec.args.clone(), cwd: relative })
34 }
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct PermissionPolicy {
39 grants: Vec<CommandGrant>,
40 dirty: bool,
41}
42
43impl PermissionPolicy {
44 pub fn permits(&self, root: &Path, spec: &TerminalSpec) -> bool {
45 CommandGrant::from_spec(root, spec).is_some_and(|grant| self.grants.contains(&grant))
46 }
47
48 pub fn remember(&mut self, root: &Path, spec: &TerminalSpec) -> bool {
50 let Some(grant) = CommandGrant::from_spec(root, spec) else {
51 return false;
52 };
53 if !self.grants.contains(&grant) {
54 self.grants.push(grant);
55 self.dirty = true;
56 }
57 true
58 }
59
60 pub fn grants(&self) -> &[CommandGrant] {
61 &self.grants
62 }
63
64 pub fn is_dirty(&self) -> bool {
65 self.dirty
66 }
67
68 pub fn mark_saved(&mut self) {
69 self.dirty = false;
70 }
71}
72
73pub trait PermissionStore: Send + Sync {
74 fn load(&self, root: &Path) -> Result<PermissionPolicy, FsError>;
75 fn save(&self, root: &Path, policy: &PermissionPolicy) -> Result<(), FsError>;
76}
77
78pub struct FilePermissionStore<'a> {
79 fs: &'a dyn FileSystemService,
80}
81
82impl<'a> FilePermissionStore<'a> {
83 pub fn new(fs: &'a dyn FileSystemService) -> Self {
84 Self { fs }
85 }
86}
87
88impl PermissionStore for FilePermissionStore<'_> {
89 fn load(&self, root: &Path) -> Result<PermissionPolicy, FsError> {
90 let path = settings_path(root);
91 let bytes = match self.fs.read_file(&path) {
92 Ok(bytes) => bytes,
93 Err(FsError::NotFound(_)) => return Ok(PermissionPolicy::default()),
94 Err(error) => return Err(error),
95 };
96 let text = String::from_utf8(bytes)
97 .map_err(|_| config_error(&path, "workspace settings are not valid UTF-8"))?;
98 let document = parse_document(&path, &text)?;
99 policy_from_document(root, &path, &document)
100 }
101
102 fn save(&self, root: &Path, policy: &PermissionPolicy) -> Result<(), FsError> {
103 let path = settings_path(root);
104 let mut document = match self.fs.read_file(&path) {
105 Ok(bytes) => {
106 let text = String::from_utf8(bytes)
107 .map_err(|_| config_error(&path, "workspace settings are not valid UTF-8"))?;
108 parse_document(&path, &text)?
109 }
110 Err(FsError::NotFound(_)) => DocumentMut::new(),
111 Err(error) => return Err(error),
112 };
113 replace_commands(&path, &mut document, &policy.grants)?;
114 self.fs.create_dir(&root.join(SETTINGS_DIR))?;
115 self.fs.write_file(&path, document.to_string().as_bytes())
116 }
117}
118
119fn settings_path(root: &Path) -> PathBuf {
120 root.join(SETTINGS_DIR).join(SETTINGS_FILE)
121}
122
123fn parse_document(path: &Path, text: &str) -> Result<DocumentMut, FsError> {
124 text.parse::<DocumentMut>().map_err(|error| config_error(path, error.to_string()))
125}
126
127fn policy_from_document(
128 root: &Path,
129 path: &Path,
130 document: &DocumentMut,
131) -> Result<PermissionPolicy, FsError> {
132 let Some(agent) = document.get("agent") else {
133 return Ok(PermissionPolicy::default());
134 };
135 let agent = agent.as_table().ok_or_else(|| config_error(path, "agent must be a table"))?;
136 let Some(permissions) = agent.get("permissions") else {
137 return Ok(PermissionPolicy::default());
138 };
139 let permissions = permissions
140 .as_table()
141 .ok_or_else(|| config_error(path, "agent.permissions must be a table"))?;
142 let Some(commands) = permissions.get("commands") else {
143 return Ok(PermissionPolicy::default());
144 };
145 let commands = commands.as_array_of_tables().ok_or_else(|| {
146 config_error(path, "agent.permissions.commands must be an array of tables")
147 })?;
148
149 let mut policy = PermissionPolicy::default();
150 for (index, table) in commands.iter().enumerate() {
151 if table.iter().any(|(key, _)| !matches!(key, "program" | "args" | "cwd")) {
152 return Err(config_error(path, format!("command {index} has an unknown field")));
153 }
154 let program = table
155 .get("program")
156 .and_then(Item::as_str)
157 .ok_or_else(|| config_error(path, format!("command {index} needs a string program")))?;
158 let args = table
159 .get("args")
160 .and_then(Item::as_array)
161 .ok_or_else(|| config_error(path, format!("command {index} needs an args array")))?;
162 let args: Vec<String> = args
163 .iter()
164 .map(|argument| {
165 argument.as_str().map(str::to_owned).ok_or_else(|| {
166 config_error(path, format!("command {index} args must be strings"))
167 })
168 })
169 .collect::<Result<_, _>>()?;
170 let stored_cwd = table
171 .get("cwd")
172 .and_then(Item::as_str)
173 .ok_or_else(|| config_error(path, format!("command {index} needs a string cwd")))?;
174 let relative = clean_relative(Path::new(stored_cwd)).ok_or_else(|| {
175 config_error(path, format!("command {index} cwd must stay inside the workspace"))
176 })?;
177 let spec = TerminalSpec {
178 program: program.into(),
179 args,
180 cwd: root.join(&relative),
181 env: Vec::new(),
182 };
183 let grant = CommandGrant::from_spec(root, &spec)
184 .ok_or_else(|| config_error(path, format!("command {index} is unsafe")))?;
185 if !policy.grants.contains(&grant) {
186 policy.grants.push(grant);
187 }
188 }
189 Ok(policy)
190}
191
192fn replace_commands(
193 path: &Path,
194 document: &mut DocumentMut,
195 grants: &[CommandGrant],
196) -> Result<(), FsError> {
197 if document.get("agent").is_some_and(|item| !item.is_table()) {
198 return Err(config_error(path, "agent must be a table"));
199 }
200 if document.get("agent").is_none() {
201 document["agent"] = Item::Table(Table::new());
202 }
203 let agent = document["agent"].as_table_mut().expect("agent table created above");
204 if agent.get("permissions").is_some_and(|item| !item.is_table()) {
205 return Err(config_error(path, "agent.permissions must be a table"));
206 }
207 if agent.get("permissions").is_none() {
208 agent["permissions"] = Item::Table(Table::new());
209 }
210 let permissions = agent["permissions"].as_table_mut().expect("permissions table created above");
211
212 let mut commands = ArrayOfTables::new();
213 for grant in grants {
214 let mut table = Table::new();
215 table["program"] = value(grant.program.clone());
216 let mut args = Array::new();
217 for argument in &grant.args {
218 args.push(argument.as_str());
219 }
220 table["args"] = value(args);
221 table["cwd"] = value(grant.cwd.to_string_lossy().into_owned());
222 commands.push(table);
223 }
224 permissions["commands"] = Item::ArrayOfTables(commands);
225 Ok(())
226}
227
228fn clean_absolute(path: &Path) -> Option<PathBuf> {
229 if !path.is_absolute() {
230 return None;
231 }
232 let mut clean = PathBuf::new();
233 for component in path.components() {
234 match component {
235 Component::ParentDir => return None,
236 Component::CurDir => {}
237 Component::Prefix(prefix) => clean.push(prefix.as_os_str()),
238 Component::RootDir => clean.push(component.as_os_str()),
239 Component::Normal(part) => clean.push(part),
240 }
241 }
242 Some(clean)
243}
244
245fn clean_relative(path: &Path) -> Option<PathBuf> {
246 if path.is_absolute() {
247 return None;
248 }
249 let mut clean = PathBuf::new();
250 for component in path.components() {
251 match component {
252 Component::Normal(part) => clean.push(part),
253 Component::CurDir => {}
254 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
255 }
256 }
257 Some(if clean.as_os_str().is_empty() { PathBuf::from(".") } else { clean })
258}
259
260fn config_error(path: &Path, message: impl Into<String>) -> FsError {
261 FsError::Other { path: path.to_path_buf(), message: message.into() }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use std::path::Path;
268 use termesh_filesystem::FileSystemService;
269 use termesh_test_support::FakeFileSystem;
270
271 const ROOT: &str = if cfg!(windows) { r"C:\proj" } else { "/proj" };
278
279 fn root() -> &'static Path {
280 Path::new(ROOT)
281 }
282
283 fn under_root(relative: &str) -> std::path::PathBuf {
285 relative.split('/').fold(root().to_path_buf(), |acc, part| acc.join(part))
286 }
287
288 fn spec(
289 program: &str,
290 args: &[&str],
291 cwd: &str,
292 env: &[(&str, &str)],
293 ) -> termesh_core::TerminalSpec {
294 termesh_core::TerminalSpec {
295 program: program.into(),
296 args: args.iter().map(|arg| (*arg).into()).collect(),
297 cwd: cwd.into(),
298 env: env.iter().map(|(key, value)| ((*key).into(), (*value).into())).collect(),
299 }
300 }
301
302 fn cargo_test_policy() -> PermissionPolicy {
303 let mut policy = PermissionPolicy::default();
304 assert!(policy.remember(root(), &spec("cargo", &["test"], ROOT, &[])));
305 policy
306 }
307
308 #[test]
309 fn only_exact_safe_commands_can_be_remembered() {
310 let root = root();
311 let safe = spec("cargo", &["test"], ROOT, &[]);
312 let env = spec("cargo", &["test"], ROOT, &[("TOKEN", "secret")]);
313 let outside = spec("cargo", &["test"], "/tmp", &[]);
314 let traversal = spec("cargo", &["test"], under_root("src/../src").to_str().unwrap(), &[]);
315
316 assert!(CommandGrant::from_spec(root, &safe).is_some());
317 assert!(CommandGrant::from_spec(root, &env).is_none());
318 assert!(CommandGrant::from_spec(root, &outside).is_none());
319 assert!(CommandGrant::from_spec(root, &traversal).is_none());
320 }
321
322 #[test]
323 fn policy_matches_program_arguments_and_workspace_relative_cwd_exactly() {
324 let root = root();
325 let mut policy = PermissionPolicy::default();
326 let allowed = spec("cargo", &["test"], ROOT, &[]);
327 assert!(policy.remember(root, &allowed));
328 assert!(policy.permits(root, &allowed));
329 assert!(!policy.permits(root, &spec("cargo", &["test", "--all"], ROOT, &[])));
330 assert!(!policy
331 .permits(root, &spec("cargo", &["test"], under_root("sub").to_str().unwrap(), &[])));
332 assert!(policy.is_dirty());
333 policy.mark_saved();
334 assert!(!policy.is_dirty());
335 }
336
337 #[test]
338 fn saving_preserves_unrelated_keys_and_comments() {
339 let fs = FakeFileSystem::new();
340 fs.add_file(
341 under_root(".termesh/workspace.toml"),
342 b"# mine\n[tasks]\ndefault = \"test\"\n",
343 );
344 let store = FilePermissionStore::new(&fs);
345
346 store.save(root(), &cargo_test_policy()).unwrap();
347
348 let text = String::from_utf8(
349 fs.read_file(under_root(".termesh/workspace.toml").as_path()).unwrap(),
350 )
351 .unwrap();
352 assert!(text.contains("# mine"));
353 assert!(text.contains("[tasks]"));
354 assert!(text.contains("[[agent.permissions.commands]]"));
355 }
356
357 #[test]
358 fn saved_policy_round_trips_without_becoming_dirty() {
359 let fs = FakeFileSystem::new();
360 fs.add_dir(root());
361 let store = FilePermissionStore::new(&fs);
362 store.save(root(), &cargo_test_policy()).unwrap();
363
364 let loaded = store.load(root()).unwrap();
365
366 assert!(loaded.permits(root(), &spec("cargo", &["test"], ROOT, &[])));
367 assert!(!loaded.is_dirty());
368 }
369
370 #[test]
371 fn malformed_or_unsafe_config_is_rejected_and_never_overwritten() {
372 let fs = FakeFileSystem::new();
373 let path = under_root(".termesh/workspace.toml");
374 fs.add_file(
375 &path,
376 b"[[agent.permissions.commands]]\nprogram = \"cargo\"\ncwd = \"../tmp\"\nargs = []\n",
377 );
378 let before = fs.read_file(&path).unwrap();
379 let store = FilePermissionStore::new(&fs);
380
381 assert!(store.load(root()).is_err());
382 assert_eq!(fs.read_file(&path).unwrap(), before);
383 }
384}