Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:39:46.418Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

tool_permissions.rs

1478 lines · 53.3 KB · rust
1use crate::{
2    Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision,
3    decide_permission_for_path,
4};
5use agent_client_protocol::schema::v1 as acp;
6use agent_skills::is_agents_skills_path;
7use anyhow::{Result, anyhow};
8use fs::Fs;
9use gpui::{App, Entity, Task, WeakEntity};
10use project::{Project, ProjectPath};
11use settings::Settings;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use util::{normalize_path, paths::component_matches_ignore_ascii_case};
15
16pub enum SensitiveSettingsKind {
17    Local,
18    Global,
19    AgentSkills,
20}
21
22/// Result of resolving a path within the project with symlink safety checks.
23///
24/// See [`resolve_project_path`].
25#[derive(Debug, Clone)]
26pub enum ResolvedProjectPath {
27    /// The path resolves to a location safely within the project boundaries.
28    Safe(ProjectPath),
29    /// The path resolves through a symlink to a location outside the project.
30    /// Agent tools should prompt the user before proceeding with access.
31    SymlinkEscape {
32        /// The project-relative path (before symlink resolution).
33        project_path: ProjectPath,
34        /// The canonical (real) filesystem path the symlink points to.
35        canonical_target: PathBuf,
36    },
37}
38
39/// Asynchronously canonicalizes the absolute paths of all worktrees in a
40/// project using the provided `Fs`. The returned paths can be passed to
41/// [`resolve_project_path`] and related helpers so that they don't need to
42/// perform blocking filesystem I/O themselves.
43pub async fn canonicalize_worktree_roots<C: gpui::AppContext>(
44    project: &Entity<Project>,
45    fs: &Arc<dyn Fs>,
46    cx: &C,
47) -> Vec<PathBuf> {
48    let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| {
49        project
50            .worktrees(cx)
51            .map(|worktree| worktree.read(cx).abs_path())
52            .collect()
53    });
54
55    let mut canonical_roots = Vec::with_capacity(abs_paths.len());
56    for abs_path in &abs_paths {
57        match fs.canonicalize(abs_path).await {
58            Ok(canonical) => canonical_roots.push(canonical),
59            Err(_) => canonical_roots.push(abs_path.to_path_buf()),
60        }
61    }
62    canonical_roots
63}
64
65/// Walks up ancestors of `path` to find the deepest one that exists on disk and
66/// can be canonicalized, then reattaches the remaining suffix components.
67///
68/// This is needed for paths where the leaf (or intermediate directories) don't
69/// exist yet but an ancestor may be a symlink. For example, when creating
70/// `.zed/settings.json` where `.zed` is a symlink to an external directory.
71///
72/// Note: intermediate directories *can* be symlinks (not just leaf entries),
73/// so we must walk the full ancestor chain. For example:
74///   `ln -s /external/config /project/.zed`
75/// makes `.zed` an intermediate symlink directory.
76async fn canonicalize_with_ancestors(path: &Path, fs: &dyn Fs) -> Option<PathBuf> {
77    let mut current: Option<&Path> = Some(path);
78    let mut suffix_components = Vec::new();
79    loop {
80        match current {
81            Some(ancestor) => match fs.canonicalize(ancestor).await {
82                Ok(canonical) => {
83                    let mut result = canonical;
84                    for component in suffix_components.into_iter().rev() {
85                        result.push(component);
86                    }
87                    return Some(result);
88                }
89                Err(_) => {
90                    if let Some(file_name) = ancestor.file_name() {
91                        suffix_components.push(file_name.to_os_string());
92                    }
93                    current = ancestor.parent();
94                }
95            },
96            None => return None,
97        }
98    }
99}
100
101/// Returns the canonicalized global agent skills directory
102/// (`~/.agents/skills`).
103///
104/// Recomputed on every call rather than cached: the underlying
105/// `canonicalize_with_ancestors` is a few `stat` syscalls (which the OS
106/// page cache already handles), and a process-wide cache would either go
107/// stale if the user moved `~/.agents/skills`, or pollute across tests
108/// using different `FakeFs` instances.
109async fn canonical_global_skills_dir(fs: &dyn Fs) -> Option<PathBuf> {
110    canonicalize_with_ancestors(&agent_skills::global_skills_dir(), fs).await
111}
112
113fn is_within_any_worktree(canonical_path: &Path, canonical_worktree_roots: &[PathBuf]) -> bool {
114    canonical_worktree_roots
115        .iter()
116        .any(|root| canonical_path.starts_with(root))
117}
118
119/// If `path` names `~/.agents/skills` or one of its descendants, return the
120/// canonicalized absolute path. Returns `None` for any path that resolves
121/// outside the global skills tree, for relative paths that don't start with
122/// `~`, or if the skills directory itself can't be canonicalized (fail closed
123/// — better to refuse access than to compare against a non-canonical path).
124///
125/// This is the gate that lets `read_file` / `list_directory` reach into the
126/// global skills directory — which lives outside any worktree — without
127/// also opening up arbitrary external paths.
128pub async fn resolve_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> {
129    let normalized_path = resolve_lexical_global_skill_path(path)?;
130
131    // Canonicalize both sides so symlinks can't sneak the path out of the
132    // skills tree (and so different but equivalent path representations
133    // match). The lexical check above intentionally runs first, so a
134    // symlinked `~/.agents/skills` root can't broaden the allowlist to every
135    // path under the symlink target. A linked immediate skill directory is
136    // allowed separately, but only for paths that stay under that skill target.
137    let canonical_path = fs.canonicalize(&normalized_path).await.ok()?;
138    let canonical_skills_dir = canonical_global_skills_dir(fs).await?;
139
140    if canonical_path.starts_with(&canonical_skills_dir)
141        || is_in_linked_global_skill_dir(
142            &normalized_path,
143            &canonical_path,
144            &canonical_skills_dir,
145            fs,
146        )
147        .await
148    {
149        Some(canonical_path)
150    } else {
151        None
152    }
153}
154
155async fn is_in_linked_global_skill_dir(
156    path: &Path,
157    canonical_path: &Path,
158    canonical_skills_dir: &Path,
159    fs: &dyn Fs,
160) -> bool {
161    let skills_dir = normalize_path(&agent_skills::global_skills_dir());
162    let Ok(relative_path) = path.strip_prefix(&skills_dir) else {
163        return false;
164    };
165    let Some(Component::Normal(skill_dir_name)) = relative_path.components().next() else {
166        return false;
167    };
168
169    let skill_dir = skills_dir.join(skill_dir_name);
170    let Ok(canonical_skill_dir) = fs.canonicalize(&skill_dir).await else {
171        return false;
172    };
173
174    !canonical_skill_dir.starts_with(canonical_skills_dir)
175        && canonical_path.starts_with(&canonical_skill_dir)
176        && fs
177            .is_file(&skill_dir.join(agent_skills::SKILL_FILE_NAME))
178            .await
179}
180
181fn expand_home_prefix(path: &Path) -> Option<PathBuf> {
182    if path.is_absolute() {
183        return Some(path.to_path_buf());
184    }
185
186    let mut components = path.components();
187    let first_component = components.next()?;
188    if !matches!(first_component, Component::Normal(component) if component == "~") {
189        return None;
190    }
191
192    let mut expanded = paths::home_dir().clone();
193    for component in components {
194        match component {
195            Component::Normal(component) => expanded.push(component),
196            Component::CurDir => {}
197            Component::ParentDir => expanded.push(".."),
198            Component::Prefix(_) | Component::RootDir => return None,
199        }
200    }
201    Some(expanded)
202}
203
204fn expand_and_normalize_absolute_path(path: &Path) -> Option<PathBuf> {
205    let expanded_path = expand_home_prefix(path)?;
206    let normalized_path = normalize_path(&expanded_path);
207    normalized_path.is_absolute().then_some(normalized_path)
208}
209
210fn resolve_lexical_global_skill_path(path: &Path) -> Option<PathBuf> {
211    let normalized_path = expand_and_normalize_absolute_path(path)?;
212    let normalized_skills_dir = normalize_path(&agent_skills::global_skills_dir());
213
214    normalized_path
215        .starts_with(&normalized_skills_dir)
216        .then_some(normalized_path)
217}
218
219/// If `path` names `~/.agents/skills` or one of its descendants, return a
220/// canonical absolute path for it. Unlike [`resolve_global_skill_path`], the
221/// target path may or may not exist on disk yet — the caller decides whether
222/// to read, write, or create it. Returns `None` for any other path, including
223/// siblings of the global skills tree or paths that would escape it with `..`
224/// or symlinks.
225pub async fn resolve_creatable_global_skill_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> {
226    let normalized_path = resolve_lexical_global_skill_path(path)?;
227    let canonical_path = canonicalize_with_ancestors(&normalized_path, fs).await?;
228    let canonical_skills_dir = canonical_global_skills_dir(fs).await?;
229
230    if canonical_path.starts_with(&canonical_skills_dir) {
231        Some(canonical_path)
232    } else {
233        None
234    }
235}
236
237fn is_strict_descendant(path: &Path, ancestor: &Path) -> bool {
238    path != ancestor && path.starts_with(ancestor)
239}
240
241/// Returns whether `path` resolves to the global agent skills directory itself.
242///
243/// This is used by destructive tools to reject operations targeting the root
244/// `~/.agents/skills` directory while still allowing operations on individual
245/// skills or resources beneath it.
246pub async fn resolves_to_global_skills_dir(path: &Path, fs: &dyn Fs) -> bool {
247    let Some(normalized_path) = resolve_lexical_global_skill_path(path) else {
248        return false;
249    };
250    let Some(canonical_path) = canonicalize_with_ancestors(&normalized_path, fs).await else {
251        return false;
252    };
253    let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await else {
254        return false;
255    };
256
257    canonical_path == canonical_skills_dir
258}
259
260/// Filters a previously-resolved global skills path so that callers which
261/// must never act on `~/.agents/skills` itself (move, delete) only see paths
262/// that point strictly below the skills root.
263async fn restrict_to_skill_descendant(
264    canonical_path: Option<PathBuf>,
265    fs: &dyn Fs,
266) -> Option<PathBuf> {
267    let canonical_path = canonical_path?;
268    let canonical_skills_dir = canonical_global_skills_dir(fs).await?;
269    is_strict_descendant(&canonical_path, &canonical_skills_dir).then_some(canonical_path)
270}
271
272/// Like [`resolve_global_skill_path`], but only succeeds for paths strictly
273/// below `~/.agents/skills`, not the skills directory itself.
274pub async fn resolve_global_skill_descendant_path(path: &Path, fs: &dyn Fs) -> Option<PathBuf> {
275    restrict_to_skill_descendant(resolve_global_skill_path(path, fs).await, fs).await
276}
277
278/// Like [`resolve_creatable_global_skill_path`], but only succeeds for paths
279/// strictly below `~/.agents/skills`, not the skills directory itself.
280pub async fn resolve_creatable_global_skill_descendant_path(
281    path: &Path,
282    fs: &dyn Fs,
283) -> Option<PathBuf> {
284    restrict_to_skill_descendant(resolve_creatable_global_skill_path(path, fs).await, fs).await
285}
286
287/// Returns the kind of sensitive settings or agent skills location this path targets, if any:
288/// either inside a `.zed/` local-settings directory, inside `.agents/skills/`, or inside
289/// the global config dir.
290///
291/// `canonical_worktree_roots` should be the result of
292/// [`canonicalize_worktree_roots`]; it's used to re-check the local
293/// `.zed/` and `.agents/skills/` protections against the canonical form
294/// of `path`, which catches two classes of bypass that the raw-component
295/// scan misses:
296///
297///   1. `..` traversal, e.g. `.agents/foo/../skills/SKILL.md`. The raw
298///      components are `[.agents, foo, .., skills, SKILL.md]`, so the
299///      consecutive-pair match in [`is_agents_skills_path`] fails.
300///   2. Intra-project symlinks, e.g. a symlink `safe -> .zed` followed
301///      by `safe/settings.json`. `resolve_project_path` correctly classes
302///      this as *not* a symlink escape (it stays inside the project), so
303///      the raw-path check is our only line of defense and it doesn't see
304///      `.zed` either.
305///
306/// After canonicalizing we strip the matching worktree root before
307/// re-scanning components, so that a worktree literally rooted at a path
308/// like `~/projects/.zed/foo` doesn't classify every file inside it as
309/// `.zed/` local-settings — only files that have `.zed` (or
310/// `.agents/skills`) inside the worktree are flagged.
311pub async fn sensitive_settings_kind(
312    path: &Path,
313    canonical_worktree_roots: &[PathBuf],
314    fs: &dyn Fs,
315) -> Option<SensitiveSettingsKind> {
316    let local_settings_folder = paths::local_settings_folder_name();
317
318    // Fast path: scan the raw path components before any I/O. Covers the
319    // common case where the agent passes a path that literally contains
320    // `.zed/` or `.agents/skills/`.
321    if path.components().any(|component| {
322        component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder)
323    }) {
324        return Some(SensitiveSettingsKind::Local);
325    }
326
327    if is_agents_skills_path(path) {
328        return Some(SensitiveSettingsKind::AgentSkills);
329    }
330
331    if let Some(canonical_path) = canonicalize_with_ancestors(path, fs).await {
332        // Re-check the local protections against the canonical path,
333        // restricted to within the project's worktrees, to catch `..`
334        // and intra-project-symlink bypasses (see doc comment above).
335        for root in canonical_worktree_roots {
336            let Ok(relative) = canonical_path.strip_prefix(root) else {
337                continue;
338            };
339
340            if relative.components().any(|component| {
341                component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder)
342            }) {
343                return Some(SensitiveSettingsKind::Local);
344            }
345            if is_agents_skills_path(relative) {
346                return Some(SensitiveSettingsKind::AgentSkills);
347            }
348
349            // The canonical path can only live inside one worktree, so
350            // stop after the first match.
351            break;
352        }
353
354        if let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await {
355            if canonical_path.starts_with(&canonical_skills_dir) {
356                return Some(SensitiveSettingsKind::AgentSkills);
357            }
358        }
359
360        if let Some(canonical_config_dir) =
361            canonicalize_with_ancestors(paths::config_dir(), fs).await
362        {
363            if canonical_path.starts_with(&canonical_config_dir) {
364                return Some(SensitiveSettingsKind::Global);
365            }
366        }
367    }
368
369    None
370}
371
372/// Resolves a path within the project, checking for symlink escapes.
373///
374/// This is the primary entry point for agent tools that need to resolve a
375/// user-provided path string into a validated `ProjectPath`. It combines
376/// path lookup (`find_project_path`) with symlink safety verification.
377///
378/// `canonical_worktree_roots` should be obtained from
379/// [`canonicalize_worktree_roots`] before calling this function so that no
380/// blocking I/O is needed here.
381///
382/// # Returns
383///
384/// - `Ok(ResolvedProjectPath::Safe(project_path))` — the path resolves to a
385///   location within the project boundaries.
386/// - `Ok(ResolvedProjectPath::SymlinkEscape { .. })` — the path resolves
387///   through a symlink to a location outside the project. Agent tools should
388///   prompt the user before proceeding.
389/// - `Err(..)` — the path could not be found in the project or could not be
390///   verified. The error message is suitable for returning to the model.
391pub fn resolve_project_path(
392    project: &Project,
393    path: impl AsRef<Path>,
394    canonical_worktree_roots: &[PathBuf],
395    cx: &App,
396) -> Result<ResolvedProjectPath> {
397    let path = path.as_ref();
398    let project_path = project
399        .find_project_path(path, cx)
400        .ok_or_else(|| anyhow!("Path {} is not in the project", path.display()))?;
401
402    let worktree = project
403        .worktree_for_id(project_path.worktree_id, cx)
404        .ok_or_else(|| anyhow!("Could not resolve path {}", path.display()))?;
405    let snapshot = worktree.read(cx);
406
407    // Fast path: if the entry exists in the snapshot and is not marked
408    // external, we know it's safe (the background scanner already verified).
409    if let Some(entry) = snapshot.entry_for_path(&project_path.path) {
410        if !entry.is_external {
411            return Ok(ResolvedProjectPath::Safe(project_path));
412        }
413
414        // Entry is external (set by the worktree scanner when a symlink's
415        // canonical target is outside the worktree root). Return the
416        // canonical path if the entry has one, otherwise fall through to
417        // filesystem-level canonicalization.
418        if let Some(canonical) = &entry.canonical_path {
419            if is_within_any_worktree(canonical.as_ref(), canonical_worktree_roots) {
420                return Ok(ResolvedProjectPath::Safe(project_path));
421            }
422
423            return Ok(ResolvedProjectPath::SymlinkEscape {
424                project_path,
425                canonical_target: canonical.to_path_buf(),
426            });
427        }
428    }
429
430    // For missing/create-mode paths (or external descendants without their own
431    // canonical_path), resolve symlink safety through snapshot metadata rather
432    // than std::fs canonicalization. This keeps behavior correct for non-local
433    // worktrees and in-memory fs backends.
434    for ancestor in project_path.path.ancestors() {
435        let Some(ancestor_entry) = snapshot.entry_for_path(ancestor) else {
436            continue;
437        };
438
439        if !ancestor_entry.is_external {
440            return Ok(ResolvedProjectPath::Safe(project_path));
441        }
442
443        let Some(canonical_ancestor) = ancestor_entry.canonical_path.as_ref() else {
444            continue;
445        };
446
447        let suffix = project_path.path.strip_prefix(ancestor).map_err(|_| {
448            anyhow!(
449                "Path {} could not be resolved in the project",
450                path.display()
451            )
452        })?;
453
454        let canonical_target = if suffix.is_empty() {
455            canonical_ancestor.to_path_buf()
456        } else {
457            canonical_ancestor.join(suffix.as_std_path())
458        };
459
460        if is_within_any_worktree(&canonical_target, canonical_worktree_roots) {
461            return Ok(ResolvedProjectPath::Safe(project_path));
462        }
463
464        return Ok(ResolvedProjectPath::SymlinkEscape {
465            project_path,
466            canonical_target,
467        });
468    }
469
470    Ok(ResolvedProjectPath::Safe(project_path))
471}
472
473/// Prompts the user for permission when a path resolves through a symlink to a
474/// location outside the project. This check is an additional gate after
475/// settings-based deny decisions: even if a tool is configured as "always allow,"
476/// a symlink escape still requires explicit user approval.
477pub fn authorize_symlink_access(
478    tool_name: &str,
479    display_path: &str,
480    canonical_target: &Path,
481    event_stream: &ToolCallEventStream,
482    cx: &mut App,
483) -> Task<Result<()>> {
484    let title = format!(
485        "`{}` points outside the project (symlink to `{}`)",
486        display_path,
487        canonical_target.display(),
488    );
489
490    let context = ToolPermissionContext::symlink_target(
491        tool_name,
492        vec![canonical_target.display().to_string()],
493    );
494
495    event_stream.authorize_always_prompt(title, context, cx)
496}
497
498pub fn authorize_with_sensitive_settings(
499    kind: Option<SensitiveSettingsKind>,
500    context: ToolPermissionContext,
501    title: &str,
502    event_stream: &ToolCallEventStream,
503    cx: &mut App,
504) -> Task<Result<()>> {
505    match kind {
506        Some(SensitiveSettingsKind::Local) => {
507            event_stream.authorize_always_prompt(format!("{title} (local settings)"), context, cx)
508        }
509        Some(SensitiveSettingsKind::Global) => {
510            event_stream.authorize_always_prompt(format!("{title} (settings)"), context, cx)
511        }
512        Some(SensitiveSettingsKind::AgentSkills) => event_stream.authorize_always_prompt(
513            format!("{title} (agent skills)"),
514            context.for_agent_skills(),
515            cx,
516        ),
517        None => event_stream.authorize(title, context, cx),
518    }
519}
520
521/// Creates a single authorization prompt for multiple symlink escapes.
522/// Each escape is a `(display_path, canonical_target)` pair.
523///
524/// Accepts `&[(&str, PathBuf)]` to match the natural return type of
525/// [`detect_symlink_escape`], avoiding intermediate owned-to-borrowed
526/// conversions at call sites.
527pub fn authorize_symlink_escapes(
528    tool_name: &str,
529    escapes: &[(&str, PathBuf)],
530    event_stream: &ToolCallEventStream,
531    cx: &mut App,
532) -> Task<Result<()>> {
533    debug_assert!(!escapes.is_empty());
534
535    if escapes.len() == 1 {
536        return authorize_symlink_access(tool_name, escapes[0].0, &escapes[0].1, event_stream, cx);
537    }
538
539    let targets = escapes
540        .iter()
541        .map(|(path, target)| format!("`{}` → `{}`", path, target.display()))
542        .collect::<Vec<_>>()
543        .join(" and ");
544    let title = format!("{} (symlinks outside project)", targets);
545
546    let context = ToolPermissionContext::symlink_target(
547        tool_name,
548        escapes
549            .iter()
550            .map(|(_, target)| target.display().to_string())
551            .collect(),
552    );
553
554    event_stream.authorize_always_prompt(title, context, cx)
555}
556
557/// Checks whether a path escapes the project via symlink, without creating
558/// an authorization task. Useful for pre-filtering paths before settings checks.
559pub fn path_has_symlink_escape(
560    project: &Project,
561    path: impl AsRef<Path>,
562    canonical_worktree_roots: &[PathBuf],
563    cx: &App,
564) -> bool {
565    matches!(
566        resolve_project_path(project, path, canonical_worktree_roots, cx),
567        Ok(ResolvedProjectPath::SymlinkEscape { .. })
568    )
569}
570
571/// Collects symlink escape info for a path without creating an authorization task.
572/// Returns `Some((display_path, canonical_target))` if the path escapes via symlink.
573pub fn detect_symlink_escape<'a>(
574    project: &Project,
575    display_path: &'a str,
576    canonical_worktree_roots: &[PathBuf],
577    cx: &App,
578) -> Option<(&'a str, PathBuf)> {
579    match resolve_project_path(project, display_path, canonical_worktree_roots, cx).ok()? {
580        ResolvedProjectPath::Safe(_) => None,
581        ResolvedProjectPath::SymlinkEscape {
582            canonical_target, ..
583        } => Some((display_path, canonical_target)),
584    }
585}
586
587/// Collects symlink escape info for two paths (source and destination) and
588/// returns any escapes found. This deduplicates the common pattern used by
589/// tools that operate on two paths (copy, move).
590///
591/// Returns a `Vec` of `(display_path, canonical_target)` pairs for paths
592/// that escape the project via symlink. The returned vec borrows the display
593/// paths from the input strings.
594pub fn collect_symlink_escapes<'a>(
595    project: &Project,
596    source_path: &'a str,
597    destination_path: &'a str,
598    canonical_worktree_roots: &[PathBuf],
599    cx: &App,
600) -> Vec<(&'a str, PathBuf)> {
601    let mut escapes = Vec::new();
602    if let Some(escape) = detect_symlink_escape(project, source_path, canonical_worktree_roots, cx)
603    {
604        escapes.push(escape);
605    }
606    if let Some(escape) =
607        detect_symlink_escape(project, destination_path, canonical_worktree_roots, cx)
608    {
609        escapes.push(escape);
610    }
611    escapes
612}
613
614/// Checks authorization for file edits, handling symlink escapes and
615/// sensitive settings paths.
616///
617/// # Authorization precedence
618///
619/// When a symlink escape is detected, the symlink authorization prompt
620/// *replaces* (rather than supplements) the normal tool-permission prompt.
621/// This is intentional: the symlink prompt already requires explicit user
622/// approval and displays the canonical target, which provides strictly more
623/// security-relevant information than the generic tool confirmation. Requiring
624/// two sequential prompts for the same operation would degrade UX without
625/// meaningfully improving security, since the user must already approve the
626/// more specific symlink-escape prompt.
627pub fn authorize_file_edit(
628    tool_name: &str,
629    path: &Path,
630    thread: &WeakEntity<Thread>,
631    event_stream: &ToolCallEventStream,
632    cx: &mut App,
633) -> Task<Result<()>> {
634    let path_str = path.to_string_lossy();
635
636    let settings = agent_settings::AgentSettings::get_global(cx);
637    let decision = decide_permission_for_path(tool_name, &path_str, settings);
638
639    if let ToolPermissionDecision::Deny(reason) = decision {
640        return Task::ready(Err(anyhow!("{}", reason)));
641    }
642
643    let path_owned = path.to_path_buf();
644    let title = format!("Edit {}", util::markdown::MarkdownInlineCode(&path_str));
645    let tool_name = tool_name.to_string();
646    let thread = thread.clone();
647    let event_stream = event_stream.clone();
648
649    // The raw-path sensitivity checks are synchronous (pure path inspection).
650    // We still have to spawn anyway to resolve symlink escapes against the
651    // worktree, but we can short-circuit straight to the appropriate
652    // SensitiveSettingsKind on these fast paths and skip the async
653    // `sensitive_settings_kind` canonicalization step below.
654    let local_settings_folder = paths::local_settings_folder_name();
655    let is_local_settings = path.components().any(|component| {
656        component_matches_ignore_ascii_case(component.as_os_str(), local_settings_folder)
657    });
658    let is_agents_skills = is_agents_skills_path(path);
659
660    cx.spawn(async move |cx| {
661        // Resolve the path and check for symlink escapes.
662        let (project_entity, fs) = thread.read_with(cx, |thread, cx| {
663            let project = thread.project().clone();
664            let fs = project.read(cx).fs().clone();
665            (project, fs)
666        })?;
667
668        let canonical_roots = canonicalize_worktree_roots(&project_entity, &fs, cx).await;
669
670        let resolved = project_entity.read_with(cx, |project, cx| {
671            resolve_project_path(project, &path_owned, &canonical_roots, cx)
672        });
673
674        if let Ok(ResolvedProjectPath::SymlinkEscape {
675            canonical_target, ..
676        }) = &resolved
677        {
678            let authorize = cx.update(|cx| {
679                authorize_symlink_access(
680                    &tool_name,
681                    &path_owned.to_string_lossy(),
682                    canonical_target,
683                    &event_stream,
684                    cx,
685                )
686            });
687            return authorize.await;
688        }
689
690        // Create-mode paths may not resolve yet, so also inspect the parent path
691        // for symlink escapes before applying settings-based allow decisions.
692        if resolved.is_err() {
693            if let Some(parent_path) = path_owned.parent() {
694                let parent_resolved = project_entity.read_with(cx, |project, cx| {
695                    resolve_project_path(project, parent_path, &canonical_roots, cx)
696                });
697
698                if let Ok(ResolvedProjectPath::SymlinkEscape {
699                    canonical_target, ..
700                }) = &parent_resolved
701                {
702                    let authorize = cx.update(|cx| {
703                        authorize_symlink_access(
704                            &tool_name,
705                            &path_owned.to_string_lossy(),
706                            canonical_target,
707                            &event_stream,
708                            cx,
709                        )
710                    });
711                    return authorize.await;
712                }
713            }
714        }
715
716        let explicitly_allowed = matches!(decision, ToolPermissionDecision::Allow);
717
718        // Check sensitive settings asynchronously. Short-circuit on the
719        // raw-path fast paths to skip the canonicalization in
720        // `sensitive_settings_kind`; the slow path still runs for paths
721        // that don't trivially look sensitive, so `..` traversal and
722        // intra-project-symlink bypasses are still caught there.
723        let settings_kind = if is_local_settings {
724            Some(SensitiveSettingsKind::Local)
725        } else if is_agents_skills {
726            Some(SensitiveSettingsKind::AgentSkills)
727        } else {
728            sensitive_settings_kind(&path_owned, &canonical_roots, fs.as_ref()).await
729        };
730
731        let is_sensitive = settings_kind.is_some();
732        if explicitly_allowed && !is_sensitive {
733            return Ok(());
734        }
735
736        match settings_kind {
737            Some(SensitiveSettingsKind::Local) => {
738                let authorize = cx.update(|cx| {
739                    let context = ToolPermissionContext::new(
740                        &tool_name,
741                        vec![path_owned.to_string_lossy().to_string()],
742                    );
743                    event_stream.authorize_always_prompt(
744                        format!("{title} (local settings)"),
745                        context,
746                        cx,
747                    )
748                });
749                return authorize.await;
750            }
751            Some(SensitiveSettingsKind::Global) => {
752                let authorize = cx.update(|cx| {
753                    let context = ToolPermissionContext::new(
754                        &tool_name,
755                        vec![path_owned.to_string_lossy().to_string()],
756                    );
757                    event_stream.authorize_always_prompt(format!("{title} (settings)"), context, cx)
758                });
759                return authorize.await;
760            }
761            Some(SensitiveSettingsKind::AgentSkills) => {
762                let authorize = cx.update(|cx| {
763                    let context = ToolPermissionContext::new(
764                        &tool_name,
765                        vec![path_owned.to_string_lossy().to_string()],
766                    )
767                    .for_agent_skills();
768                    event_stream.authorize_always_prompt(
769                        format!("{title} (agent skills)"),
770                        context,
771                        cx,
772                    )
773                });
774                return authorize.await;
775            }
776            None => {}
777        }
778
779        match resolved {
780            Ok(_) => Ok(()),
781            Err(_) => {
782                let authorize = cx.update(|cx| {
783                    let context = ToolPermissionContext::new(
784                        &tool_name,
785                        vec![path_owned.to_string_lossy().to_string()],
786                    );
787                    event_stream.authorize(&title, context, cx)
788                });
789                authorize.await
790            }
791        }
792    })
793}
794
795/// The user's choice when prompted about how to handle unsaved changes
796/// in a buffer that the agent wants to edit or overwrite.
797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum DirtyBufferDecision {
799    /// Save the buffer's pending edits to disk, then proceed.
800    /// (Edit-mode prompt only.)
801    Save,
802    /// Discard the buffer's pending edits (reload from disk), then proceed.
803    Discard,
804    /// Keep the buffer's pending edits and cancel the agent's operation.
805    /// (Overwrite-mode prompt only.)
806    Keep,
807}
808
809/// Which prompt to show when the agent encounters a dirty buffer.
810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
811pub enum DirtyBufferPromptKind {
812    /// The agent wants to apply targeted edits on top of the current
813    /// content. Offers Save (persist edits, then edit on top) vs Discard
814    /// (revert to disk, then edit).
815    Edit,
816    /// The agent wants to overwrite the file's entire contents. Offers
817    /// Keep (cancel the overwrite to preserve the user's work) vs
818    /// Discard (reload from disk and let the agent overwrite).
819    Overwrite,
820}
821
822/// Prompts the user about how to handle a dirty buffer that the agent
823/// wants to edit or overwrite. Returns the chosen action; the caller is
824/// responsible for actually performing the corresponding side effect
825/// (save / reload / cancel) before continuing.
826pub fn authorize_dirty_buffer(
827    kind: DirtyBufferPromptKind,
828    event_stream: &ToolCallEventStream,
829    cx: &mut App,
830) -> Task<Result<DirtyBufferDecision>> {
831    let (message, options) = match kind {
832        DirtyBufferPromptKind::Edit => (
833            "This file has unsaved changes. Do you want to save or discard them \
834             before the agent continues editing?"
835                .to_string(),
836            vec![
837                acp::PermissionOption::new(
838                    acp::PermissionOptionId::new("save"),
839                    "Save",
840                    acp::PermissionOptionKind::AllowOnce,
841                ),
842                acp::PermissionOption::new(
843                    acp::PermissionOptionId::new("discard"),
844                    "Discard",
845                    acp::PermissionOptionKind::RejectOnce,
846                ),
847            ],
848        ),
849        DirtyBufferPromptKind::Overwrite => (
850            "This file has unsaved changes and the agent wants to overwrite it.".to_string(),
851            vec![
852                acp::PermissionOption::new(
853                    acp::PermissionOptionId::new("discard"),
854                    "Overwrite",
855                    acp::PermissionOptionKind::AllowOnce,
856                ),
857                acp::PermissionOption::new(
858                    acp::PermissionOptionId::new("keep"),
859                    "Cancel",
860                    acp::PermissionOptionKind::RejectOnce,
861                ),
862            ],
863        ),
864    };
865
866    let prompt = event_stream.prompt_for_decision(None, Some(message), options, cx);
867    cx.spawn(async move |_cx| {
868        let option_id = prompt.await?;
869        match option_id.0.as_ref() {
870            "save" => Ok(DirtyBufferDecision::Save),
871            "discard" => Ok(DirtyBufferDecision::Discard),
872            "keep" => Ok(DirtyBufferDecision::Keep),
873            other => Err(anyhow!(
874                "Unexpected dirty-buffer decision option_id: {other}"
875            )),
876        }
877    })
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use fs::Fs;
884    use gpui::TestAppContext;
885    use project::{FakeFs, Project};
886    use serde_json::json;
887    use settings::SettingsStore;
888    use util::path;
889
890    fn init_test(cx: &mut TestAppContext) {
891        cx.update(|cx| {
892            let settings_store = SettingsStore::test(cx);
893            cx.set_global(settings_store);
894        });
895    }
896
897    async fn worktree_roots(
898        project: &Entity<Project>,
899        fs: &Arc<dyn Fs>,
900        cx: &TestAppContext,
901    ) -> Vec<PathBuf> {
902        let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| {
903            project
904                .worktrees(cx)
905                .map(|wt| wt.read(cx).abs_path())
906                .collect()
907        });
908
909        let mut roots = Vec::with_capacity(abs_paths.len());
910        for p in &abs_paths {
911            match fs.canonicalize(p).await {
912                Ok(c) => roots.push(c),
913                Err(_) => roots.push(p.to_path_buf()),
914            }
915        }
916        roots
917    }
918
919    #[gpui::test]
920    async fn test_resolve_creatable_global_skill_path_allows_tilde_path(cx: &mut TestAppContext) {
921        init_test(cx);
922
923        let fs = FakeFs::new(cx.executor());
924        let input_path = PathBuf::from("~")
925            .join(".agents")
926            .join("skills")
927            .join("my-skill");
928        let expected_path = agent_skills::global_skills_dir().join("my-skill");
929
930        let resolved = resolve_creatable_global_skill_path(&input_path, fs.as_ref())
931            .await
932            .expect("global skill path should resolve");
933
934        assert_eq!(resolved, expected_path);
935    }
936
937    #[gpui::test]
938    async fn test_resolve_global_skill_path_allows_tilde_path(cx: &mut TestAppContext) {
939        init_test(cx);
940
941        let fs = FakeFs::new(cx.executor());
942        let skill_file = agent_skills::global_skills_dir()
943            .join("my-skill")
944            .join("SKILL.md");
945        fs.insert_tree(
946            skill_file
947                .parent()
948                .expect("skill file should have a parent"),
949            json!({ "SKILL.md": "---\nname: my-skill\ndescription: test\n---" }),
950        )
951        .await;
952
953        let input_path = PathBuf::from("~")
954            .join(".agents")
955            .join("skills")
956            .join("my-skill")
957            .join("SKILL.md");
958        let resolved = resolve_global_skill_path(&input_path, fs.as_ref())
959            .await
960            .expect("global skill file should resolve");
961
962        assert_eq!(resolved, skill_file);
963    }
964
965    #[gpui::test]
966    async fn test_resolve_global_skill_path_allows_symlinked_skill_dir(cx: &mut TestAppContext) {
967        init_test(cx);
968
969        let fs = FakeFs::new(cx.executor());
970        let skills_dir = agent_skills::global_skills_dir();
971        fs.insert_tree(
972            path!("/external/my-skill"),
973            json!({
974                "SKILL.md": "---\nname: my-skill\ndescription: test\n---",
975                "references": { "guide.md": "details" }
976            }),
977        )
978        .await;
979        fs.create_dir(&skills_dir)
980            .await
981            .expect("global skills directory should be created");
982        fs.create_symlink(
983            &skills_dir.join("my-skill"),
984            PathBuf::from(path!("/external/my-skill")),
985        )
986        .await
987        .expect("skill directory should be symlinked");
988
989        let input_path = PathBuf::from("~")
990            .join(".agents")
991            .join("skills")
992            .join("my-skill")
993            .join("references")
994            .join("guide.md");
995        let resolved = resolve_global_skill_path(&input_path, fs.as_ref())
996            .await
997            .expect("symlinked global skill resource should resolve");
998
999        assert_eq!(
1000            resolved,
1001            PathBuf::from(path!("/external/my-skill/references/guide.md"))
1002        );
1003    }
1004
1005    #[gpui::test]
1006    async fn test_resolve_global_skill_path_rejects_escape_from_symlinked_skill_dir(
1007        cx: &mut TestAppContext,
1008    ) {
1009        init_test(cx);
1010
1011        let fs = FakeFs::new(cx.executor());
1012        let skills_dir = agent_skills::global_skills_dir();
1013        fs.insert_tree(
1014            path!("/external/my-skill"),
1015            json!({
1016                "SKILL.md": "---\nname: my-skill\ndescription: test\n---",
1017            }),
1018        )
1019        .await;
1020        fs.insert_tree(path!("/private"), json!({ "secret.txt": "secret" }))
1021            .await;
1022        fs.create_symlink(
1023            &PathBuf::from(path!("/external/my-skill/secret")),
1024            PathBuf::from(path!("/private")),
1025        )
1026        .await
1027        .expect("nested symlink should be created");
1028        fs.create_dir(&skills_dir)
1029            .await
1030            .expect("global skills directory should be created");
1031        fs.create_symlink(
1032            &skills_dir.join("my-skill"),
1033            PathBuf::from(path!("/external/my-skill")),
1034        )
1035        .await
1036        .expect("skill directory should be symlinked");
1037
1038        let input_path = PathBuf::from("~")
1039            .join(".agents")
1040            .join("skills")
1041            .join("my-skill")
1042            .join("secret")
1043            .join("secret.txt");
1044
1045        assert!(
1046            resolve_global_skill_path(&input_path, fs.as_ref())
1047                .await
1048                .is_none(),
1049            "nested symlinks inside a symlinked skill must not broaden global skill access",
1050        );
1051    }
1052
1053    #[gpui::test]
1054    async fn test_resolve_creatable_global_skill_path_rejects_other_home_paths(
1055        cx: &mut TestAppContext,
1056    ) {
1057        init_test(cx);
1058
1059        let fs = FakeFs::new(cx.executor());
1060        let sibling_path = PathBuf::from("~").join(".agents").join("not-skills");
1061        let escaped_path = PathBuf::from("~")
1062            .join(".agents")
1063            .join("skills")
1064            .join("..")
1065            .join("not-skills");
1066
1067        assert!(
1068            resolve_creatable_global_skill_path(&sibling_path, fs.as_ref())
1069                .await
1070                .is_none()
1071        );
1072        assert!(
1073            resolve_creatable_global_skill_path(&escaped_path, fs.as_ref())
1074                .await
1075                .is_none()
1076        );
1077    }
1078
1079    #[gpui::test]
1080    async fn test_resolve_creatable_global_skill_path_rejects_symlink_escape(
1081        cx: &mut TestAppContext,
1082    ) {
1083        init_test(cx);
1084
1085        let fs = FakeFs::new(cx.executor());
1086        let skills_dir = agent_skills::global_skills_dir();
1087        fs.create_dir(&skills_dir)
1088            .await
1089            .expect("global skills directory should be created");
1090        fs.create_dir(path!("/external").as_ref())
1091            .await
1092            .expect("external directory should be created");
1093        fs.create_symlink(&skills_dir.join("link"), PathBuf::from(path!("/external")))
1094            .await
1095            .expect("symlink should be created");
1096
1097        let escaped_path = PathBuf::from("~")
1098            .join(".agents")
1099            .join("skills")
1100            .join("link")
1101            .join("new-dir");
1102
1103        assert!(
1104            resolve_creatable_global_skill_path(&escaped_path, fs.as_ref())
1105                .await
1106                .is_none()
1107        );
1108    }
1109
1110    #[gpui::test]
1111    async fn test_global_skill_path_resolvers_reject_absolute_paths_when_skills_dir_is_symlink_to_root(
1112        cx: &mut TestAppContext,
1113    ) {
1114        init_test(cx);
1115
1116        let fs = FakeFs::new(cx.executor());
1117        fs.insert_tree(paths::home_dir(), json!({ ".agents": {} }))
1118            .await;
1119        fs.insert_tree(path!("/tmp"), json!({ "outside.txt": "outside" }))
1120            .await;
1121
1122        let skills_dir = agent_skills::global_skills_dir();
1123        fs.create_symlink(&skills_dir, PathBuf::from(path!("/")))
1124            .await
1125            .expect("global skills directory should be symlinked to root");
1126
1127        let outside_path = PathBuf::from(path!("/tmp/outside.txt"));
1128        assert!(
1129            resolve_global_skill_path(&outside_path, fs.as_ref())
1130                .await
1131                .is_none(),
1132            "existing absolute paths outside the lexical global skills tree should not resolve",
1133        );
1134        assert!(
1135            resolve_creatable_global_skill_path(&outside_path, fs.as_ref())
1136                .await
1137                .is_none(),
1138            "creatable absolute paths outside the lexical global skills tree should not resolve",
1139        );
1140
1141        let traversed_path = PathBuf::from("~")
1142            .join(".agents")
1143            .join("skills")
1144            .join("..")
1145            .join("outside");
1146        assert!(
1147            resolve_creatable_global_skill_path(&traversed_path, fs.as_ref())
1148                .await
1149                .is_none(),
1150            "paths that normalize outside the lexical global skills tree should not resolve",
1151        );
1152    }
1153
1154    #[gpui::test]
1155    async fn test_global_skill_path_resolvers_reject_absolute_paths_when_skills_dir_is_symlink_to_home(
1156        cx: &mut TestAppContext,
1157    ) {
1158        init_test(cx);
1159
1160        let fs = FakeFs::new(cx.executor());
1161        fs.insert_tree(
1162            paths::home_dir(),
1163            json!({
1164                ".agents": {},
1165                "outside.txt": "outside",
1166            }),
1167        )
1168        .await;
1169
1170        let skills_dir = agent_skills::global_skills_dir();
1171        fs.create_symlink(&skills_dir, paths::home_dir().clone())
1172            .await
1173            .expect("global skills directory should be symlinked to home");
1174
1175        let outside_path = paths::home_dir().join("outside.txt");
1176        assert!(
1177            resolve_global_skill_path(&outside_path, fs.as_ref())
1178                .await
1179                .is_none(),
1180            "existing absolute paths outside the lexical global skills tree should not resolve",
1181        );
1182        assert!(
1183            resolve_creatable_global_skill_path(&outside_path, fs.as_ref())
1184                .await
1185                .is_none(),
1186            "creatable absolute paths outside the lexical global skills tree should not resolve",
1187        );
1188    }
1189
1190    #[gpui::test]
1191    async fn test_resolve_project_path_safe_for_normal_files(cx: &mut TestAppContext) {
1192        init_test(cx);
1193
1194        let fs = FakeFs::new(cx.executor());
1195        fs.insert_tree(
1196            path!("/root/project"),
1197            json!({
1198                "src": {
1199                    "main.rs": "fn main() {}",
1200                    "lib.rs": "pub fn hello() {}"
1201                },
1202                "README.md": "# Project"
1203            }),
1204        )
1205        .await;
1206
1207        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1208        cx.run_until_parked();
1209        let fs_arc: Arc<dyn Fs> = fs;
1210        let roots = worktree_roots(&project, &fs_arc, cx).await;
1211
1212        cx.read(|cx| {
1213            let project = project.read(cx);
1214
1215            let resolved = resolve_project_path(project, "project/src/main.rs", &roots, cx)
1216                .expect("should resolve normal file");
1217            assert!(
1218                matches!(resolved, ResolvedProjectPath::Safe(_)),
1219                "normal file should be Safe, got: {:?}",
1220                resolved
1221            );
1222
1223            let resolved = resolve_project_path(project, "project/README.md", &roots, cx)
1224                .expect("should resolve readme");
1225            assert!(
1226                matches!(resolved, ResolvedProjectPath::Safe(_)),
1227                "readme should be Safe, got: {:?}",
1228                resolved
1229            );
1230        });
1231    }
1232
1233    #[gpui::test]
1234    async fn test_resolve_project_path_detects_symlink_escape(cx: &mut TestAppContext) {
1235        init_test(cx);
1236
1237        let fs = FakeFs::new(cx.executor());
1238        fs.insert_tree(
1239            path!("/root"),
1240            json!({
1241                "project": {
1242                    "src": {
1243                        "main.rs": "fn main() {}"
1244                    }
1245                },
1246                "external": {
1247                    "secret.txt": "top secret"
1248                }
1249            }),
1250        )
1251        .await;
1252
1253        fs.create_symlink(path!("/root/project/link").as_ref(), "../external".into())
1254            .await
1255            .expect("should create symlink");
1256
1257        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1258        cx.run_until_parked();
1259        let fs_arc: Arc<dyn Fs> = fs;
1260        let roots = worktree_roots(&project, &fs_arc, cx).await;
1261
1262        cx.read(|cx| {
1263            let project = project.read(cx);
1264
1265            let resolved = resolve_project_path(project, "project/link", &roots, cx)
1266                .expect("should resolve symlink path");
1267            match &resolved {
1268                ResolvedProjectPath::SymlinkEscape {
1269                    canonical_target, ..
1270                } => {
1271                    assert_eq!(
1272                        canonical_target,
1273                        Path::new(path!("/root/external")),
1274                        "canonical target should point to external directory"
1275                    );
1276                }
1277                ResolvedProjectPath::Safe(_) => {
1278                    panic!("symlink escaping project should be detected as SymlinkEscape");
1279                }
1280            }
1281        });
1282    }
1283
1284    #[gpui::test]
1285    async fn test_resolve_project_path_allows_intra_project_symlinks(cx: &mut TestAppContext) {
1286        init_test(cx);
1287
1288        let fs = FakeFs::new(cx.executor());
1289        fs.insert_tree(
1290            path!("/root/project"),
1291            json!({
1292                "real_dir": {
1293                    "file.txt": "hello"
1294                }
1295            }),
1296        )
1297        .await;
1298
1299        fs.create_symlink(path!("/root/project/link_dir").as_ref(), "real_dir".into())
1300            .await
1301            .expect("should create symlink");
1302
1303        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1304        cx.run_until_parked();
1305        let fs_arc: Arc<dyn Fs> = fs;
1306        let roots = worktree_roots(&project, &fs_arc, cx).await;
1307
1308        cx.read(|cx| {
1309            let project = project.read(cx);
1310
1311            let resolved = resolve_project_path(project, "project/link_dir", &roots, cx)
1312                .expect("should resolve intra-project symlink");
1313            assert!(
1314                matches!(resolved, ResolvedProjectPath::Safe(_)),
1315                "intra-project symlink should be Safe, got: {:?}",
1316                resolved
1317            );
1318        });
1319    }
1320
1321    #[gpui::test]
1322    async fn test_resolve_project_path_missing_child_under_external_symlink(
1323        cx: &mut TestAppContext,
1324    ) {
1325        init_test(cx);
1326
1327        let fs = FakeFs::new(cx.executor());
1328        fs.insert_tree(
1329            path!("/root"),
1330            json!({
1331                "project": {},
1332                "external": {
1333                    "existing.txt": "hello"
1334                }
1335            }),
1336        )
1337        .await;
1338
1339        fs.create_symlink(path!("/root/project/link").as_ref(), "../external".into())
1340            .await
1341            .expect("should create symlink");
1342
1343        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1344        cx.run_until_parked();
1345        let fs_arc: Arc<dyn Fs> = fs;
1346        let roots = worktree_roots(&project, &fs_arc, cx).await;
1347
1348        cx.read(|cx| {
1349            let project = project.read(cx);
1350
1351            let resolved = resolve_project_path(project, "project/link/new_dir", &roots, cx)
1352                .expect("should resolve missing child path under symlink");
1353            match resolved {
1354                ResolvedProjectPath::SymlinkEscape {
1355                    canonical_target, ..
1356                } => {
1357                    assert_eq!(
1358                        canonical_target,
1359                        Path::new(path!("/root/external/new_dir")),
1360                        "missing child path should resolve to escaped canonical target",
1361                    );
1362                }
1363                ResolvedProjectPath::Safe(_) => {
1364                    panic!("missing child under external symlink should be SymlinkEscape");
1365                }
1366            }
1367        });
1368    }
1369
1370    #[gpui::test]
1371    async fn test_resolve_project_path_allows_cross_worktree_symlinks(cx: &mut TestAppContext) {
1372        init_test(cx);
1373
1374        let fs = FakeFs::new(cx.executor());
1375        fs.insert_tree(
1376            path!("/root"),
1377            json!({
1378                "worktree_one": {},
1379                "worktree_two": {
1380                    "shared_dir": {
1381                        "file.txt": "hello"
1382                    }
1383                }
1384            }),
1385        )
1386        .await;
1387
1388        fs.create_symlink(
1389            path!("/root/worktree_one/link_to_worktree_two").as_ref(),
1390            PathBuf::from("../worktree_two/shared_dir"),
1391        )
1392        .await
1393        .expect("should create symlink");
1394
1395        let project = Project::test(
1396            fs.clone(),
1397            [
1398                path!("/root/worktree_one").as_ref(),
1399                path!("/root/worktree_two").as_ref(),
1400            ],
1401            cx,
1402        )
1403        .await;
1404        cx.run_until_parked();
1405        let fs_arc: Arc<dyn Fs> = fs;
1406        let roots = worktree_roots(&project, &fs_arc, cx).await;
1407
1408        cx.read(|cx| {
1409            let project = project.read(cx);
1410
1411            let resolved =
1412                resolve_project_path(project, "worktree_one/link_to_worktree_two", &roots, cx)
1413                    .expect("should resolve cross-worktree symlink");
1414            assert!(
1415                matches!(resolved, ResolvedProjectPath::Safe(_)),
1416                "cross-worktree symlink should be Safe, got: {:?}",
1417                resolved
1418            );
1419        });
1420    }
1421
1422    #[gpui::test]
1423    async fn test_resolve_project_path_missing_child_under_cross_worktree_symlink(
1424        cx: &mut TestAppContext,
1425    ) {
1426        init_test(cx);
1427
1428        let fs = FakeFs::new(cx.executor());
1429        fs.insert_tree(
1430            path!("/root"),
1431            json!({
1432                "worktree_one": {},
1433                "worktree_two": {
1434                    "shared_dir": {}
1435                }
1436            }),
1437        )
1438        .await;
1439
1440        fs.create_symlink(
1441            path!("/root/worktree_one/link_to_worktree_two").as_ref(),
1442            PathBuf::from("../worktree_two/shared_dir"),
1443        )
1444        .await
1445        .expect("should create symlink");
1446
1447        let project = Project::test(
1448            fs.clone(),
1449            [
1450                path!("/root/worktree_one").as_ref(),
1451                path!("/root/worktree_two").as_ref(),
1452            ],
1453            cx,
1454        )
1455        .await;
1456        cx.run_until_parked();
1457        let fs_arc: Arc<dyn Fs> = fs;
1458        let roots = worktree_roots(&project, &fs_arc, cx).await;
1459
1460        cx.read(|cx| {
1461            let project = project.read(cx);
1462
1463            let resolved = resolve_project_path(
1464                project,
1465                "worktree_one/link_to_worktree_two/new_dir",
1466                &roots,
1467                cx,
1468            )
1469            .expect("should resolve missing child under cross-worktree symlink");
1470            assert!(
1471                matches!(resolved, ResolvedProjectPath::Safe(_)),
1472                "missing child under cross-worktree symlink should be Safe, got: {:?}",
1473                resolved
1474            );
1475        });
1476    }
1477}
1478
Served at tenant.openagents/omega Member data and write actions are omitted.