1477 lines · 52.1 KB · Rust
RawDownload
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.