use crate::{ Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision, decide_permission_for_path,};use agent_client_protocol::schema::v1 as acp;use agent_skills::is_agents_skills_path;use anyhow::{Result, anyhow};use fs::Fs;use gpui::{App, Entity, Task, WeakEntity};use project::{Project, ProjectPath};use settings::Settings;use std::path::{Component, Path, PathBuf};use std::sync::Arc;use util::{normalize_path, paths::component_matches_ignore_ascii_case}; pub enum SensitiveSettingsKind { Local, Global, AgentSkills,} /// Result of resolving a path within the project with symlink safety checks.////// See [`resolve_project_path`].#[derive(Debug, Clone)]pub enum ResolvedProjectPath { /// The path resolves to a location safely within the project boundaries. Safe(ProjectPath), /// The path resolves through a symlink to a location outside the project. /// Agent tools should prompt the user before proceeding with access. SymlinkEscape { /// The project-relative path (before symlink resolution). project_path: ProjectPath, /// The canonical (real) filesystem path the symlink points to. canonical_target: PathBuf, },} /// Asynchronously canonicalizes the absolute paths of all worktrees in a/// project using the provided `Fs`. The returned paths can be passed to/// [`resolve_project_path`] and related helpers so that they don't need to/// perform blocking filesystem I/O themselves.pub async fn canonicalize_worktree_roots<C: gpui::AppContext>( project: &Entity<Project>, fs: &Arc<dyn Fs>, cx: &C,) -> Vec<PathBuf> { let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| { project .worktrees(cx) .map(|worktree| worktree.read(cx).abs_path()) .collect() }); let mut canonical_roots = Vec::with_capacity(abs_paths.len()); for abs_path in &abs_paths { match fs.canonicalize(abs_path).await { Ok(canonical) => canonical_roots.push(canonical), Err(_) => canonical_roots.push(abs_path.to_path_buf()), } } canonical_roots} /// Walks up ancestors of `path` to find the deepest one that exists on disk and/// can be canonicalized, then reattaches the remaining suffix components.////// This is needed for paths where the leaf (or intermediate directories) don't/// exist yet but an ancestor may be a symlink. For example, when creating/// `.zed/settings.json` where `.zed` is a symlink to an external directory.////// Note: intermediate directories *can* be symlinks (not just leaf entries),/// so we must walk the full ancestor chain. For example:/// `ln -s /external/config /project/.zed`/// makes `.zed` an intermediate symlink directory.async fn canonicalize_with_ancestors(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let mut current: Option<&Path> = Some(path); let mut suffix_components = Vec::new(); loop { match current { Some(ancestor) => match fs.canonicalize(ancestor).await { Ok(canonical) => { let mut result = canonical; for component in suffix_components.into_iter().rev() { result.push(component); } return Some(result); } Err(_) => { if let Some(file_name) = ancestor.file_name() { suffix_components.push(file_name.to_os_string()); } current = ancestor.parent(); } }, None => return None, } }} /// Returns the canonicalized global agent skills directory/// (`~/.agents/skills`).////// Recomputed on every call rather than cached: the underlying/// `canonicalize_with_ancestors` is a few `stat` syscalls (which the OS/// page cache already handles), and a process-wide cache would either go/// stale if the user moved `~/.agents/skills`, or pollute across tests/// using different `FakeFs` instances.async fn canonical_global_skills_dir(fs: &dyn Fs) -> Option<PathBuf> { canonicalize_with_ancestors(&agent_skills::global_skills_dir(), fs).await} fn is_within_any_worktree(canonical_path: &Path, canonical_worktree_roots: &[PathBuf]) -> bool { canonical_worktree_roots .iter() .any(|root| canonical_path.starts_with(root))} /// If `path` names `~/.agents/skills` or one of its descendants, return the/// canonicalized absolute path. Returns `None` for any path that resolves/// outside the global skills tree, for relative paths that don't start with/// `~`, or if the skills directory itself can't be canonicalized (fail closed/// — better to refuse access than to compare against a non-canonical path).////// This is the gate that lets `read_file` / `list_directory` reach into the/// global skills directory — which lives outside any worktree — without/// also opening up arbitrary external paths.pub async fn resolve_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let normalized_path = resolve_lexical_global_skill_path(path)?; // Canonicalize both sides so symlinks can't sneak the path out of the // skills tree (and so different but equivalent path representations // match). The lexical check above intentionally runs first, so a // symlinked `~/.agents/skills` root can't broaden the allowlist to every // path under the symlink target. A linked immediate skill directory is // allowed separately, but only for paths that stay under that skill target. let canonical_path = fs.canonicalize(&normalized_path).await.ok()?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; if canonical_path.starts_with(&canonical_skills_dir) || is_in_linked_global_skill_dir( &normalized_path, &canonical_path, &canonical_skills_dir, fs, ) .await { Some(canonical_path) } else { None }} async fn is_in_linked_global_skill_dir( path: &Path, canonical_path: &Path, canonical_skills_dir: &Path, fs: &dyn Fs,) -> bool { let skills_dir = normalize_path(&agent_skills::global_skills_dir()); let Ok(relative_path) = path.strip_prefix(&skills_dir) else { return false; }; let Some(Component::Normal(skill_dir_name)) = relative_path.components().next() else { return false; }; let skill_dir = skills_dir.join(skill_dir_name); let Ok(canonical_skill_dir) = fs.canonicalize(&skill_dir).await else { return false; }; !canonical_skill_dir.starts_with(canonical_skills_dir) && canonical_path.starts_with(&canonical_skill_dir) && fs .is_file(&skill_dir.join(agent_skills::SKILL_FILE_NAME)) .await} fn expand_home_prefix(path: &Path) -> Option<PathBuf> { if path.is_absolute() { return Some(path.to_path_buf()); } let mut components = path.components(); let first_component = components.next()?; if !matches!(first_component, Component::Normal(component) if component == "~") { return None; } let mut expanded = paths::home_dir().clone(); for component in components { match component { Component::Normal(component) => expanded.push(component), Component::CurDir => {} Component::ParentDir => expanded.push(".."), Component::Prefix(_) | Component::RootDir => return None, } } Some(expanded)} fn expand_and_normalize_absolute_path(path: &Path) -> Option<PathBuf> { let expanded_path = expand_home_prefix(path)?; let normalized_path = normalize_path(&expanded_path); normalized_path.is_absolute().then_some(normalized_path)} fn resolve_lexical_global_skill_path(path: &Path) -> Option<PathBuf> { let normalized_path = expand_and_normalize_absolute_path(path)?; let normalized_skills_dir = normalize_path(&agent_skills::global_skills_dir()); normalized_path .starts_with(&normalized_skills_dir) .then_some(normalized_path)} /// If `path` names `~/.agents/skills` or one of its descendants, return a/// canonical absolute path for it. Unlike [`resolve_global_skill_path`], the/// target path may or may not exist on disk yet — the caller decides whether/// to read, write, or create it. Returns `None` for any other path, including/// siblings of the global skills tree or paths that would escape it with `..`/// or symlinks.pub async fn resolve_creatable_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let normalized_path = resolve_lexical_global_skill_path(path)?; let canonical_path = canonicalize_with_ancestors(&normalized_path, fs).await?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; if canonical_path.starts_with(&canonical_skills_dir) { Some(canonical_path) } else { None }} fn is_strict_descendant(path: &Path, ancestor: &Path) -> bool { path != ancestor && path.starts_with(ancestor)} /// Returns whether `path` resolves to the global agent skills directory itself.////// This is used by destructive tools to reject operations targeting the root/// `~/.agents/skills` directory while still allowing operations on individual/// skills or resources beneath it.pub async fn resolves_to_global_skills_dir(path: &Path, fs: &dyn Fs) -> bool { let Some(normalized_path) = resolve_lexical_global_skill_path(path) else { return false; }; let Some(canonical_path) = canonicalize_with_ancestors(&normalized_path, fs).await else { return false; }; let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await else { return false; }; canonical_path == canonical_skills_dir} /// Filters a previously-resolved global skills path so that callers which/// must never act on `~/.agents/skills` itself (move, delete) only see paths/// that point strictly below the skills root.async fn restrict_to_skill_descendant( canonical_path: Option<PathBuf>, fs: &dyn Fs,) -> Option<PathBuf> { let canonical_path = canonical_path?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; is_strict_descendant(&canonical_path, &canonical_skills_dir).then_some(canonical_path)} /// Like [`resolve_global_skill_path`], but only succeeds for paths strictly/// below `~/.agents/skills`, not the skills directory itself.pub async fn resolve_global_skill_descendant_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { restrict_to_skill_descendant(resolve_global_skill_path(path, fs).await, fs).await} /// Like [`resolve_creatable_global_skill_path`], but only succeeds for paths/// strictly below `~/.agents/skills`, not the skills directory itself.pub async fn resolve_creatable_global_skill_descendant_path( path: &Path, fs: &dyn Fs,) -> Option<PathBuf> { restrict_to_skill_descendant(resolve_creatable_global_skill_path(path, fs).await, fs).await} /// Returns the kind of sensitive settings or agent skills location this path targets, if any:/// either inside a `.zed/` local-settings directory, inside `.agents/skills/`, or inside/// the global config dir.////// `canonical_worktree_roots` should be the result of/// [`canonicalize_worktree_roots`]; it's used to re-check the local/// `.zed/` and `.agents/skills/` protections against the canonical form/// of `path`, which catches two classes of bypass that the raw-component/// scan misses:////// 1. `..` traversal, e.g. `.agents/foo/../skills/SKILL.md`. The raw/// components are `[.agents, foo, .., skills, SKILL.md]`, so the/// consecutive-pair match in [`is_agents_skills_path`] fails./// 2. Intra-project symlinks, e.g. a symlink `safe -> .zed` followed/// by `safe/settings.json`. `resolve_project_path` correctly classes/// this as *not* a symlink escape (it stays inside the project), so/// the raw-path check is our only line of defense and it doesn't see/// `.zed` either.////// After canonicalizing we strip the matching worktree root before/// re-scanning components, so that a worktree literally rooted at a path/// like `~/projects/.zed/foo` doesn't classify every file inside it as/// `.zed/` local-settings — only files that have `.zed` (or/// `.agents/skills`) inside the worktree are flagged.pub async fn sensitive_settings_kind( path: &Path, canonical_worktree_roots: &[PathBuf], fs: &dyn Fs,) -> Option<SensitiveSettingsKind> { let local_settings_folder = paths::local_settings_folder_name(); // Fast path: scan the raw path components before any I/O. Covers the // common case where the agent passes a path that literally contains // `.zed/` or `.agents/skills/`. if path.components().any(|component| { component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder) }) { return Some(SensitiveSettingsKind::Local); } if is_agents_skills_path(path) { return Some(SensitiveSettingsKind::AgentSkills); } if let Some(canonical_path) = canonicalize_with_ancestors(path, fs).await { // Re-check the local protections against the canonical path, // restricted to within the project's worktrees, to catch `..` // and intra-project-symlink bypasses (see doc comment above). for root in canonical_worktree_roots { let Ok(relative) = canonical_path.strip_prefix(root) else { continue; }; if relative.components().any(|component| { component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder) }) { return Some(SensitiveSettingsKind::Local); } if is_agents_skills_path(relative) { return Some(SensitiveSettingsKind::AgentSkills); } // The canonical path can only live inside one worktree, so // stop after the first match. break; } if let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await { if canonical_path.starts_with(&canonical_skills_dir) { return Some(SensitiveSettingsKind::AgentSkills); } } if let Some(canonical_config_dir) = canonicalize_with_ancestors(paths::config_dir(), fs).await { if canonical_path.starts_with(&canonical_config_dir) { return Some(SensitiveSettingsKind::Global); } } } None} /// Resolves a path within the project, checking for symlink escapes.////// This is the primary entry point for agent tools that need to resolve a/// user-provided path string into a validated `ProjectPath`. It combines/// path lookup (`find_project_path`) with symlink safety verification.////// `canonical_worktree_roots` should be obtained from/// [`canonicalize_worktree_roots`] before calling this function so that no/// blocking I/O is needed here.////// # Returns////// - `Ok(ResolvedProjectPath::Safe(project_path))` — the path resolves to a/// location within the project boundaries./// - `Ok(ResolvedProjectPath::SymlinkEscape { .. })` — the path resolves/// through a symlink to a location outside the project. Agent tools should/// prompt the user before proceeding./// - `Err(..)` — the path could not be found in the project or could not be/// verified. The error message is suitable for returning to the model.pub fn resolve_project_path( project: &Project, path: impl AsRef<Path>, canonical_worktree_roots: &[PathBuf], cx: &App,) -> Result<ResolvedProjectPath> { let path = path.as_ref(); let project_path = project .find_project_path(path, cx) .ok_or_else(|| anyhow!("Path {} is not in the project", path.display()))?; let worktree = project .worktree_for_id(project_path.worktree_id, cx) .ok_or_else(|| anyhow!("Could not resolve path {}", path.display()))?; let snapshot = worktree.read(cx); // Fast path: if the entry exists in the snapshot and is not marked // external, we know it's safe (the background scanner already verified). if let Some(entry) = snapshot.entry_for_path(&project_path.path) { if !entry.is_external { return Ok(ResolvedProjectPath::Safe(project_path)); } // Entry is external (set by the worktree scanner when a symlink's // canonical target is outside the worktree root). Return the // canonical path if the entry has one, otherwise fall through to // filesystem-level canonicalization. if let Some(canonical) = &entry.canonical_path { if is_within_any_worktree(canonical.as_ref(), canonical_worktree_roots) { return Ok(ResolvedProjectPath::Safe(project_path)); } return Ok(ResolvedProjectPath::SymlinkEscape { project_path, canonical_target: canonical.to_path_buf(), }); } } // For missing/create-mode paths (or external descendants without their own // canonical_path), resolve symlink safety through snapshot metadata rather // than std::fs canonicalization. This keeps behavior correct for non-local // worktrees and in-memory fs backends. for ancestor in project_path.path.ancestors() { let Some(ancestor_entry) = snapshot.entry_for_path(ancestor) else { continue; }; if !ancestor_entry.is_external { return Ok(ResolvedProjectPath::Safe(project_path)); } let Some(canonical_ancestor) = ancestor_entry.canonical_path.as_ref() else { continue; }; let suffix = project_path.path.strip_prefix(ancestor).map_err(|_| { anyhow!( "Path {} could not be resolved in the project", path.display() ) })?; let canonical_target = if suffix.is_empty() { canonical_ancestor.to_path_buf() } else { canonical_ancestor.join(suffix.as_std_path()) }; if is_within_any_worktree(&canonical_target, canonical_worktree_roots) { return Ok(ResolvedProjectPath::Safe(project_path)); } return Ok(ResolvedProjectPath::SymlinkEscape { project_path, canonical_target, }); } Ok(ResolvedProjectPath::Safe(project_path))} /// Prompts the user for permission when a path resolves through a symlink to a/// location outside the project. This check is an additional gate after/// settings-based deny decisions: even if a tool is configured as "always allow,"/// a symlink escape still requires explicit user approval.pub fn authorize_symlink_access( tool_name: &str, display_path: &str, canonical_target: &Path, event_stream: &ToolCallEventStream, cx: &mut App,) -> Task<Result<()>> { let title = format!( "`{}` points outside the project (symlink to `{}`)", display_path, canonical_target.display(), ); let context = ToolPermissionContext::symlink_target( tool_name, vec![canonical_target.display().to_string()], ); event_stream.authorize_always_prompt(title, context, cx)} pub fn authorize_with_sensitive_settings( kind: Option<SensitiveSettingsKind>, context: ToolPermissionContext,Lines 1–500 of 1477. Load more
use crate::{ Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision, decide_permission_for_path, }; use agent_client_protocol::schema::v1 as acp; use agent_skills::is_agents_skills_path; use anyhow::{Result, anyhow}; use fs::Fs; use gpui::{App, Entity, Task, WeakEntity}; use project::{Project, ProjectPath}; use settings::Settings; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use util::{normalize_path, paths::component_matches_ignore_ascii_case}; pub enum SensitiveSettingsKind { Local, Global, AgentSkills, } /// Result of resolving a path within the project with symlink safety checks. /// /// See [`resolve_project_path`]. #[derive(Debug, Clone)] pub enum ResolvedProjectPath { /// The path resolves to a location safely within the project boundaries. Safe(ProjectPath), /// The path resolves through a symlink to a location outside the project. /// Agent tools should prompt the user before proceeding with access. SymlinkEscape { /// The project-relative path (before symlink resolution). project_path: ProjectPath, /// The canonical (real) filesystem path the symlink points to. canonical_target: PathBuf, }, } /// Asynchronously canonicalizes the absolute paths of all worktrees in a /// project using the provided `Fs`. The returned paths can be passed to /// [`resolve_project_path`] and related helpers so that they don't need to /// perform blocking filesystem I/O themselves. pub async fn canonicalize_worktree_roots<C: gpui::AppContext>( project: &Entity<Project>, fs: &Arc<dyn Fs>, cx: &C, ) -> Vec<PathBuf> { let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| { project .worktrees(cx) .map(|worktree| worktree.read(cx).abs_path()) .collect() }); let mut canonical_roots = Vec::with_capacity(abs_paths.len()); for abs_path in &abs_paths { match fs.canonicalize(abs_path).await { Ok(canonical) => canonical_roots.push(canonical), Err(_) => canonical_roots.push(abs_path.to_path_buf()), } } canonical_roots } /// Walks up ancestors of `path` to find the deepest one that exists on disk and /// can be canonicalized, then reattaches the remaining suffix components. /// /// This is needed for paths where the leaf (or intermediate directories) don't /// exist yet but an ancestor may be a symlink. For example, when creating /// `.zed/settings.json` where `.zed` is a symlink to an external directory. /// /// Note: intermediate directories *can* be symlinks (not just leaf entries), /// so we must walk the full ancestor chain. For example: /// `ln -s /external/config /project/.zed` /// makes `.zed` an intermediate symlink directory. async fn canonicalize_with_ancestors(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let mut current: Option<&Path> = Some(path); let mut suffix_components = Vec::new(); loop { match current { Some(ancestor) => match fs.canonicalize(ancestor).await { Ok(canonical) => { let mut result = canonical; for component in suffix_components.into_iter().rev() { result.push(component); } return Some(result); } Err(_) => { if let Some(file_name) = ancestor.file_name() { suffix_components.push(file_name.to_os_string()); } current = ancestor.parent(); } }, None => return None, } } } /// Returns the canonicalized global agent skills directory /// (`~/.agents/skills`). /// /// Recomputed on every call rather than cached: the underlying /// `canonicalize_with_ancestors` is a few `stat` syscalls (which the OS /// page cache already handles), and a process-wide cache would either go /// stale if the user moved `~/.agents/skills`, or pollute across tests /// using different `FakeFs` instances. async fn canonical_global_skills_dir(fs: &dyn Fs) -> Option<PathBuf> { canonicalize_with_ancestors(&agent_skills::global_skills_dir(), fs).await } fn is_within_any_worktree(canonical_path: &Path, canonical_worktree_roots: &[PathBuf]) -> bool { canonical_worktree_roots .iter() .any(|root| canonical_path.starts_with(root)) } /// If `path` names `~/.agents/skills` or one of its descendants, return the /// canonicalized absolute path. Returns `None` for any path that resolves /// outside the global skills tree, for relative paths that don't start with /// `~`, or if the skills directory itself can't be canonicalized (fail closed /// — better to refuse access than to compare against a non-canonical path). /// /// This is the gate that lets `read_file` / `list_directory` reach into the /// global skills directory — which lives outside any worktree — without /// also opening up arbitrary external paths. pub async fn resolve_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let normalized_path = resolve_lexical_global_skill_path(path)?; // Canonicalize both sides so symlinks can't sneak the path out of the // skills tree (and so different but equivalent path representations // match). The lexical check above intentionally runs first, so a // symlinked `~/.agents/skills` root can't broaden the allowlist to every // path under the symlink target. A linked immediate skill directory is // allowed separately, but only for paths that stay under that skill target. let canonical_path = fs.canonicalize(&normalized_path).await.ok()?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; if canonical_path.starts_with(&canonical_skills_dir) || is_in_linked_global_skill_dir( &normalized_path, &canonical_path, &canonical_skills_dir, fs, ) .await { Some(canonical_path) } else { None } } async fn is_in_linked_global_skill_dir( path: &Path, canonical_path: &Path, canonical_skills_dir: &Path, fs: &dyn Fs, ) -> bool { let skills_dir = normalize_path(&agent_skills::global_skills_dir()); let Ok(relative_path) = path.strip_prefix(&skills_dir) else { return false; }; let Some(Component::Normal(skill_dir_name)) = relative_path.components().next() else { return false; }; let skill_dir = skills_dir.join(skill_dir_name); let Ok(canonical_skill_dir) = fs.canonicalize(&skill_dir).await else { return false; }; !canonical_skill_dir.starts_with(canonical_skills_dir) && canonical_path.starts_with(&canonical_skill_dir) && fs .is_file(&skill_dir.join(agent_skills::SKILL_FILE_NAME)) .await } fn expand_home_prefix(path: &Path) -> Option<PathBuf> { if path.is_absolute() { return Some(path.to_path_buf()); } let mut components = path.components(); let first_component = components.next()?; if !matches!(first_component, Component::Normal(component) if component == "~") { return None; } let mut expanded = paths::home_dir().clone(); for component in components { match component { Component::Normal(component) => expanded.push(component), Component::CurDir => {} Component::ParentDir => expanded.push(".."), Component::Prefix(_) | Component::RootDir => return None, } } Some(expanded) } fn expand_and_normalize_absolute_path(path: &Path) -> Option<PathBuf> { let expanded_path = expand_home_prefix(path)?; let normalized_path = normalize_path(&expanded_path); normalized_path.is_absolute().then_some(normalized_path) } fn resolve_lexical_global_skill_path(path: &Path) -> Option<PathBuf> { let normalized_path = expand_and_normalize_absolute_path(path)?; let normalized_skills_dir = normalize_path(&agent_skills::global_skills_dir()); normalized_path .starts_with(&normalized_skills_dir) .then_some(normalized_path) } /// If `path` names `~/.agents/skills` or one of its descendants, return a /// canonical absolute path for it. Unlike [`resolve_global_skill_path`], the /// target path may or may not exist on disk yet — the caller decides whether /// to read, write, or create it. Returns `None` for any other path, including /// siblings of the global skills tree or paths that would escape it with `..` /// or symlinks. pub async fn resolve_creatable_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { let normalized_path = resolve_lexical_global_skill_path(path)?; let canonical_path = canonicalize_with_ancestors(&normalized_path, fs).await?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; if canonical_path.starts_with(&canonical_skills_dir) { Some(canonical_path) } else { None } } fn is_strict_descendant(path: &Path, ancestor: &Path) -> bool { path != ancestor && path.starts_with(ancestor) } /// Returns whether `path` resolves to the global agent skills directory itself. /// /// This is used by destructive tools to reject operations targeting the root /// `~/.agents/skills` directory while still allowing operations on individual /// skills or resources beneath it. pub async fn resolves_to_global_skills_dir(path: &Path, fs: &dyn Fs) -> bool { let Some(normalized_path) = resolve_lexical_global_skill_path(path) else { return false; }; let Some(canonical_path) = canonicalize_with_ancestors(&normalized_path, fs).await else { return false; }; let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await else { return false; }; canonical_path == canonical_skills_dir } /// Filters a previously-resolved global skills path so that callers which /// must never act on `~/.agents/skills` itself (move, delete) only see paths /// that point strictly below the skills root. async fn restrict_to_skill_descendant( canonical_path: Option<PathBuf>, fs: &dyn Fs, ) -> Option<PathBuf> { let canonical_path = canonical_path?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; is_strict_descendant(&canonical_path, &canonical_skills_dir).then_some(canonical_path) } /// Like [`resolve_global_skill_path`], but only succeeds for paths strictly /// below `~/.agents/skills`, not the skills directory itself. pub async fn resolve_global_skill_descendant_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> { restrict_to_skill_descendant(resolve_global_skill_path(path, fs).await, fs).await } /// Like [`resolve_creatable_global_skill_path`], but only succeeds for paths /// strictly below `~/.agents/skills`, not the skills directory itself. pub async fn resolve_creatable_global_skill_descendant_path( path: &Path, fs: &dyn Fs, ) -> Option<PathBuf> { restrict_to_skill_descendant(resolve_creatable_global_skill_path(path, fs).await, fs).await } /// Returns the kind of sensitive settings or agent skills location this path targets, if any: /// either inside a `.zed/` local-settings directory, inside `.agents/skills/`, or inside /// the global config dir. /// /// `canonical_worktree_roots` should be the result of /// [`canonicalize_worktree_roots`]; it's used to re-check the local /// `.zed/` and `.agents/skills/` protections against the canonical form /// of `path`, which catches two classes of bypass that the raw-component /// scan misses: /// /// 1. `..` traversal, e.g. `.agents/foo/../skills/SKILL.md`. The raw /// components are `[.agents, foo, .., skills, SKILL.md]`, so the /// consecutive-pair match in [`is_agents_skills_path`] fails. /// 2. Intra-project symlinks, e.g. a symlink `safe -> .zed` followed /// by `safe/settings.json`. `resolve_project_path` correctly classes /// this as *not* a symlink escape (it stays inside the project), so /// the raw-path check is our only line of defense and it doesn't see /// `.zed` either. /// /// After canonicalizing we strip the matching worktree root before /// re-scanning components, so that a worktree literally rooted at a path /// like `~/projects/.zed/foo` doesn't classify every file inside it as /// `.zed/` local-settings — only files that have `.zed` (or /// `.agents/skills`) inside the worktree are flagged. pub async fn sensitive_settings_kind( path: &Path, canonical_worktree_roots: &[PathBuf], fs: &dyn Fs, ) -> Option<SensitiveSettingsKind> { let local_settings_folder = paths::local_settings_folder_name(); // Fast path: scan the raw path components before any I/O. Covers the // common case where the agent passes a path that literally contains // `.zed/` or `.agents/skills/`. if path.components().any(|component| { component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder) }) { return Some(SensitiveSettingsKind::Local); } if is_agents_skills_path(path) { return Some(SensitiveSettingsKind::AgentSkills); } if let Some(canonical_path) = canonicalize_with_ancestors(path, fs).await { // Re-check the local protections against the canonical path, // restricted to within the project's worktrees, to catch `..` // and intra-project-symlink bypasses (see doc comment above). for root in canonical_worktree_roots { let Ok(relative) = canonical_path.strip_prefix(root) else { continue; }; if relative.components().any(|component| { component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder) }) { return Some(SensitiveSettingsKind::Local); } if is_agents_skills_path(relative) { return Some(SensitiveSettingsKind::AgentSkills); } // The canonical path can only live inside one worktree, so // stop after the first match. break; } if let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await { if canonical_path.starts_with(&canonical_skills_dir) { return Some(SensitiveSettingsKind::AgentSkills); } } if let Some(canonical_config_dir) = canonicalize_with_ancestors(paths::config_dir(), fs).await { if canonical_path.starts_with(&canonical_config_dir) { return Some(SensitiveSettingsKind::Global); } } } None } /// Resolves a path within the project, checking for symlink escapes. /// /// This is the primary entry point for agent tools that need to resolve a /// user-provided path string into a validated `ProjectPath`. It combines /// path lookup (`find_project_path`) with symlink safety verification. /// /// `canonical_worktree_roots` should be obtained from /// [`canonicalize_worktree_roots`] before calling this function so that no /// blocking I/O is needed here. /// /// # Returns /// /// - `Ok(ResolvedProjectPath::Safe(project_path))` — the path resolves to a /// location within the project boundaries. /// - `Ok(ResolvedProjectPath::SymlinkEscape { .. })` — the path resolves /// through a symlink to a location outside the project. Agent tools should /// prompt the user before proceeding. /// - `Err(..)` — the path could not be found in the project or could not be /// verified. The error message is suitable for returning to the model. pub fn resolve_project_path( project: &Project, path: impl AsRef<Path>, canonical_worktree_roots: &[PathBuf], cx: &App, ) -> Result<ResolvedProjectPath> { let path = path.as_ref(); let project_path = project .find_project_path(path, cx) .ok_or_else(|| anyhow!("Path {} is not in the project", path.display()))?; let worktree = project .worktree_for_id(project_path.worktree_id, cx) .ok_or_else(|| anyhow!("Could not resolve path {}", path.display()))?; let snapshot = worktree.read(cx); // Fast path: if the entry exists in the snapshot and is not marked // external, we know it's safe (the background scanner already verified). if let Some(entry) = snapshot.entry_for_path(&project_path.path) { if !entry.is_external { return Ok(ResolvedProjectPath::Safe(project_path)); } // Entry is external (set by the worktree scanner when a symlink's // canonical target is outside the worktree root). Return the // canonical path if the entry has one, otherwise fall through to // filesystem-level canonicalization. if let Some(canonical) = &entry.canonical_path { if is_within_any_worktree(canonical.as_ref(), canonical_worktree_roots) { return Ok(ResolvedProjectPath::Safe(project_path)); } return Ok(ResolvedProjectPath::SymlinkEscape { project_path, canonical_target: canonical.to_path_buf(), }); } } // For missing/create-mode paths (or external descendants without their own // canonical_path), resolve symlink safety through snapshot metadata rather // than std::fs canonicalization. This keeps behavior correct for non-local // worktrees and in-memory fs backends. for ancestor in project_path.path.ancestors() { let Some(ancestor_entry) = snapshot.entry_for_path(ancestor) else { continue; }; if !ancestor_entry.is_external { return Ok(ResolvedProjectPath::Safe(project_path)); } let Some(canonical_ancestor) = ancestor_entry.canonical_path.as_ref() else { continue; }; let suffix = project_path.path.strip_prefix(ancestor).map_err(|_| { anyhow!( "Path {} could not be resolved in the project", path.display() ) })?; let canonical_target = if suffix.is_empty() { canonical_ancestor.to_path_buf() } else { canonical_ancestor.join(suffix.as_std_path()) }; if is_within_any_worktree(&canonical_target, canonical_worktree_roots) { return Ok(ResolvedProjectPath::Safe(project_path)); } return Ok(ResolvedProjectPath::SymlinkEscape { project_path, canonical_target, }); } Ok(ResolvedProjectPath::Safe(project_path)) } /// Prompts the user for permission when a path resolves through a symlink to a /// location outside the project. This check is an additional gate after /// settings-based deny decisions: even if a tool is configured as "always allow," /// a symlink escape still requires explicit user approval. pub fn authorize_symlink_access( tool_name: &str, display_path: &str, canonical_target: &Path, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task<Result<()>> { let title = format!( "`{}` points outside the project (symlink to `{}`)", display_path, canonical_target.display(), ); let context = ToolPermissionContext::symlink_target( tool_name, vec![canonical_target.display().to_string()], ); event_stream.authorize_always_prompt(title, context, cx) } pub fn authorize_with_sensitive_settings( kind: Option<SensitiveSettingsKind>, context: ToolPermissionContext,