Skip to repository content1478 lines · 53.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:39:46.418Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
tool_permissions.rs
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