diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index ff18d982..73657cab 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -1,5 +1,7 @@ //! Config commands: config, settings, mode switches, trust, logout +use std::path::{Path, PathBuf}; + use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name}; use crate::palette; @@ -222,10 +224,117 @@ pub fn plan_mode(app: &mut App) -> CommandResult { ) } -/// Enable trust mode (file access outside workspace) -pub fn trust(app: &mut App) -> CommandResult { - app.trust_mode = true; - CommandResult::message("Trust mode enabled - can access files outside workspace") +/// Manage workspace-level trust and the per-path allowlist. +/// +/// Subcommands: +/// - `/trust` – show current state and trusted external paths +/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks) +/// - `/trust off` – disable workspace-level trust mode +/// - `/trust add ` – add a directory to the allowlist (#29) +/// - `/trust remove ` (alias `rm`) – remove a path from the allowlist +/// - `/trust list` – list trusted external paths for this workspace +pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + let mut parts = raw.splitn(2, char::is_whitespace); + let sub = parts.next().unwrap_or("").to_lowercase(); + let rest = parts.next().map(str::trim).unwrap_or(""); + let workspace = app.workspace.clone(); + + match sub.as_str() { + "" | "status" | "list" => trust_status(&workspace, app, sub == "list"), + "on" | "enable" | "yes" | "y" => { + app.trust_mode = true; + CommandResult::message( + "Workspace trust mode enabled — agent file tools can now read/write any path. \ + Use `/trust off` to revert; prefer `/trust add ` for a narrower opt-in.", + ) + } + "off" | "disable" | "no" | "n" => { + app.trust_mode = false; + CommandResult::message("Workspace trust mode disabled.") + } + "add" => trust_add(&workspace, rest), + "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), + other => CommandResult::error(format!( + "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add `, or `/trust remove `." + )), + } +} + +fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult { + let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace); + let mut lines = Vec::new(); + lines.push(format!( + "Workspace trust mode: {}", + if app.trust_mode { + "enabled" + } else { + "disabled" + } + )); + if trust.paths().is_empty() { + if force_paths { + lines.push("No external paths trusted from this workspace.".to_string()); + } else { + lines.push( + "No external paths trusted yet. Use `/trust add ` to allow a directory." + .to_string(), + ); + } + } else { + lines.push(format!("Trusted external paths ({}):", trust.paths().len())); + for path in trust.paths() { + lines.push(format!(" • {}", path.display())); + } + } + CommandResult::message(lines.join("\n")) +} + +fn trust_add(workspace: &Path, raw: &str) -> CommandResult { + if raw.is_empty() { + return CommandResult::error( + "Usage: /trust add . Supply an absolute path or a path relative to the workspace.", + ); + } + let path = PathBuf::from(expand_tilde(raw)); + if !path.exists() { + return CommandResult::error(format!( + "Path not found: {} — supply an existing directory or file.", + path.display() + )); + } + match crate::workspace_trust::add(workspace, &path) { + Ok(stored) => CommandResult::message(format!( + "Added to trust list for this workspace: {}", + stored.display() + )), + Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), + } +} + +fn trust_remove(workspace: &Path, raw: &str) -> CommandResult { + if raw.is_empty() { + return CommandResult::error("Usage: /trust remove "); + } + let path = PathBuf::from(expand_tilde(raw)); + match crate::workspace_trust::remove(workspace, &path) { + Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())), + Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())), + Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), + } +} + +fn expand_tilde(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix("~/") + && let Some(home) = dirs::home_dir() + { + return home.join(rest).to_string_lossy().into_owned(); + } else if raw == "~" + && let Some(home) = dirs::home_dir() + { + return home.to_string_lossy().into_owned(); + } + raw.to_string() } /// Logout - clear API key and return to onboarding @@ -509,16 +618,31 @@ mod tests { } #[test] - fn test_trust_enables_flag() { + fn test_trust_on_enables_flag() { let mut app = create_test_app(); assert!(!app.trust_mode); - let result = trust(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Trust mode enabled")); + let result = trust(&mut app, Some("on")); + let msg = result.message.expect("message"); + assert!(msg.contains("Workspace trust mode enabled")); assert!(app.trust_mode); } + #[test] + fn test_trust_status_default_lists_state() { + let mut app = create_test_app(); + let result = trust(&mut app, None); + let msg = result.message.expect("status message"); + assert!(msg.contains("Workspace trust mode")); + } + + #[test] + fn test_trust_add_requires_path() { + let mut app = create_test_app(); + let result = trust(&mut app, Some("add")); + let msg = result.message.expect("error message"); + assert!(msg.starts_with("Error:"), "got {msg:?}"); + } + #[test] fn test_logout_clears_api_key_state() { let _lock = lock_test_env(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 3a239715..0788aaee 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -241,8 +241,8 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "trust", aliases: &[], - description: "Enable trust mode (access files outside workspace)", - usage: "/trust", + description: "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)", + usage: "/trust [on|off|add |remove |list]", }, CommandInfo { name: "logout", @@ -358,7 +358,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "yolo" => config::yolo(app), "agent" => config::agent_mode(app), "plan" => config::plan_mode(app), - "trust" => config::trust(app), + "trust" => config::trust(app, arg), "logout" => config::logout(app), // Debug commands diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 513da80c..dacc6239 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2020,6 +2020,11 @@ impl Engine { } fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext { + // Load the per-workspace trusted-paths list (#29) on every tool-context + // build. Cheap (a small JSON file) and always reflects the latest + // `/trust add` / `/trust remove` mutations without an explicit cache + // refresh hook. + let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace); let ctx = ToolContext::with_auto_approve( self.session.workspace.clone(), self.session.trust_mode, @@ -2029,7 +2034,8 @@ impl Engine { ) .with_state_namespace(self.session.id.clone()) .with_features(self.config.features.clone()) - .with_shell_manager(self.shell_manager.clone()); + .with_shell_manager(self.shell_manager.clone()) + .with_trusted_external_paths(trusted.paths().to_vec()); if mode == AppMode::Yolo { ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4f3d0fec..50f2f8cb 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -51,6 +51,7 @@ mod tui; mod ui; mod utils; mod working_set; +mod workspace_trust; use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS}; use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind}; diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index b2509d33..20da482f 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -30,6 +30,11 @@ struct DiagnosticsOutput { sandbox_type: Option, rustc_version: Option, cargo_version: Option, + /// User-trusted external paths the agent may access from this workspace + /// (`/trust add ` from the slash command, persisted in + /// `~/.deepseek/workspace-trust.json`). See issue #29. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + trusted_external_paths: Vec, } #[derive(Debug, Clone, Default)] @@ -81,6 +86,11 @@ impl ToolSpec for DiagnosticsTool { let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string()); let sandbox_available = sandbox_type.is_some(); + let trusted_external_paths = context + .trusted_external_paths + .iter() + .map(|p| p.display().to_string()) + .collect(); let diagnostics = DiagnosticsOutput { workspace_root, current_dir, @@ -92,6 +102,7 @@ impl ToolSpec for DiagnosticsTool { sandbox_type, rustc_version: probe_version("rustc", &["--version"], &context.workspace), cargo_version: probe_version("cargo", &["--version"], &context.workspace), + trusted_external_paths, }; ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string())) diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index bcea64d9..955e5e12 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -198,6 +198,11 @@ pub struct ToolContext { pub features: Features, /// Namespace for tool state that should be scoped to the current session/thread. pub state_namespace: String, + /// User-trusted external paths the agent may read/write even when they + /// fall outside `workspace`. Loaded from `~/.deepseek/workspace-trust.json` + /// and refreshed when the user runs `/trust add `. Distinct from + /// `trust_mode`, which is the all-or-nothing legacy switch (#29). + pub trusted_external_paths: Vec, } impl ToolContext { @@ -219,6 +224,7 @@ impl ToolContext { auto_approve: false, features: Features::with_defaults(), state_namespace: "workspace".to_string(), + trusted_external_paths: Vec::new(), } } @@ -243,6 +249,7 @@ impl ToolContext { auto_approve: false, features: Features::with_defaults(), state_namespace: "workspace".to_string(), + trusted_external_paths: Vec::new(), } } @@ -267,9 +274,19 @@ impl ToolContext { auto_approve, features: Features::with_defaults(), state_namespace: "workspace".to_string(), + trusted_external_paths: Vec::new(), } } + /// Set the user's trusted external paths (loaded from + /// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for + /// how the list is consulted. + #[must_use] + pub fn with_trusted_external_paths(mut self, paths: Vec) -> Self { + self.trusted_external_paths = paths; + self + } + /// Resolve a path relative to workspace, validating it doesn't escape. /// /// This handles both existing files (using canonicalize) and non-existent files @@ -317,7 +334,10 @@ impl ToolContext { // hasn't been canonicalized yet let workspace_plain = normalize_path(&self.workspace); let candidate_normalized = normalize_path(&candidate); - if !candidate_normalized.starts_with(&workspace_plain) { + if !candidate_normalized.starts_with(&workspace_plain) + && !self.is_trusted_external_path(&candidate_canonical) + && !self.is_trusted_external_path(&candidate_normalized) + { return Err(ToolError::PathEscape { path: candidate_canonical, }); @@ -334,7 +354,9 @@ impl ToolContext { )) })?; - if !canonical.starts_with(&workspace_canonical) { + if !canonical.starts_with(&workspace_canonical) + && !self.is_trusted_external_path(&canonical) + { return Err(ToolError::PathEscape { path: canonical }); } @@ -376,9 +398,12 @@ impl ToolContext { } let canonical = normalize_path(&canonical); - // Validate it's under workspace + // Validate it's under workspace, OR is under a user-trusted external + // path (`/trust add ` from the slash command, persisted in + // `~/.deepseek/workspace-trust.json`). if !canonical.starts_with(&workspace_canonical) && !canonical.starts_with(&workspace_normalized) + && !self.is_trusted_external_path(&canonical) { return Err(ToolError::PathEscape { path: canonical }); } @@ -386,6 +411,14 @@ impl ToolContext { Ok(canonical) } + /// Whether `path` is under any of the user-trusted external roots. The + /// caller should pass an already-canonicalized (or normalized) path. + fn is_trusted_external_path(&self, path: &Path) -> bool { + self.trusted_external_paths + .iter() + .any(|trusted| path.starts_with(trusted)) + } + /// Set the trust mode. #[allow(dead_code)] pub fn with_trust_mode(mut self, trust: bool) -> Self { @@ -650,6 +683,39 @@ mod tests { assert!(result.is_ok()); } + /// Issue #29: paths under a user-trusted external directory resolve + /// successfully even though they fall outside the workspace, while + /// untrusted external paths still error with `PathEscape`. + #[test] + fn test_tool_context_trusted_external_path_allows_escape() { + let workspace = tempdir().expect("workspace tempdir"); + let trusted_root = tempdir().expect("trusted tempdir"); + let trusted_file = trusted_root.path().join("notes.md"); + std::fs::write(&trusted_file, "shared notes").unwrap(); + + let ctx = + ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![ + trusted_root + .path() + .canonicalize() + .unwrap_or_else(|_| trusted_root.path().to_path_buf()), + ]); + + let resolved = ctx + .resolve_path(trusted_file.to_str().unwrap()) + .expect("trusted path should resolve"); + assert!(resolved.ends_with("notes.md")); + + // Path outside workspace AND outside the trust list should still fail. + let other = tempdir().expect("untrusted tempdir"); + let other_file = other.path().join("secret.md"); + std::fs::write(&other_file, "x").unwrap(); + let err = ctx + .resolve_path(other_file.to_str().unwrap()) + .expect_err("untrusted path must error"); + assert!(matches!(err, ToolError::PathEscape { .. })); + } + #[test] fn test_required_str() { let input = json!({"name": "test", "count": 42}); diff --git a/crates/tui/src/workspace_trust.rs b/crates/tui/src/workspace_trust.rs new file mode 100644 index 00000000..b055d21e --- /dev/null +++ b/crates/tui/src/workspace_trust.rs @@ -0,0 +1,284 @@ +//! Per-workspace trust list of external paths the agent may read/write +//! without triggering a `PathEscape` error (#29). +//! +//! Storage: `~/.deepseek/workspace-trust.json`. The file is a JSON object +//! mapping each workspace's canonical path to a sorted list of canonical +//! paths the user has explicitly trusted from that workspace. Trust granted +//! in workspace A does not apply when running from workspace B. +//! +//! Threat model: this is a deliberate user opt-in to a path the workspace +//! sandbox would otherwise refuse. The only access the trust list grants is +//! through DeepSeek-TUI's own file tools (`read_file`, `write_file`, etc.) — +//! it does not loosen the OS sandbox profile (Seatbelt/Landlock) used for +//! shell commands. Sandbox-profile expansion is tracked separately so a +//! shell tool can opt into the same paths in a future release. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +const TRUST_FILE_NAME: &str = "workspace-trust.json"; + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct TrustFile { + /// Map workspace canonical path → sorted unique trusted paths. + #[serde(default)] + workspaces: BTreeMap>, +} + +/// In-memory trust list for a single workspace, snapshotted at load time. +/// Tools consult this snapshot to decide whether an out-of-workspace path +/// is permitted; the engine refreshes it after `/trust` mutations. +#[derive(Debug, Default, Clone)] +pub struct WorkspaceTrust { + paths: Vec, +} + +impl WorkspaceTrust { + #[must_use] + #[allow(dead_code)] + pub fn empty() -> Self { + Self { paths: Vec::new() } + } + + /// Load the trusted-paths snapshot for `workspace` from disk. Missing or + /// malformed files yield an empty list rather than an error so a corrupt + /// trust file never wedges the TUI; the next mutation rewrites it. + #[must_use] + pub fn load_for(workspace: &Path) -> Self { + match trust_file_path() { + Some(path) => Self::load_from_file(workspace, &path), + None => Self::empty(), + } + } + + fn load_from_file(workspace: &Path, file_path: &Path) -> Self { + let key = workspace_key(workspace); + let file = read_trust_file_at(file_path).unwrap_or_default(); + let paths = file + .workspaces + .get(&key) + .cloned() + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(); + Self { paths } + } + + /// Return the trusted paths in canonical form. + #[must_use] + pub fn paths(&self) -> &[PathBuf] { + &self.paths + } + + /// Whether the candidate is trusted: the candidate (after canonical + /// normalization) starts with one of the trusted prefixes. Directory + /// trust grants access to anything under the directory. + #[must_use] + #[allow(dead_code)] + pub fn permits(&self, candidate: &Path) -> bool { + let canonical = candidate + .canonicalize() + .unwrap_or_else(|_| candidate.to_path_buf()); + self.paths + .iter() + .any(|trusted| canonical.starts_with(trusted)) + } +} + +/// Add `path` to `workspace`'s trust list and persist. Returns the canonical +/// trusted path that was actually stored, so callers can echo it back to the +/// user. +pub fn add(workspace: &Path, path: &Path) -> Result { + let trust_path = trust_file_path() + .context("home directory not available; cannot persist workspace trust list")?; + add_at(workspace, path, &trust_path) +} + +fn add_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result { + let canonical = canonicalize_or_keep(path); + let key = workspace_key(workspace); + let mut file = read_trust_file_at(trust_path).unwrap_or_default(); + let entry = file.workspaces.entry(key).or_default(); + let stored = canonical.to_string_lossy().to_string(); + if !entry.iter().any(|p| p == &stored) { + entry.push(stored.clone()); + entry.sort(); + entry.dedup(); + } + write_trust_file_at(&file, trust_path)?; + Ok(canonical) +} + +/// Remove `path` from `workspace`'s trust list. Returns true when an entry +/// was actually removed. +pub fn remove(workspace: &Path, path: &Path) -> Result { + let Some(trust_path) = trust_file_path() else { + return Ok(false); + }; + remove_at(workspace, path, &trust_path) +} + +fn remove_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result { + let canonical = canonicalize_or_keep(path); + let key = workspace_key(workspace); + let mut file = read_trust_file_at(trust_path).unwrap_or_default(); + let stored = canonical.to_string_lossy().to_string(); + let removed = match file.workspaces.get_mut(&key) { + Some(entry) => { + let len_before = entry.len(); + entry.retain(|p| p != &stored); + let changed = entry.len() != len_before; + if entry.is_empty() { + file.workspaces.remove(&key); + } + changed + } + None => false, + }; + if removed { + write_trust_file_at(&file, trust_path)?; + } + Ok(removed) +} + +fn workspace_key(workspace: &Path) -> String { + canonicalize_or_keep(workspace) + .to_string_lossy() + .into_owned() +} + +fn canonicalize_or_keep(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn trust_file_path() -> Option { + dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME)) +} + +fn read_trust_file_at(path: &Path) -> Result { + if !path.exists() { + return Ok(TrustFile::default()); + } + let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display())) +} + +fn write_trust_file_at(file: &TrustFile, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir {}", parent.display()))?; + } + let json = serde_json::to_string_pretty(file).context("serialize trust file")?; + std::fs::write(path, json).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Set up an isolated fake `~/.deepseek/workspace-trust.json` location. + /// Returns the tmpdir (kept alive for the test) plus the explicit trust + /// file path passed to the `*_at` helpers — avoids touching `$HOME` so + /// tests run safely in parallel. + fn isolated_trust_path() -> (TempDir, PathBuf) { + let tmp = TempDir::new().expect("tempdir"); + let trust_path = tmp.path().join(".deepseek").join("workspace-trust.json"); + (tmp, trust_path) + } + + #[test] + fn empty_trust_for_unknown_workspace() { + let (tmp, trust_path) = isolated_trust_path(); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).unwrap(); + let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path); + assert!(trust.paths().is_empty()); + assert!(!trust.permits(Path::new("/anywhere"))); + } + + #[test] + fn add_persists_and_load_returns_path() { + let (tmp, trust_path) = isolated_trust_path(); + let workspace = tmp.path().join("ws"); + let other = tmp.path().join("data/notes"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + + let stored = add_at(&workspace, &other, &trust_path).expect("add"); + // On macOS, /var/folders is a symlink to /private/var/folders so the + // canonical form may live under that prefix. Compare using + // canonicalize on both ends. + let canonical_other = other.canonicalize().unwrap_or(other.clone()); + assert_eq!(stored, canonical_other); + + let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path); + assert_eq!(trust.paths().len(), 1); + // Create the file so canonicalize resolves through any symlinks; the + // stored trust path uses the canonical form. + let inner = other.join("file.md"); + std::fs::write(&inner, "x").unwrap(); + assert!(trust.permits(&inner)); + assert!(!trust.permits(Path::new("/etc/passwd"))); + } + + #[test] + fn add_is_idempotent() { + let (tmp, trust_path) = isolated_trust_path(); + let workspace = tmp.path().join("ws"); + let other = tmp.path().join("data/notes"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + + let _ = add_at(&workspace, &other, &trust_path).unwrap(); + let _ = add_at(&workspace, &other, &trust_path).unwrap(); + let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path); + assert_eq!(trust.paths().len(), 1); + } + + #[test] + fn trust_is_workspace_scoped() { + let (tmp, trust_path) = isolated_trust_path(); + let ws_a = tmp.path().join("ws-a"); + let ws_b = tmp.path().join("ws-b"); + let other = tmp.path().join("data/notes"); + std::fs::create_dir_all(&ws_a).unwrap(); + std::fs::create_dir_all(&ws_b).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + + add_at(&ws_a, &other, &trust_path).unwrap(); + assert_eq!( + WorkspaceTrust::load_from_file(&ws_a, &trust_path) + .paths() + .len(), + 1 + ); + assert_eq!( + WorkspaceTrust::load_from_file(&ws_b, &trust_path) + .paths() + .len(), + 0 + ); + } + + #[test] + fn remove_deletes_path() { + let (tmp, trust_path) = isolated_trust_path(); + let workspace = tmp.path().join("ws"); + let other = tmp.path().join("data/notes"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + + add_at(&workspace, &other, &trust_path).unwrap(); + let removed = remove_at(&workspace, &other, &trust_path).unwrap(); + assert!(removed); + + let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path); + assert!(trust.paths().is_empty()); + } +}