返回 DeepSeek-TUI-2026
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 DeepSeek-TUI's own file tools (`read_file`, `write_file`, etc.) —
12 //! it does not loosen the OS sandbox profile (Seatbelt/Landlock) 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 dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME))
162 }
163
164 fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
165 if !path.exists() {
166 return Ok(TrustFile::default());
167 }
168 let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
169 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
170 }
171
172 fn write_trust_file_at(file: &TrustFile, path: &Path) -> Result<()> {
173 if let Some(parent) = path.parent() {
174 std::fs::create_dir_all(parent)
175 .with_context(|| format!("create dir {}", parent.display()))?;
176 }
177 let json = serde_json::to_string_pretty(file).context("serialize trust file")?;
178 write_atomic(path, json.as_bytes()).with_context(|| format!("write {}", path.display()))?;
179 Ok(())
180 }
181
182 #[cfg(test)]
183 mod tests {
184 use super::*;
185 use tempfile::TempDir;
186
187 /// Set up an isolated fake `~/.deepseek/workspace-trust.json` location.
188 /// Returns the tmpdir (kept alive for the test) plus the explicit trust
189 /// file path passed to the `*_at` helpers — avoids touching `$HOME` so
190 /// tests run safely in parallel.
191 fn isolated_trust_path() -> (TempDir, PathBuf) {
192 let tmp = TempDir::new().expect("tempdir");
193 let trust_path = tmp.path().join(".deepseek").join("workspace-trust.json");
194 (tmp, trust_path)
195 }
196
197 #[test]
198 fn empty_trust_for_unknown_workspace() {
199 let (tmp, trust_path) = isolated_trust_path();
200 let workspace = tmp.path().join("ws");
201 std::fs::create_dir_all(&workspace).unwrap();
202 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
203 assert!(trust.paths().is_empty());
204 assert!(!trust.permits(Path::new("/anywhere")));
205 }
206
207 #[test]
208 fn add_persists_and_load_returns_path() {
209 let (tmp, trust_path) = isolated_trust_path();
210 let workspace = tmp.path().join("ws");
211 let other = tmp.path().join("data/notes");
212 std::fs::create_dir_all(&workspace).unwrap();
213 std::fs::create_dir_all(&other).unwrap();
214
215 let stored = add_at(&workspace, &other, &trust_path).expect("add");
216 // On macOS, /var/folders is a symlink to /private/var/folders so the
217 // canonical form may live under that prefix. Compare using
218 // canonicalize on both ends.
219 let canonical_other = other.canonicalize().unwrap_or(other.clone());
220 assert_eq!(stored, canonical_other);
221
222 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
223 assert_eq!(trust.paths().len(), 1);
224 // Create the file so canonicalize resolves through any symlinks; the
225 // stored trust path uses the canonical form.
226 let inner = other.join("file.md");
227 std::fs::write(&inner, "x").unwrap();
228 assert!(trust.permits(&inner));
229 assert!(!trust.permits(Path::new("/etc/passwd")));
230 }
231
232 #[test]
233 fn add_is_idempotent() {
234 let (tmp, trust_path) = isolated_trust_path();
235 let workspace = tmp.path().join("ws");
236 let other = tmp.path().join("data/notes");
237 std::fs::create_dir_all(&workspace).unwrap();
238 std::fs::create_dir_all(&other).unwrap();
239
240 let _ = add_at(&workspace, &other, &trust_path).unwrap();
241 let _ = add_at(&workspace, &other, &trust_path).unwrap();
242 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
243 assert_eq!(trust.paths().len(), 1);
244 }
245
246 #[test]
247 fn trust_is_workspace_scoped() {
248 let (tmp, trust_path) = isolated_trust_path();
249 let ws_a = tmp.path().join("ws-a");
250 let ws_b = tmp.path().join("ws-b");
251 let other = tmp.path().join("data/notes");
252 std::fs::create_dir_all(&ws_a).unwrap();
253 std::fs::create_dir_all(&ws_b).unwrap();
254 std::fs::create_dir_all(&other).unwrap();
255
256 add_at(&ws_a, &other, &trust_path).unwrap();
257 assert_eq!(
258 WorkspaceTrust::load_from_file(&ws_a, &trust_path)
259 .paths()
260 .len(),
261 1
262 );
263 assert_eq!(
264 WorkspaceTrust::load_from_file(&ws_b, &trust_path)
265 .paths()
266 .len(),
267 0
268 );
269 }
270
271 #[test]
272 fn remove_deletes_path() {
273 let (tmp, trust_path) = isolated_trust_path();
274 let workspace = tmp.path().join("ws");
275 let other = tmp.path().join("data/notes");
276 std::fs::create_dir_all(&workspace).unwrap();
277 std::fs::create_dir_all(&other).unwrap();
278
279 add_at(&workspace, &other, &trust_path).unwrap();
280 let removed = remove_at(&workspace, &other, &trust_path).unwrap();
281 assert!(removed);
282
283 let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
284 assert!(trust.paths().is_empty());
285 }
286 }
287
287 lines RUST