feat(#29): per-workspace trust list with /trust slash command
Adds a persistent allowlist of external paths the agent may read/write
from outside the current workspace, scoped to the workspace it was
granted in. The list lives in ~/.deepseek/workspace-trust.json with
schema {"workspaces": {"<ws>": ["<trusted>", ...]}}; canonical paths on
both sides keep symlink-aliased macOS tempdirs sane.
Surface area:
* crates/tui/src/workspace_trust.rs — new module: load_for / add /
remove plus *_at variants for tests that need an explicit file path
rather than HOME mutation.
* tools/spec.rs — ToolContext gains trusted_external_paths and
resolve_path consults it before returning PathEscape, both for the
existing-path branch and the to-be-created (parent-canonical) branch.
* core/engine.rs — build_tool_context loads the trust snapshot on every
tool dispatch so /trust mutations apply on the next call.
* commands/config.rs — /trust now takes subcommands (add, remove,
list, on, off, status) instead of being a single all-or-nothing
toggle. Tilde expansion handled in-line.
* commands/mod.rs — registry entry updated with the new usage string
and a dispatcher that forwards args.
* tools/diagnostics.rs — adds trusted_external_paths to the JSON
output so the agent and the user can see the list at a glance.
The interactive "Allow once / Always allow / Deny" prompt that the
issue describes is deferred — for v0.5.1 the workflow is "grant
ahead with /trust add". A future change will add a hook in
ToolContext::resolve_path that surfaces an ApprovalRequest when an
escape path is hit, so the slash-command remains the durable
mechanism while the prompt becomes the discovery one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <path>` – add a directory to the allowlist (#29)
|
||||
/// - `/trust remove <path>` (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 <path>` 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 <path>`, or `/trust remove <path>`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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 <path>` 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 <path>. 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 <path>");
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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 <path>`, `/trust list`, `/trust on|off`)",
|
||||
usage: "/trust [on|off|add <path>|remove <path>|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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -30,6 +30,11 @@ struct DiagnosticsOutput {
|
||||
sandbox_type: Option<String>,
|
||||
rustc_version: Option<String>,
|
||||
cargo_version: Option<String>,
|
||||
/// User-trusted external paths the agent may access from this workspace
|
||||
/// (`/trust add <path>` 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<String>,
|
||||
}
|
||||
|
||||
#[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()))
|
||||
|
||||
@@ -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 <path>`. Distinct from
|
||||
/// `trust_mode`, which is the all-or-nothing legacy switch (#29).
|
||||
pub trusted_external_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> 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 <path>` 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});
|
||||
|
||||
@@ -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<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME))
|
||||
}
|
||||
|
||||
fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user