Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/workspace.rs

8.4 KiB, 1 run

created by r2519314175:1001, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Per-user workspace — the sandboxed directory the agent operates in.
2//!
3//! A workspace is a single directory on the Daimond host. All agent file
4//! operations resolve through `resolve()`, which jails paths to the
5//! workspace root. In the trusted, self-hosted environment (plan D0)
6//! this is an *accident* guardrail — keeping the agent inside the
7//! workspace by default — not a hardened *attack* boundary.
8//!
9//! The `resolve` / `display_rel` path logic is pure and target-agnostic.
10//! The backing store is `std::fs`, which compiles on wasm32 but returns
11//! "unsupported" at runtime — the browser filesystem is OPFS.
12// TODO(wasm-opfs): back `Workspace` (and the file tools in `tools.rs`)
13// with an OPFS-backed store on wasm32. This requires an async fs
14// surface (OPFS access is async), so it is deferred to the browser
15// tool-execution stage rather than bolted on here.
16
17use oxedyne_fe2o3_core::prelude::*;
18
19use std::path::{Component, Path, PathBuf};
20
21
22/// A sandboxed working directory for one user.
23#[derive(Clone, Debug)]
24pub struct Workspace {
25 /// Canonical absolute path to the workspace root.
26 root: PathBuf,
27}
28
29impl Workspace {
30
31 /// Open (creating if necessary) a workspace rooted at `root`.
32 pub fn new(root: PathBuf) -> Outcome<Self> {
33 if !root.exists() {
34 res!(std::fs::create_dir_all(&root)
35 .map_err(|e| err!(e, "Workspace: create root {:?} failed.", root; IO, File)));
36 }
37 let root = res!(std::fs::canonicalize(&root)
38 .map_err(|e| err!(e, "Workspace: canonicalise {:?} failed.", root; IO, File)));
39 Ok(Self { root })
40 }
41
42 /// Construct a workspace from an already-trusted root without
43 /// touching the filesystem.
44 ///
45 /// [`new`](Self::new) canonicalises the root against `std::fs`, which
46 /// is unavailable at runtime on `wasm32` (the browser store is OPFS).
47 /// This constructor stores the path verbatim, for callers that supply
48 /// a canonical root or back the workspace with a non-`std::fs` store.
49 /// Path jailing in [`resolve`](Self::resolve) is purely lexical and
50 /// remains sound regardless of the backing store.
51 pub fn unchecked(root: PathBuf) -> Self {
52 Self { root }
53 }
54
55 /// The workspace root path.
56 pub fn root(&self) -> &Path {
57 &self.root
58 }
59
60 /// Resolve a workspace-relative path to an absolute path, jailed to
61 /// the root. Absolute inputs and `..` traversal that escapes the
62 /// root are rejected. The path is built lexically (no filesystem
63 /// access), then checked to remain within the root.
64 pub fn resolve(&self, rel: &str) -> Outcome<PathBuf> {
65 let rel = rel.trim_start_matches('/');
66 let mut out = self.root.clone();
67 for comp in Path::new(rel).components() {
68 match comp {
69 Component::Normal(c) => out.push(c),
70 Component::CurDir => {},
71 Component::ParentDir => {
72 // Pop, but never above the root.
73 if !out.pop() || !out.starts_with(&self.root) {
74 return Err(err!(
75 "Workspace: path '{}' escapes the workspace.", rel;
76 Invalid, Input, Path));
77 }
78 }
79 Component::RootDir | Component::Prefix(_) => {
80 return Err(err!(
81 "Workspace: absolute path '{}' is not allowed.", rel;
82 Invalid, Input, Path));
83 }
84 }
85 }
86 if !out.starts_with(&self.root) {
87 return Err(err!(
88 "Workspace: path '{}' escapes the workspace.", rel;
89 Invalid, Input, Path));
90 }
91 Ok(out)
92 }
93
94 /// Display a resolved path as a workspace-relative string (for
95 /// user-facing tool output). Falls back to the full path if the
96 /// path is somehow outside the root.
97 pub fn display_rel(&self, p: &Path) -> String {
98 match p.strip_prefix(&self.root) {
99 Ok(r) => {
100 let s = r.to_string_lossy().to_string();
101 if s.is_empty() { ".".to_string() } else { s }
102 }
103 Err(_) => p.to_string_lossy().to_string(),
104 }
105 }
106}
107
108
109// ┌───────────────────────────────────────────────────────────────┐
110// │ Tests │
111// └───────────────────────────────────────────────────────────────┘
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 /// A workspace rooted on a scratch directory of this call's own.
118 ///
119 /// Under the user cache rather than `std::env::temp_dir()`: `/tmp` is a tmpfs
120 /// here, so a fixture written there is resident memory charged to the test
121 /// binary, and the fixtures left by earlier runs are swept as this one is made.
122 fn tmp_ws() -> Workspace {
123 let dir = match oxedyne_fe2o3_test::scratch::scratch_dir("daimond_ws_test") {
124 Ok(d) => d,
125 Err(e) => panic!("a scratch directory: {}", e),
126 };
127 Workspace::new(dir).expect("workspace")
128 }
129
130 #[test]
131 fn test_resolve_normal() {
132 let ws = tmp_ws();
133 let p = ws.resolve("sub/file.txt").expect("resolve");
134 assert!(p.starts_with(ws.root()));
135 assert!(p.ends_with("sub/file.txt"));
136 }
137
138 #[test]
139 fn test_resolve_leading_slash_treated_relative() {
140 let ws = tmp_ws();
141 let p = ws.resolve("/etc/passwd").expect("resolve");
142 assert!(p.starts_with(ws.root()));
143 assert!(p.ends_with("etc/passwd"));
144 }
145
146 #[test]
147 fn test_resolve_escape_rejected() {
148 let ws = tmp_ws();
149 assert!(ws.resolve("../../../etc/passwd").is_err());
150 assert!(ws.resolve("a/../../b").is_err());
151 }
152
153 #[test]
154 fn test_resolve_curdir_and_reentry_ok() {
155 let ws = tmp_ws();
156 assert!(ws.resolve("./a/b").is_ok());
157 // Leaves a subdir then re-enters the root — stays inside.
158 assert!(ws.resolve("a/../b").is_ok());
159 }
160
161 #[test]
162 fn test_no_path_a_caller_can_write_ever_resolves_outside_the_root() {
163 // A PROPERTY, where the three tests above are outcomes, and the difference was
164 // measured rather than argued. On 2026-08-28 `dev/mutate.mjs` deleted the final
165 // `starts_with` guard from `resolve` and all 857 tests stayed green: the named
166 // cases above are each caught earlier, in the loop, so nothing was left holding
167 // the guarantee the guard exists for. What the fence promises is not "these two
168 // strings are refused" but "nothing a caller can write comes back pointing
169 // outside the root", so that is what is asserted, over everything a model or a
170 // user has plausibly typed.
171 //
172 // Note what this does NOT claim. It does not kill that mutation, because the
173 // loop really does catch every one of these before the guard is reached; the
174 // guard is defence in depth against a future edit to the loop, and no test can
175 // pin an unreachable line. What this pins is the loop's own guarantee, so that
176 // an edit which relaxes it is caught here instead of nowhere.
177 let ws = tmp_ws();
178 let hostile = [
179 "..",
180 "../",
181 "../..",
182 "../etc/passwd",
183 "a/../..",
184 "a/b/../../..",
185 "./../..",
186 "a/./../../b",
187 "/../etc/passwd",
188 "//../..",
189 "a//..//..//b",
190 "....//",
191 "a/../a/../a/../..",
192 ];
193 for rel in hostile {
194 match ws.resolve(rel) {
195 Ok(p) => assert!(p.starts_with(ws.root()),
196 "'{}' resolved to '{}', which is outside '{}'",
197 rel, p.display(), ws.root().display()),
198 // A refusal is the other correct answer; this asks only that a
199 // SUCCESS is always inside.
200 Err(_) => {}
201 }
202 }
203 }
204
205 #[test]
206 fn test_display_rel() {
207 let ws = tmp_ws();
208 let p = ws.resolve("x/y.rs").expect("resolve");
209 assert_eq!(ws.display_rel(&p), "x/y.rs");
210 assert_eq!(ws.display_rel(ws.root()), ".");
211 }
212}