| 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 |