Skip to main content

termesh_workspace/
permissions.rs

1//! Exact, workspace-scoped grants for agent-managed terminal commands (ADR-0008 §5).
2
3use 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    /// Lexically normalized path relative to the workspace, or `.` for its root.
17    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    /// Remember one exact safe command. Returns false when the command is unsafe.
49    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    /// A workspace root the host platform agrees is absolute.
272    ///
273    /// `/proj` is *rooted* on Windows but not *absolute* — that needs a drive prefix — and
274    /// [`clean_absolute`] rightly refuses a non-absolute cwd, since a relative one cannot be
275    /// checked for containment. Hardcoding `/proj` therefore made every grant assertion here
276    /// fail on Windows while passing everywhere else.
277    const ROOT: &str = if cfg!(windows) { r"C:\proj" } else { "/proj" };
278
279    fn root() -> &'static Path {
280        Path::new(ROOT)
281    }
282
283    /// `ROOT` joined with a workspace-relative path, in the host's own separator.
284    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}