Skip to main content

scema_tools/
workspace.rs

1//! [`Workspace`]: the answer to *where*, and the only thing allowed to answer it.
2//!
3//! The CLI has an operator typing paths, which needs no confinement — they can already read
4//! their own disk. The daemon and the MCP server do not: their callers are a browser
5//! extension and a language model, and "observe this directory" from either of those is an
6//! instruction from somewhere the operator is not looking. A model asked to audit a project
7//! will cheerfully propose observing `~/.ssh`, and the observer would do it.
8//!
9//! So both of those front ends resolve every path through this type and nothing else. It is
10//! the same split `alchem-link` settled on, and the same two rules:
11//!
12//! * **Resolve first, compare second.** Paths are fully canonicalised — symlinks followed,
13//!   `..` collapsed — and *then* checked against the roots. A string scan for `..` passes a
14//!   symlink that points at `/`, which is the whole attack.
15//! * **A tool that opens a path directly bypasses the model.** There is no partial
16//!   application of this; if a front end resolves one path itself, the confinement is
17//!   decorative.
18//!
19//! Note what this does *not* do. It says nothing about *whether* an action is allowed —
20//! that is `Goal`'s constraints and, when there is ever a write path, an approval policy.
21//! Where and whether are different questions and the failure mode of merging them is that a
22//! grant for one silently becomes a grant for the other.
23
24use std::path::{Path, PathBuf};
25
26use anyhow::{anyhow, Result};
27
28/// A set of directories a front end may look inside.
29#[derive(Clone, Debug)]
30pub struct Workspace {
31    roots: Vec<PathBuf>,
32}
33
34impl Workspace {
35    /// Build from candidate roots, dropping any that do not resolve.
36    ///
37    /// A root that does not exist is dropped rather than fatal, but an empty result *is*
38    /// fatal: a workspace with no roots would either confine nothing or confine everything
39    /// depending on how the check is written, and neither is a state to start a daemon in.
40    pub fn new<I, P>(roots: I) -> Result<Self>
41    where
42        I: IntoIterator<Item = P>,
43        P: AsRef<Path>,
44    {
45        let resolved: Vec<PathBuf> = roots
46            .into_iter()
47            .filter_map(|r| std::fs::canonicalize(r.as_ref()).ok())
48            .collect();
49        if resolved.is_empty() {
50            return Err(anyhow!(
51                "no readable workspace root; refusing to start with nothing to confine to"
52            ));
53        }
54        Ok(Workspace { roots: resolved })
55    }
56
57    /// The current working directory as the sole root.
58    pub fn cwd() -> Result<Self> {
59        Workspace::new([std::env::current_dir()?])
60    }
61
62    pub fn roots(&self) -> &[PathBuf] {
63        &self.roots
64    }
65
66    /// Human-readable roots, for a `/policy` response or an MCP tool description.
67    pub fn root_labels(&self) -> Vec<String> {
68        self.roots.iter().map(|p| strip_verbatim(p)).collect()
69    }
70
71    /// Resolve a caller-supplied locator, or refuse.
72    ///
73    /// The error deliberately names the roots. A confinement failure the caller cannot
74    /// diagnose gets worked around by turning confinement off.
75    pub fn resolve(&self, locator: &str) -> Result<PathBuf> {
76        if locator.trim().is_empty() {
77            return Err(anyhow!("empty path"));
78        }
79        let candidate = Path::new(locator);
80        // Relative paths resolve against the first root, not the process working directory.
81        // A daemon's cwd is not something the caller can see, so resolving against it would
82        // make the same request mean different things depending on how it was started.
83        let joined = if candidate.is_absolute() {
84            candidate.to_path_buf()
85        } else {
86            self.roots[0].join(candidate)
87        };
88        let resolved = std::fs::canonicalize(&joined)
89            .map_err(|e| anyhow!("cannot resolve `{locator}`: {e}"))?;
90
91        if self.roots.iter().any(|r| resolved.starts_with(r)) {
92            Ok(resolved)
93        } else {
94            Err(anyhow!(
95                "`{locator}` resolves to `{}`, which is outside this workspace ({})",
96                strip_verbatim(&resolved),
97                self.root_labels().join(", ")
98            ))
99        }
100    }
101}
102
103/// Strip the Windows extended-length prefix for display. See `repo::display_path`.
104fn strip_verbatim(p: &Path) -> String {
105    let s = p.to_string_lossy().to_string();
106    match s.strip_prefix(r"\\?\") {
107        Some(rest) => rest.to_string(),
108        None => s,
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs;
116
117    fn scratch() -> PathBuf {
118        let p = std::env::temp_dir().join(format!(
119            "scema-omni-ws-{}-{}",
120            std::process::id(),
121            std::time::SystemTime::now()
122                .duration_since(std::time::UNIX_EPOCH)
123                .unwrap()
124                .as_nanos()
125        ));
126        fs::create_dir_all(&p).unwrap();
127        fs::canonicalize(&p).unwrap()
128    }
129
130    #[test]
131    fn a_path_inside_a_root_resolves() {
132        let root = scratch();
133        fs::create_dir_all(root.join("sub")).unwrap();
134        let ws = Workspace::new([&root]).unwrap();
135        assert!(ws.resolve("sub").is_ok());
136        assert!(ws.resolve(root.join("sub").to_str().unwrap()).is_ok());
137        fs::remove_dir_all(&root).ok();
138    }
139
140    #[test]
141    fn dot_dot_cannot_climb_out() {
142        let root = scratch();
143        fs::create_dir_all(root.join("sub")).unwrap();
144        let ws = Workspace::new([root.join("sub")]).unwrap();
145        let err = ws.resolve("..").unwrap_err().to_string();
146        assert!(err.contains("outside this workspace"), "got {err}");
147        fs::remove_dir_all(&root).ok();
148    }
149
150    #[test]
151    fn an_absolute_path_elsewhere_is_refused_and_the_error_names_the_roots() {
152        // An error the caller cannot diagnose gets worked around by disabling confinement.
153        let root = scratch();
154        let ws = Workspace::new([&root]).unwrap();
155        let outside = std::env::temp_dir();
156        let err = ws.resolve(outside.to_str().unwrap()).unwrap_err().to_string();
157        assert!(err.contains("outside this workspace"));
158        assert!(err.contains(&strip_verbatim(&root)), "got {err}");
159        fs::remove_dir_all(&root).ok();
160    }
161
162    #[test]
163    #[cfg(unix)]
164    fn a_symlink_pointing_out_is_refused() {
165        // The case a string scan for `..` misses entirely, which is why resolution happens
166        // before the comparison.
167        let root = scratch();
168        let inside = root.join("inside");
169        fs::create_dir_all(&inside).unwrap();
170        let target = scratch();
171        std::os::unix::fs::symlink(&target, inside.join("escape")).unwrap();
172        let ws = Workspace::new([&inside]).unwrap();
173        assert!(ws.resolve("escape").is_err(), "a symlink out is still out");
174        fs::remove_dir_all(&root).ok();
175        fs::remove_dir_all(&target).ok();
176    }
177
178    #[test]
179    fn a_relative_path_resolves_against_the_root_not_the_process_cwd() {
180        // The daemon's cwd is invisible to the caller; resolving against it would make one
181        // request mean different things depending on how the daemon was launched.
182        let root = scratch();
183        fs::create_dir_all(root.join("marker")).unwrap();
184        let ws = Workspace::new([&root]).unwrap();
185        let got = ws.resolve("marker").unwrap();
186        assert!(got.starts_with(&root));
187        fs::remove_dir_all(&root).ok();
188    }
189
190    #[test]
191    fn a_workspace_with_no_readable_root_refuses_to_exist() {
192        assert!(Workspace::new(["definitely-not-here-4f2a"]).is_err());
193    }
194
195    #[test]
196    fn a_missing_path_inside_a_root_is_an_error_not_a_silent_pass() {
197        let root = scratch();
198        let ws = Workspace::new([&root]).unwrap();
199        let err = ws.resolve("no-such-dir").unwrap_err().to_string();
200        assert!(err.contains("cannot resolve"), "got {err}");
201        fs::remove_dir_all(&root).ok();
202    }
203}