返回 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 #[allow(dead_code)]
44 pub fn empty() -> Self {
45 Self { paths: Vec::new() }
46 }
47
48 /// Load the trusted-paths snapshot for `workspace` from disk. Missing or
49 /// malformed files yield an empty list rather than an error so a corrupt
50 /// trust file never wedges the TUI; the next mutation rewrites it.
51 #[must_use]
52 pub fn load_for(workspace: &Path) -> Self {
53 match trust_file_path() {
54 Some(path) => Self::load_from_file(workspace, &path),
55 None => Self::empty(),
56 }
57 }
58
59 fn load_from_file(workspace: &Path, file_path: &Path) -> Self {
60 let key = workspace_key(workspace);
61 let file = read_trust_file_at(file_path).unwrap_or_default();
62 let paths = file
63 .workspaces
64 .get(&key)
65 .cloned()
66 .unwrap_or_default()
67 .into_iter()
68 .map(PathBuf::from)
69 .collect();
70 Self { paths }
71 }
72
73 /// Return the trusted paths in canonical form.
74 #[must_use]
75 pub fn paths(&self) -> &[PathBuf] {
76 &self.paths
77 }
78
79 /// Whether the candidate is trusted: the candidate (after canonical
80 /// normalization) starts with one of the trusted prefixes. Directory
81 /// trust grants access to anything under the directory.
82 #[must_use]
83 #[allow(dead_code)]
84 pub fn permits(&self, candidate: &Path) -> bool {
85 let canonical = candidate
86 .canonicalize()
87 .unwrap_or_else(|_| candidate.to_path_buf());
88 self.paths
89 .iter()
90 .any(|trusted| canonical.starts_with(trusted))
91 }
92 }
93
94 /// Add `path` to `workspace`'s trust list and persist. Returns the canonical
95 /// trusted path that was actually stored, so callers can echo it back to the
96 /// user.
97 pub fn add(workspace: &Path, path: &Path) -> Result<PathBuf> {
98 let trust_path = trust_file_path()
99 .context("home directory not available; cannot persist workspace trust list")?;
100 add_at(workspace, path, &trust_path)
101 }
102
103 fn add_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<PathBuf> {
104 let canonical = canonicalize_or_keep(path);
105 let key = workspace_key(workspace);
106 let mut file = read_trust_file_at(trust_path).unwrap_or_default();
107 let entry = file.workspaces.entry(key).or_default();
108 let stored = canonical.to_string_lossy().to_string();
109 if !entry.iter().any(|p| p == &stored) {
110 entry.push(stored.clone());
111 entry.sort();
112 entry.dedup();
113 }
114 write_trust_file_at(&file, trust_path)?;
115 Ok(canonical)
116 }
117
118 /// Remove `path` from `workspace`'s trust list. Returns true when an entry
119 /// was actually removed.
120 pub fn remove(workspace: &Path, path: &Path) -> Result<bool> {
121 let Some(trust_path) = trust_file_path() else {
122 return Ok(false);
123 };
124 remove_at(workspace, path, &trust_path)
125 }
126
127 fn remove_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<bool> {
128 let canonical = canonicalize_or_keep(path);
129 let key = workspace_key(workspace);
130 let mut file = read_trust_file_at(trust_path).unwrap_or_default();
131 let stored = canonical.to_string_lossy().to_string();
132 let removed = match file.workspaces.get_mut(&key) {
133 Some(entry) => {
134 let len_before = entry.len();
135 entry.retain(|p| p != &stored);
136 let changed = entry.len() != len_before;
137 if entry.is_empty() {
138 file.workspaces.remove(&key);
139 }
140 changed
141 }
142 None => false,
143 };
144 if removed {
145 write_trust_file_at(&file, trust_path)?;
146 }
147 Ok(removed)
148 }
149
150 fn workspace_key(workspace: &Path) -> String {
151 canonicalize_or_keep(workspace)
152 .to_string_lossy()
153 .into_owned()
154 }
155
156 fn canonicalize_or_keep(path: &Path) -> PathBuf {
157 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
158 }
159
160 fn trust_file_path() -> Option<PathBuf> {
161 codewhale_config::ensure_state_dir(".")
162 .ok()
163 .map(|dir| dir.join(TRUST_FILE_NAME))
164 }
165
166 fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
167 if !path.exists() {
168 return Ok(TrustFile::default());
169 }
170 let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
171 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
172 }
173
174 fn write_trust_file_at(file: &TrustFile, path: &Path) -> Result<()> {
175 if let Some(parent) = path.parent() {
176 std::fs::create_dir_all(parent)
177 .with_context(|| format!("create dir {}", parent.display()))?;
178 }
179 let json = serde_json::to_string_pretty(file).context("serialize trust file")?;
180 write_atomic(path, json.as_bytes()).with_context(|| format!("write {}", path.display()))?;
181 Ok(())
182 }
183
184 #[cfg(test)]
185 mod tests {
186 use super::*;
187 use tempfile::TempDir;
188
189 /// Set up an isolated fake `~/.deepseek/workspace-trust.json` location.
190 /// Returns the tmpdir (kept alive for the test) plus the explicit trust
191 /// file path passed to the `*_at` helpers — avoids touching `$HOME` so
192 /// tests run safely in parallel.
193 fn isolated_trust_path() -> (TempDir, PathBuf) {
194 let tmp = TempDir::new().expect("tempdir");
195 let trust_path = tmp.path().join(".deepseek").join("workspace-trust.json");
196 (tmp, trust_path)
197 }
198
199 #[test]
200 fn empty_trust_for_unknown_workspace() {
201 let (tmp, trust_path) = isolated_trust_path();
202 let workspace = tmp.path().join("ws");
203 std::fs::create_dir_all(&workspace).unwrap();
204 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
205 assert!(trust.paths().is_empty());
206 assert!(!trust.permits(Path::new("/anywhere")));
207 }
208
209 #[test]
210 fn add_persists_and_load_returns_path() {
211 let (tmp, trust_path) = isolated_trust_path();
212 let workspace = tmp.path().join("ws");
213 let other = tmp.path().join("data/notes");
214 std::fs::create_dir_all(&workspace).unwrap();
215 std::fs::create_dir_all(&other).unwrap();
216
217 let stored = add_at(&workspace, &other, &trust_path).expect("add");
218 // On macOS, /var/folders is a symlink to /private/var/folders so the
219 // canonical form may live under that prefix. Compare using
220 // canonicalize on both ends.
221 let canonical_other = other.canonicalize().unwrap_or(other.clone());
222 assert_eq!(stored, canonical_other);
223
224 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
225 assert_eq!(trust.paths().len(), 1);
226 // Create the file so canonicalize resolves through any symlinks; the
227 // stored trust path uses the canonical form.
228 let inner = other.join("file.md");
229 std::fs::write(&inner, "x").unwrap();
230 assert!(trust.permits(&inner));
231 assert!(!trust.permits(Path::new("/etc/passwd")));
232 }
233
234 #[test]
235 fn add_is_idempotent() {
236 let (tmp, trust_path) = isolated_trust_path();
237 let workspace = tmp.path().join("ws");
238 let other = tmp.path().join("data/notes");
239 std::fs::create_dir_all(&workspace).unwrap();
240 std::fs::create_dir_all(&other).unwrap();
241
242 let _ = add_at(&workspace, &other, &trust_path).unwrap();
243 let _ = add_at(&workspace, &other, &trust_path).unwrap();
244 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
245 assert_eq!(trust.paths().len(), 1);
246 }
247
248 #[test]
249 fn trust_is_workspace_scoped() {
250 let (tmp, trust_path) = isolated_trust_path();
251 let ws_a = tmp.path().join("ws-a");
252 let ws_b = tmp.path().join("ws-b");
253 let other = tmp.path().join("data/notes");
254 std::fs::create_dir_all(&ws_a).unwrap();
255 std::fs::create_dir_all(&ws_b).unwrap();
256 std::fs::create_dir_all(&other).unwrap();
257
258 add_at(&ws_a, &other, &trust_path).unwrap();
259 assert_eq!(
260 WorkspaceTrust::load_from_file(&ws_a, &trust_path)
261 .paths()
262 .len(),
263 1
264 );
265 assert_eq!(
266 WorkspaceTrust::load_from_file(&ws_b, &trust_path)
267 .paths()
268 .len(),
269 0
270 );
271 }
272
273 #[test]
274 fn remove_deletes_path() {
275 let (tmp, trust_path) = isolated_trust_path();
276 let workspace = tmp.path().join("ws");
277 let other = tmp.path().join("data/notes");
278 std::fs::create_dir_all(&workspace).unwrap();
279 std::fs::create_dir_all(&other).unwrap();
280
281 add_at(&workspace, &other, &trust_path).unwrap();
282 let removed = remove_at(&workspace, &other, &trust_path).unwrap();
283 assert!(removed);
284
285 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
286 assert!(trust.paths().is_empty());
287 }
288 }
289
289 lines RUST