Skip to main content

apollo/tools/
sandbox.rs

1use std::path::{Component, Path, PathBuf};
2
3fn workspace_canonical(workspace: &Path) -> PathBuf {
4    workspace
5        .canonicalize()
6        .unwrap_or_else(|_| workspace.to_path_buf())
7}
8
9fn validate_within_workspace(
10    workspace: &Path,
11    resolved: &Path,
12    original: &str,
13) -> anyhow::Result<PathBuf> {
14    let canonical_workspace = workspace_canonical(workspace);
15    if !resolved.starts_with(&canonical_workspace) {
16        anyhow::bail!(
17            "Access denied: path '{}' is outside the workspace.",
18            original
19        );
20    }
21    Ok(resolved.to_path_buf())
22}
23
24/// Files inside the workspace that hold apollo's own credentials or runtime
25/// configuration. Prompt injection reaches the file tools, so these are denied
26/// for both reads and writes even though they sit within the boundary.
27const DENIED_FILE_NAMES: &[&str] = &[".env", "apollo.json"];
28
29/// Name prefixes denied the same way — `.env.local`, `.env.production`, …
30const DENIED_FILE_PREFIXES: &[&str] = &[".env."];
31
32/// Directories denied anywhere in a resolved path, relative to the home
33/// directory. `~/.apollo` holds the HTTP token that drives the agent.
34const DENIED_HOME_DIRS: &[&str] = &[".apollo"];
35
36/// Directories inside the *workspace* that apollo itself loads code and state
37/// from: `.apollo/skills/*/SKILL.md` is executed through `sh -c` on the next
38/// skill load, and `.apollo/plugins/manifest.json` names what gets loaded. A
39/// write there turns prompt injection into reboot-persistent code execution,
40/// so writes are refused. Reads stay allowed — skill and plugin loading use
41/// `std::fs` directly and never pass through this module, so nothing apollo
42/// needs is affected, and a denied read would only stop the model inspecting
43/// its own non-secret skills.
44const DENIED_WORKSPACE_WRITE_DIRS: &[&str] = &[".apollo"];
45
46fn ensure_write_not_denied(
47    workspace: &Path,
48    resolved: &Path,
49    original: &str,
50) -> anyhow::Result<()> {
51    let canonical_workspace = workspace_canonical(workspace);
52    for dir in DENIED_WORKSPACE_WRITE_DIRS {
53        if resolved.starts_with(canonical_workspace.join(dir)) {
54            anyhow::bail!(
55                "Access denied: '{}' is inside the workspace's {} directory, which holds \
56                 apollo's own skills, plugins and state.",
57                original,
58                dir
59            );
60        }
61    }
62    Ok(())
63}
64
65fn ensure_not_denied(resolved: &Path, original: &str) -> anyhow::Result<()> {
66    if let Some(name) = resolved.file_name().and_then(|n| n.to_str()) {
67        let denied = DENIED_FILE_NAMES.contains(&name)
68            || DENIED_FILE_PREFIXES
69                .iter()
70                .any(|prefix| name.starts_with(prefix));
71        if denied {
72            anyhow::bail!(
73                "Access denied: '{}' holds apollo's own credentials or configuration.",
74                original
75            );
76        }
77    }
78
79    if let Some(home) = dirs::home_dir() {
80        for dir in DENIED_HOME_DIRS {
81            let denied_root = home.join(dir);
82            if resolved.starts_with(&denied_root) {
83                anyhow::bail!(
84                    "Access denied: '{}' is inside {}, which holds apollo's own credentials.",
85                    original,
86                    denied_root.display()
87                );
88            }
89        }
90    }
91
92    Ok(())
93}
94
95fn resolve_write_candidate(requested: &Path) -> PathBuf {
96    let mut prefix = if requested.is_absolute() {
97        PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
98    } else {
99        PathBuf::new()
100    };
101    let mut canonical_prefix = prefix.clone();
102    let mut remainder = PathBuf::new();
103    let mut saw_missing_component = false;
104
105    for component in requested.components() {
106        match component {
107            Component::RootDir | Component::Prefix(_) => {}
108            Component::CurDir => {}
109            Component::ParentDir => {
110                if saw_missing_component {
111                    remainder.pop();
112                } else {
113                    prefix.pop();
114                    if prefix.exists() {
115                        canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
116                    }
117                }
118            }
119            Component::Normal(part) => {
120                if saw_missing_component {
121                    remainder.push(part);
122                    continue;
123                }
124
125                prefix.push(part);
126                if prefix.exists() {
127                    canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
128                } else {
129                    saw_missing_component = true;
130                    remainder.push(part);
131                }
132            }
133        }
134    }
135
136    if saw_missing_component {
137        canonical_prefix.join(remainder)
138    } else {
139        canonical_prefix
140    }
141}
142
143pub fn resolve_workspace_existing_path(
144    workspace: &Path,
145    raw_path: &str,
146) -> anyhow::Result<PathBuf> {
147    let path = raw_path.trim();
148    if path.is_empty() {
149        anyhow::bail!("Path is required");
150    }
151    if path.starts_with('~') {
152        anyhow::bail!("Home directory expansion (~) is disabled for security.");
153    }
154
155    let requested = if Path::new(path).is_absolute() {
156        PathBuf::from(path)
157    } else {
158        workspace.join(path)
159    };
160    let resolved = requested
161        .canonicalize()
162        .map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", raw_path, e))?;
163
164    ensure_not_denied(&resolved, raw_path)?;
165    validate_within_workspace(workspace, &resolved, raw_path)
166}
167
168pub fn resolve_workspace_write_path(workspace: &Path, raw_path: &str) -> anyhow::Result<PathBuf> {
169    let path = raw_path.trim();
170    if path.is_empty() {
171        anyhow::bail!("Path is required");
172    }
173    if path.starts_with('~') {
174        anyhow::bail!("Home directory expansion (~) is disabled for security.");
175    }
176
177    let requested = if Path::new(path).is_absolute() {
178        PathBuf::from(path)
179    } else {
180        workspace.join(path)
181    };
182    let resolved = resolve_write_candidate(&requested);
183    ensure_not_denied(&resolved, raw_path)?;
184    ensure_write_not_denied(workspace, &resolved, raw_path)?;
185    validate_within_workspace(workspace, &resolved, raw_path)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn rejects_absolute_escape() {
194        let tmp = tempfile::tempdir().unwrap();
195        let ws = tmp.path().join("workspace");
196        std::fs::create_dir_all(&ws).unwrap();
197        let err = resolve_workspace_write_path(&ws, "/etc/passwd").unwrap_err();
198        assert!(err.to_string().contains("outside the workspace"));
199    }
200
201    #[test]
202    fn refuses_to_read_workspace_secrets() {
203        let tmp = tempfile::tempdir().unwrap();
204        let ws = tmp.path().join("workspace");
205        std::fs::create_dir_all(&ws).unwrap();
206        for name in [".env", ".env.local", "apollo.json"] {
207            std::fs::write(ws.join(name), "secret").unwrap();
208            let err = resolve_workspace_existing_path(&ws, name).unwrap_err();
209            assert!(err.to_string().contains("Access denied"), "{name}: {err}");
210        }
211    }
212
213    #[test]
214    fn refuses_to_write_workspace_secrets() {
215        let tmp = tempfile::tempdir().unwrap();
216        let ws = tmp.path().join("workspace");
217        std::fs::create_dir_all(&ws).unwrap();
218        for name in [".env", ".env.production", "apollo.json", "sub/.env"] {
219            let err = resolve_workspace_write_path(&ws, name).unwrap_err();
220            assert!(err.to_string().contains("Access denied"), "{name}: {err}");
221        }
222    }
223
224    #[test]
225    fn allows_ordinary_workspace_files() {
226        let tmp = tempfile::tempdir().unwrap();
227        let ws = tmp.path().join("workspace");
228        std::fs::create_dir_all(&ws).unwrap();
229        std::fs::write(ws.join("notes.md"), "hello").unwrap();
230        resolve_workspace_existing_path(&ws, "notes.md").unwrap();
231        resolve_workspace_write_path(&ws, "sub/new.txt").unwrap();
232        resolve_workspace_write_path(&ws, "environment.json").unwrap();
233    }
234
235    #[test]
236    fn refuses_to_write_anywhere_under_the_workspace_apollo_dir() {
237        let tmp = tempfile::tempdir().unwrap();
238        let ws = tmp.path().join("workspace");
239        std::fs::create_dir_all(&ws).unwrap();
240        for path in [
241            ".apollo/skills/evil/SKILL.md",
242            ".apollo/plugins/manifest.json",
243            ".apollo/state.surreal",
244            ".apollo/graph_memory.json",
245            "./.apollo/skills/x/SKILL.md",
246            "sub/../.apollo/skills/x/SKILL.md",
247        ] {
248            let err = resolve_workspace_write_path(&ws, path).unwrap_err();
249            assert!(err.to_string().contains("Access denied"), "{path}: {err}");
250        }
251        let absolute = ws.join(".apollo/skills/evil/SKILL.md");
252        let err = resolve_workspace_write_path(&ws, absolute.to_str().unwrap()).unwrap_err();
253        assert!(err.to_string().contains("Access denied"), "{err}");
254    }
255
256    /// The deny is scoped to the directory itself, not to any name containing
257    /// it, and ordinary work in the workspace is untouched.
258    #[test]
259    fn apollo_deny_does_not_swallow_neighbouring_names() {
260        let tmp = tempfile::tempdir().unwrap();
261        let ws = tmp.path().join("workspace");
262        std::fs::create_dir_all(&ws).unwrap();
263        resolve_workspace_write_path(&ws, ".apollo-notes.md").unwrap();
264        resolve_workspace_write_path(&ws, "src/apollo/main.rs").unwrap();
265        resolve_workspace_write_path(&ws, "docs/.apollos/x.md").unwrap();
266    }
267
268    /// Skill loading reads `.apollo/skills` with `std::fs`, so denying writes
269    /// must not deny reads.
270    #[test]
271    fn reads_under_workspace_apollo_still_work() {
272        let tmp = tempfile::tempdir().unwrap();
273        let ws = tmp.path().join("workspace");
274        std::fs::create_dir_all(ws.join(".apollo/skills/demo")).unwrap();
275        std::fs::write(ws.join(".apollo/skills/demo/SKILL.md"), "# demo").unwrap();
276        resolve_workspace_existing_path(&ws, ".apollo/skills/demo/SKILL.md").unwrap();
277    }
278
279    #[test]
280    fn rejects_relative_traversal() {
281        let tmp = tempfile::tempdir().unwrap();
282        let ws = tmp.path().join("workspace");
283        std::fs::create_dir_all(&ws).unwrap();
284        let outside = tmp.path().join("secrets.txt");
285        std::fs::write(&outside, "secret").unwrap();
286        let err = resolve_workspace_existing_path(&ws, "../secrets.txt").unwrap_err();
287        assert!(err.to_string().contains("outside the workspace"));
288    }
289}