返回 CodeWhale
workspace_trust.rs
根目录 / crates / tui / src / workspace_trust.rs
1 //! Per-workspace trust list of external paths the agent may read/write
2 //! without triggering a `PathEscape` error (#29).
3 //!
4 //! Storage: `~/.deepseek/workspace-trust.json`. The file is a JSON object
5 //! mapping each workspace's canonical path to a sorted list of canonical
6 //! paths the user has explicitly trusted from that workspace. Trust granted
7 //! in workspace A does not apply when running from workspace B.
8 //!
9 //! Threat model: this is a deliberate user opt-in to a path the workspace
10 //! sandbox would otherwise refuse. The only access the trust list grants is
11 //! through CodeWhale's own file tools (`read_file`, `write_file`, etc.) —
12 //! it does not loosen the OS sandbox profile (Seatbelt/bubblewrap) used for
13 //! shell commands. Sandbox-profile expansion is tracked separately so a
14 //! shell tool can opt into the same paths in a future release.
15
16 use std::collections::BTreeMap;
17 use std::path::{Path, PathBuf};
18
19 use anyhow::{Context, Result};
20 use serde::{Deserialize, Serialize};
21
22 use crate::utils::write_atomic;
23
24 const TRUST_FILE_NAME: &str = "workspace-trust.json";
25
26 #[derive(Debug, Default, Clone, Serialize, Deserialize)]
27 struct TrustFile {
28 /// Map workspace canonical path → sorted unique trusted paths.
29 #[serde(default)]
30 workspaces: BTreeMap<String, Vec<String>>,
31 }
32
33 /// In-memory trust list for a single workspace, snapshotted at load time.
34 /// Tools consult this snapshot to decide whether an out-of-workspace path
35 /// is permitted; the engine refreshes it after `/trust` mutations.
36 #[derive(Debug, Default, Clone)]
37 pub struct WorkspaceTrust {
38 paths: Vec<PathBuf>,
39 }
40
41 impl WorkspaceTrust {
42 #[must_use]
43 pub fn empty() -> Self {
44 Self { paths: Vec::new() }
45 }
46
47 /// Load the trusted-paths snapshot for `workspace` from disk. Missing or
48 /// malformed files yield an empty list rather than an error so a corrupt
49 /// trust file never wedges the TUI; the next mutation rewrites it.
50 #[must_use]
51 pub fn load_for(workspace: &Path) -> Self {
52 match trust_file_path() {
53 Some(path) => Self::load_from_file(workspace, &path),
54 None => Self::empty(),
55 }
56 }
57
58 fn load_from_file(workspace: &Path, file_path: &Path) -> Self {
59 let key = workspace_key(workspace);
60 let file = read_trust_file_at(file_path).unwrap_or_default();
61 let paths = file
62 .workspaces
63 .get(&key)
64 .cloned()
65 .unwrap_or_default()
66 .into_iter()
67 .map(PathBuf::from)
68 .collect();
69 Self { paths }
70 }
71
72 /// Return the trusted paths in canonical form.
73 #[must_use]
74 pub fn paths(&self) -> &[PathBuf] {
75 &self.paths
76 }
77
78 /// Whether the candidate is trusted: the candidate (after canonical
79 /// normalization) starts with one of the trusted prefixes. Directory
80 /// trust grants access to anything under the directory.
81 #[must_use]
82 #[cfg_attr(not(test), expect(dead_code))]
83 pub fn permits(&self, candidate: &Path) -> bool {
84 let canonical = candidate
85 .canonicalize()
86 .unwrap_or_else(|_| candidate.to_path_buf());
87 self.paths
88 .iter()
89 .any(|trusted| canonical.starts_with(trusted))
90 }
91 }
92
93 /// Add `path` to `workspace`'s trust list and persist. Returns the canonical
94 /// trusted path that was actually stored, so callers can echo it back to the
95 /// user.
96 pub fn add(workspace: &Path, path: &Path) -> Result<PathBuf> {
97 let trust_path = trust_file_path()
98 .context("home directory not available; cannot persist workspace trust list")?;
99 add_at(workspace, path, &trust_path)
100 }
101
102 fn add_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<PathBuf> {
103 let canonical = canonicalize_or_keep(path);
104 let key = workspace_key(workspace);
105 let mut file = read_trust_file_at(trust_path).unwrap_or_default();
106 let entry = file.workspaces.entry(key).or_default();
107 let stored = canonical.to_string_lossy().to_string();
108 if !entry.iter().any(|p| p == &stored) {
109 entry.push(stored.clone());
110 entry.sort();
111 entry.dedup();
112 }
113 write_trust_file_at(&file, trust_path)?;
114 Ok(canonical)
115 }
116
117 /// Remove `path` from `workspace`'s trust list. Returns true when an entry
118 /// was actually removed.
119 pub fn remove(workspace: &Path, path: &Path) -> Result<bool> {
120 let Some(trust_path) = trust_file_path() else {
121 return Ok(false);
122 };
123 remove_at(workspace, path, &trust_path)
124 }
125
126 fn remove_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<bool> {
127 let canonical = canonicalize_or_keep(path);
128 let key = workspace_key(workspace);
129 let mut file = read_trust_file_at(trust_path).unwrap_or_default();
130 let stored = canonical.to_string_lossy().to_string();
131 let removed = match file.workspaces.get_mut(&key) {
132 Some(entry) => {
133 let len_before = entry.len();
134 entry.retain(|p| p != &stored);
135 let changed = entry.len() != len_before;
136 if entry.is_empty() {
137 file.workspaces.remove(&key);
138 }
139 changed
140 }
141 None => false,
142 };
143 if removed {
144 write_trust_file_at(&file, trust_path)?;
145 }
146 Ok(removed)
147 }
148
149 fn workspace_key(workspace: &Path) -> String {
150 canonicalize_or_keep(workspace)
151 .to_string_lossy()
152 .into_owned()
153 }
154
155 fn canonicalize_or_keep(path: &Path) -> PathBuf {
156 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
157 }
158
159 fn trust_file_path() -> Option<PathBuf> {
160 codewhale_config::ensure_state_dir(".")
161 .ok()
162 .map(|dir| dir.join(TRUST_FILE_NAME))
163 }
164
165 fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
166 if !path.exists() {
167 return Ok(TrustFile::default());
168 }
169 let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
170 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
171 }
172
173 fn write_trust_file_at(file: &TrustFile, path: &Path) -> Result<()> {
174 if let Some(parent) = path.parent() {
175 std::fs::create_dir_all(parent)
176 .with_context(|| format!("create dir {}", parent.display()))?;
177 }
178 let json = serde_json::to_string_pretty(file).context("serialize trust file")?;
179 write_atomic(path, json.as_bytes()).with_context(|| format!("write {}", path.display()))?;
180 Ok(())
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use tempfile::TempDir;
187
188 /// Set up an isolated fake `~/.deepseek/workspace-trust.json` location.
189 /// Returns the tmpdir (kept alive for the test) plus the explicit trust
190 /// file path passed to the `*_at` helpers — avoids touching `$HOME` so
191 /// tests run safely in parallel.
192 fn isolated_trust_path() -> (TempDir, PathBuf) {
193 let tmp = TempDir::new().expect("tempdir");
194 let trust_path = tmp.path().join(".deepseek").join("workspace-trust.json");
195 (tmp, trust_path)
196 }
197
198 #[test]
199 fn empty_trust_for_unknown_workspace() {
200 let (tmp, trust_path) = isolated_trust_path();
201 let workspace = tmp.path().join("ws");
202 std::fs::create_dir_all(&workspace).unwrap();
203 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
204 assert!(trust.paths().is_empty());
205 assert!(!trust.permits(Path::new("/anywhere")));
206 }
207
208 #[test]
209 fn add_persists_and_load_returns_path() {
210 let (tmp, trust_path) = isolated_trust_path();
211 let workspace = tmp.path().join("ws");
212 let other = tmp.path().join("data/notes");
213 std::fs::create_dir_all(&workspace).unwrap();
214 std::fs::create_dir_all(&other).unwrap();
215
216 let stored = add_at(&workspace, &other, &trust_path).expect("add");
217 // On macOS, /var/folders is a symlink to /private/var/folders so the
218 // canonical form may live under that prefix. Compare using
219 // canonicalize on both ends.
220 let canonical_other = other.canonicalize().unwrap_or(other.clone());
221 assert_eq!(stored, canonical_other);
222
223 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
224 assert_eq!(trust.paths().len(), 1);
225 // Create the file so canonicalize resolves through any symlinks; the
226 // stored trust path uses the canonical form.
227 let inner = other.join("file.md");
228 std::fs::write(&inner, "x").unwrap();
229 assert!(trust.permits(&inner));
230 assert!(!trust.permits(Path::new("/etc/passwd")));
231 }
232
233 #[test]
234 fn add_is_idempotent() {
235 let (tmp, trust_path) = isolated_trust_path();
236 let workspace = tmp.path().join("ws");
237 let other = tmp.path().join("data/notes");
238 std::fs::create_dir_all(&workspace).unwrap();
239 std::fs::create_dir_all(&other).unwrap();
240
241 let _ = add_at(&workspace, &other, &trust_path).unwrap();
242 let _ = add_at(&workspace, &other, &trust_path).unwrap();
243 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
244 assert_eq!(trust.paths().len(), 1);
245 }
246
247 #[test]
248 fn trust_is_workspace_scoped() {
249 let (tmp, trust_path) = isolated_trust_path();
250 let ws_a = tmp.path().join("ws-a");
251 let ws_b = tmp.path().join("ws-b");
252 let other = tmp.path().join("data/notes");
253 std::fs::create_dir_all(&ws_a).unwrap();
254 std::fs::create_dir_all(&ws_b).unwrap();
255 std::fs::create_dir_all(&other).unwrap();
256
257 add_at(&ws_a, &other, &trust_path).unwrap();
258 assert_eq!(
259 WorkspaceTrust::load_from_file(&ws_a, &trust_path)
260 .paths()
261 .len(),
262 1
263 );
264 assert_eq!(
265 WorkspaceTrust::load_from_file(&ws_b, &trust_path)
266 .paths()
267 .len(),
268 0
269 );
270 }
271
272 #[test]
273 fn remove_deletes_path() {
274 let (tmp, trust_path) = isolated_trust_path();
275 let workspace = tmp.path().join("ws");
276 let other = tmp.path().join("data/notes");
277 std::fs::create_dir_all(&workspace).unwrap();
278 std::fs::create_dir_all(&other).unwrap();
279
280 add_at(&workspace, &other, &trust_path).unwrap();
281 let removed = remove_at(&workspace, &other, &trust_path).unwrap();
282 assert!(removed);
283
284 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
285 assert!(trust.paths().is_empty());
286 }
287 }
288
288 lines RUST