Skip to main content

mcp_execution_skill/
output_path.rs

1//! Confinement of caller-supplied `SKILL.md` output paths to a base directory.
2//!
3//! `save_skill` accepts an optional `output_path` from the caller. Without
4//! confinement, a malicious or buggy caller could supply an absolute path,
5//! a `..`-relative path, or a path that walks through a symlink planted
6//! inside the base directory to write arbitrary files anywhere on disk
7//! (see issue #184). [`resolve_skill_output_path`] mirrors the read-side
8//! confinement already used by [`crate::scan_tools_directory`], adapted for
9//! a target that may not exist yet.
10
11use mcp_execution_core::sanitize_path_for_error;
12use std::path::{Component, Path, PathBuf};
13use thiserror::Error;
14
15/// Errors from resolving and confining a skill output path to its base directory.
16#[derive(Debug, Error)]
17pub enum OutputPathError {
18    /// `server_id` is empty or is not a single plain path segment (e.g. it
19    /// contains a `..`, `/`, or is otherwise not a bare directory name).
20    #[error("server_id must be a single non-empty path segment: {server_id:?}")]
21    InvalidServerId {
22        /// The rejected server id.
23        server_id: String,
24    },
25
26    /// The caller supplied an absolute `output_path`, which would override
27    /// the base directory entirely.
28    #[error("output_path must be relative to the skills directory, not absolute: {path}")]
29    AbsolutePath {
30        /// Sanitized display form of the rejected path.
31        path: String,
32    },
33
34    /// The caller-supplied `output_path` contains a `..` component.
35    #[error("output_path must not contain '..' components: {path}")]
36    ParentTraversal {
37        /// Sanitized display form of the rejected path.
38        path: String,
39    },
40
41    /// The caller-supplied `output_path` has no file name component.
42    #[error("output_path is not a valid file path: {path}")]
43    InvalidPath {
44        /// Sanitized display form of the rejected path.
45        path: String,
46    },
47
48    /// `server_id`'s own directory already exists as a symlink, which is
49    /// rejected outright regardless of where it points - including at a
50    /// sibling server's own directory, which would otherwise pass a
51    /// resolve-and-confine check (issue #217).
52    #[error("server_id directory must not be a symlink: {path}")]
53    ServerIdIsSymlink {
54        /// Sanitized display form of the offending path.
55        path: String,
56    },
57
58    /// The resolved path escapes the base directory, typically because a
59    /// path component resolved through (or is itself) a symlink that points
60    /// outside it.
61    #[error("resolved path escapes the skills directory: {path}")]
62    Escape {
63        /// Sanitized display form of the path that escaped confinement.
64        path: String,
65    },
66
67    /// A path component that must be a directory already exists as
68    /// something else (e.g. a regular file).
69    #[error("path component is not a directory: {path}")]
70    NotADirectory {
71        /// Sanitized display form of the offending component.
72        path: String,
73    },
74
75    /// The resolved output path already exists as a directory.
76    #[error("output path is a directory, not a file: {path}")]
77    NotAFile {
78        /// Sanitized display form of the offending path.
79        path: String,
80    },
81
82    /// Creating a directory needed to hold the output file failed.
83    #[error("failed to create directory {path}: {source}")]
84    CreateDir {
85        /// Sanitized display form of the directory that could not be created.
86        path: String,
87        /// Underlying I/O error.
88        #[source]
89        source: std::io::Error,
90    },
91
92    /// I/O error resolving the base directory itself.
93    #[error("failed to resolve skills directory: {0}")]
94    Io(#[from] std::io::Error),
95}
96
97/// Validates `server_id` is a single plain path segment: non-empty, and
98/// with no `..`, path separator, or root/prefix component.
99///
100/// `server_id` is walked through the same confinement loop as
101/// `output_path`'s directory components (see [`resolve_skill_output_path`]),
102/// so a `..` or absolute `server_id` would eventually be caught there too —
103/// this check rejects it earlier, before any filesystem work, and gives a
104/// precise error rather than relying on the generic `Escape` path.
105///
106/// Returns the validated [`Component`] itself (borrowed from `server_id`),
107/// rather than just `Ok(())`, so the caller pushes the exact component this
108/// function parsed and approved instead of re-deriving one from the raw
109/// string — which could diverge from this validation on an input like
110/// `"a/."`, where `Path::components()` normalizes away the trailing `.` and
111/// this function sees a single `Normal("a")`, but a fresh
112/// `Component::Normal(OsStr::new("a/."))` would still carry the embedded
113/// separator. Delegates to `mcp_execution_core::validate_path_segment`, shared
114/// with `mcp-execution-server`'s equivalent check for `introspect_server`'s
115/// `output_dir`, so both crates reject the same malformed `server_id` shapes.
116fn validate_server_id_segment(server_id: &str) -> Result<Component<'_>, OutputPathError> {
117    mcp_execution_core::validate_path_segment(server_id).ok_or_else(|| {
118        OutputPathError::InvalidServerId {
119            server_id: server_id.to_string(),
120        }
121    })
122}
123
124/// Validates a caller-supplied `output_path` and returns it as a safe,
125/// base-relative path, or `SKILL.md` when none was supplied.
126fn relative_target(output_path: Option<&Path>) -> Result<PathBuf, OutputPathError> {
127    let Some(path) = output_path else {
128        return Ok(PathBuf::from("SKILL.md"));
129    };
130
131    if path.is_absolute() {
132        return Err(OutputPathError::AbsolutePath {
133            path: sanitize_path_for_error(path),
134        });
135    }
136    if mcp_execution_core::contains_parent_dir(path) {
137        return Err(OutputPathError::ParentTraversal {
138            path: sanitize_path_for_error(path),
139        });
140    }
141    if path.file_name().is_none() {
142        return Err(OutputPathError::InvalidPath {
143            path: sanitize_path_for_error(path),
144        });
145    }
146
147    Ok(path.to_path_buf())
148}
149
150/// Resolves a `save_skill` output path, confining it to `base_dir/server_id`.
151///
152/// `server_id` and `output_path` are both attacker-influenced, so both are
153/// walked through the *same* checked component loop, rooted at `base_dir`
154/// (canonicalized once, since `base_dir` itself is trusted, first-party
155/// configuration rather than caller input): `server_id` is pushed as the
156/// first component, followed by `output_path`'s directory components, if
157/// any. `output_path`, when supplied, is treated as *relative* to
158/// `base_dir/server_id`: an absolute path or a path containing a `..`
159/// component is rejected before any filesystem work happens. `None`
160/// resolves to the default `SKILL.md`. Confining to `base_dir/server_id`
161/// (rather than just `base_dir`) matches the documented `save_skill`
162/// contract, in the sense that both the default path and every accepted
163/// `output_path` land under `server_id`'s own directory.
164///
165/// `server_id`'s own directory is resolved before anything else and is
166/// rejected outright if it already exists as a symlink, regardless of where
167/// it points: a resolve-and-confine check alone would accept a symlink from
168/// `base_dir/server-b` to `base_dir/server-a`, since the target still
169/// resolves under `base_dir` (issue #217). Every subsequent `output_path`
170/// directory component is then confined to that resolved `server_id`
171/// directory specifically, not merely to `base_dir` as a whole, so no
172/// deeper component can reach a different server's directory either.
173///
174/// Each component is created and confinement-checked one at a time (rather
175/// than via a single recursive create-and-canonicalize), so that a symlink
176/// already present under `base_dir` when this call starts — whether *at*
177/// `server_id` or at any deeper `output_path` directory component — is
178/// resolved and rejected *before* this function creates anything under it
179/// or descends into it. This is a check against pre-existing state, not a
180/// concurrency guarantee — it does not defend against a symlink planted by
181/// a racing process between this function's checks and the caller's
182/// subsequent write. The final path component is rejected outright if it
183/// already exists as a symlink, dangling or not: a dangling symlink can't
184/// be resolved by `canonicalize`, but would still be followed by a
185/// subsequent write, so it is checked with `symlink_metadata` instead.
186///
187/// # Errors
188///
189/// Returns [`OutputPathError`] if `server_id` is empty or not a single
190/// plain path segment, if `output_path` is absolute, contains a `..`
191/// component, or has no file name, if the resolved path escapes `base_dir`
192/// (including via a symlink at `server_id` itself or any deeper
193/// component), if a required directory could not be created, or if a path
194/// component that must be a directory (or must hold the output file)
195/// already exists as the wrong kind of entry.
196///
197/// # Examples
198///
199/// ```no_run
200/// use mcp_execution_skill::resolve_skill_output_path;
201/// use std::path::Path;
202///
203/// # async fn example() -> Result<(), mcp_execution_skill::OutputPathError> {
204/// let path =
205///     resolve_skill_output_path(Path::new("/home/user/.claude/skills"), "github", None).await?;
206/// println!("Resolved to {}", path.display());
207/// # Ok(())
208/// # }
209/// ```
210pub async fn resolve_skill_output_path(
211    base_dir: &Path,
212    server_id: &str,
213    output_path: Option<&Path>,
214) -> Result<PathBuf, OutputPathError> {
215    let server_component = validate_server_id_segment(server_id)?;
216    let relative = relative_target(output_path)?;
217
218    tokio::fs::create_dir_all(base_dir)
219        .await
220        .map_err(|source| OutputPathError::CreateDir {
221            path: sanitize_path_for_error(base_dir),
222            source,
223        })?;
224    let canonical_root = tokio::fs::canonicalize(base_dir).await?;
225
226    // Resolve server_id's own directory first, rejecting it outright if it already exists as
227    // a symlink - regardless of where it points - rather than resolving and re-checking it as
228    // the loop below does for deeper components. A symlink here could point at a *sibling*
229    // server's own directory, which still resolves under `canonical_root` and would therefore
230    // pass a resolve-and-confine check; only an unconditional rejection closes that gap
231    // (issue #217). Every subsequent component is then confined to `server_dir` specifically,
232    // not just to `canonical_root` as a whole, so a caller scoped to this `server_id` can never
233    // reach another server's directory through a deeper `output_path` component either.
234    let mut server_dir = canonical_root.clone();
235    server_dir.push(server_component);
236    if !server_dir.starts_with(&canonical_root) {
237        return Err(OutputPathError::Escape {
238            path: sanitize_path_for_error(&server_dir),
239        });
240    }
241    match tokio::fs::symlink_metadata(&server_dir).await {
242        Ok(meta) => {
243            if meta.file_type().is_symlink() {
244                return Err(OutputPathError::ServerIdIsSymlink {
245                    path: sanitize_path_for_error(&server_dir),
246                });
247            }
248            if !meta.is_dir() {
249                return Err(OutputPathError::NotADirectory {
250                    path: sanitize_path_for_error(&server_dir),
251                });
252            }
253        }
254        Err(_) => {
255            tokio::fs::create_dir(&server_dir).await.map_err(|source| {
256                OutputPathError::CreateDir {
257                    path: sanitize_path_for_error(&server_dir),
258                    source,
259                }
260            })?;
261        }
262    }
263
264    let output_dir_components = relative
265        .parent()
266        .map(|parent| parent.components().collect::<Vec<_>>())
267        .unwrap_or_default();
268
269    let mut current = server_dir.clone();
270    for component in output_dir_components {
271        current.push(component);
272        // Cheap for a `..`-free relative path pushed onto an absolute root (a
273        // named component can't leave it), but load-bearing on Windows: a
274        // root-without-prefix component like `\pwn` is not caught by
275        // `Path::is_absolute` and would otherwise reach `create_dir` unconfined.
276        if !current.starts_with(&server_dir) {
277            return Err(OutputPathError::Escape {
278                path: sanitize_path_for_error(&current),
279            });
280        }
281        match tokio::fs::symlink_metadata(&current).await {
282            Ok(_) => {
283                let resolved = tokio::fs::canonicalize(&current).await?;
284                if !resolved.starts_with(&server_dir) {
285                    return Err(OutputPathError::Escape {
286                        path: sanitize_path_for_error(&current),
287                    });
288                }
289                if !tokio::fs::metadata(&resolved).await?.is_dir() {
290                    return Err(OutputPathError::NotADirectory {
291                        path: sanitize_path_for_error(&current),
292                    });
293                }
294                current = resolved;
295            }
296            Err(_) => {
297                tokio::fs::create_dir(&current).await.map_err(|source| {
298                    OutputPathError::CreateDir {
299                        path: sanitize_path_for_error(&current),
300                        source,
301                    }
302                })?;
303            }
304        }
305    }
306
307    // `relative_target` already guarantees a file name is present; re-checked
308    // here (rather than `.expect`-ed) so this function has no panic surface.
309    let Some(file_name) = relative.file_name() else {
310        return Err(OutputPathError::InvalidPath {
311            path: sanitize_path_for_error(&relative),
312        });
313    };
314    let final_path = current.join(file_name);
315
316    if let Ok(meta) = tokio::fs::symlink_metadata(&final_path).await {
317        // Reject unconditionally, independent of whether `canonicalize`
318        // would succeed: a dangling symlink (target does not exist) makes
319        // canonicalize fail, which must not be treated as "safe, doesn't
320        // exist yet" — a subsequent write still follows the link.
321        if meta.file_type().is_symlink() {
322            return Err(OutputPathError::Escape {
323                path: sanitize_path_for_error(&final_path),
324            });
325        }
326        if meta.is_dir() {
327            return Err(OutputPathError::NotAFile {
328                path: sanitize_path_for_error(&final_path),
329            });
330        }
331    }
332
333    Ok(final_path)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use tempfile::TempDir;
340
341    #[tokio::test]
342    async fn default_path_stays_within_base() {
343        let base = TempDir::new().unwrap();
344        let resolved = resolve_skill_output_path(base.path(), "my-server", None)
345            .await
346            .unwrap();
347        let canonical_base = base.path().canonicalize().unwrap();
348        assert!(resolved.starts_with(&canonical_base));
349        assert_eq!(resolved, canonical_base.join("my-server").join("SKILL.md"));
350    }
351
352    #[tokio::test]
353    async fn legitimate_relative_path_is_accepted() {
354        let base = TempDir::new().unwrap();
355        let resolved =
356            resolve_skill_output_path(base.path(), "my-server", Some(Path::new("custom/SKILL.md")))
357                .await
358                .unwrap();
359        let canonical_base = base.path().canonicalize().unwrap();
360        assert!(resolved.starts_with(&canonical_base));
361        assert_eq!(
362            resolved,
363            canonical_base
364                .join("my-server")
365                .join("custom")
366                .join("SKILL.md")
367        );
368    }
369
370    #[tokio::test]
371    async fn empty_server_id_is_rejected() {
372        let base = TempDir::new().unwrap();
373        let err = resolve_skill_output_path(base.path(), "", None)
374            .await
375            .unwrap_err();
376        assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
377        // Rejected before any filesystem work, so nothing was created inside
378        // the (already-existing, since `TempDir::new` creates it) base dir.
379        assert!(
380            tokio::fs::read_dir(base.path())
381                .await
382                .unwrap()
383                .next_entry()
384                .await
385                .unwrap()
386                .is_none()
387        );
388    }
389
390    #[tokio::test]
391    async fn server_id_with_parent_traversal_is_rejected() {
392        let base = TempDir::new().unwrap();
393        tokio::fs::create_dir_all(base.path().join("other-server"))
394            .await
395            .unwrap();
396
397        let err = resolve_skill_output_path(base.path(), "../other-server", None)
398            .await
399            .unwrap_err();
400        assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
401    }
402
403    #[tokio::test]
404    async fn server_id_with_path_separator_is_rejected() {
405        let base = TempDir::new().unwrap();
406        let err = resolve_skill_output_path(base.path(), "a/b", None)
407            .await
408            .unwrap_err();
409        assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
410    }
411
412    #[tokio::test]
413    async fn absolute_path_is_rejected() {
414        let base = TempDir::new().unwrap();
415        // A bare `/etc/passwd`-style path has no drive prefix, so
416        // `Path::is_absolute()` is false for it on Windows (see
417        // `windows_root_relative_path_cannot_escape_base` below, which
418        // covers that case separately via the `Escape` variant); use a
419        // path that is genuinely absolute on the current platform so this
420        // test exercises `AbsolutePath` specifically.
421        let absolute = if cfg!(windows) {
422            r"C:\Windows\System32\config"
423        } else {
424            "/etc/passwd"
425        };
426        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new(absolute)))
427            .await
428            .unwrap_err();
429        assert!(matches!(err, OutputPathError::AbsolutePath { .. }));
430        assert!(!base.path().join("my-server").exists());
431    }
432
433    #[tokio::test]
434    async fn parent_traversal_is_rejected() {
435        let base = TempDir::new().unwrap();
436        let err = resolve_skill_output_path(
437            base.path(),
438            "my-server",
439            Some(Path::new("../../etc/passwd")),
440        )
441        .await
442        .unwrap_err();
443        assert!(matches!(err, OutputPathError::ParentTraversal { .. }));
444        assert!(!base.path().join("my-server").exists());
445    }
446
447    #[tokio::test]
448    async fn path_with_no_file_name_is_rejected() {
449        let base = TempDir::new().unwrap();
450        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new(".")))
451            .await
452            .unwrap_err();
453        assert!(matches!(err, OutputPathError::InvalidPath { .. }));
454    }
455
456    #[tokio::test]
457    async fn empty_output_path_is_rejected() {
458        let base = TempDir::new().unwrap();
459        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("")))
460            .await
461            .unwrap_err();
462        assert!(matches!(err, OutputPathError::InvalidPath { .. }));
463    }
464
465    // Windows path semantics differ enough from Unix (root-without-prefix
466    // components, drive-relative paths) that the confinement guard needs
467    // its own coverage rather than relying on the Unix-shaped tests above.
468    #[cfg(windows)]
469    #[tokio::test]
470    async fn windows_root_relative_path_cannot_escape_base() {
471        let base = TempDir::new().unwrap();
472        // `is_absolute()` is false for a root-without-prefix path like this
473        // on Windows, so it passes `relative_target`'s absolute-path check;
474        // confinement must catch it via `starts_with` instead.
475        let err =
476            resolve_skill_output_path(base.path(), "my-server", Some(Path::new(r"\pwn\evil.md")))
477                .await
478                .unwrap_err();
479        assert!(matches!(err, OutputPathError::Escape { .. }));
480    }
481
482    /// S4 regression: `base_dir/server_id` itself is a symlink to outside
483    /// the base — planted before this call, e.g. by an earlier `save_skill`
484    /// invocation or another process with write access to the base. Exercises
485    /// the same loop iteration (`server_id` as the first walked component)
486    /// used for `symlinked_parent_directory_escape_is_rejected` below, one
487    /// level up.
488    #[tokio::test]
489    #[cfg(unix)]
490    async fn symlinked_server_id_directory_escape_is_rejected() {
491        let base = TempDir::new().unwrap();
492        let outside = TempDir::new().unwrap();
493
494        std::os::unix::fs::symlink(outside.path(), base.path().join("my-server")).unwrap();
495
496        let err = resolve_skill_output_path(base.path(), "my-server", None)
497            .await
498            .unwrap_err();
499        assert!(matches!(err, OutputPathError::ServerIdIsSymlink { .. }));
500        assert!(!outside.path().join("SKILL.md").exists());
501    }
502
503    /// #217 regression: a symlink at `server_id`'s own directory pointing at
504    /// a *sibling* directory that lives inside the same base must still be
505    /// rejected outright, not merely allowed because it resolves under the
506    /// base as a whole.
507    #[tokio::test]
508    #[cfg(unix)]
509    async fn symlinked_server_id_directory_to_sibling_is_rejected() {
510        let base = TempDir::new().unwrap();
511        tokio::fs::create_dir_all(base.path().join("server-a"))
512            .await
513            .unwrap();
514        std::os::unix::fs::symlink(base.path().join("server-a"), base.path().join("server-b"))
515            .unwrap();
516
517        let err = resolve_skill_output_path(base.path(), "server-b", None)
518            .await
519            .unwrap_err();
520        assert!(matches!(err, OutputPathError::ServerIdIsSymlink { .. }));
521        assert!(!base.path().join("server-a").join("SKILL.md").exists());
522    }
523
524    /// S2 regression guard, now running through the unified walk shared
525    /// with the `server_id` (S4) and final-component (S1) checks.
526    #[tokio::test]
527    #[cfg(unix)]
528    async fn symlinked_parent_directory_escape_is_rejected() {
529        let base = TempDir::new().unwrap();
530        let outside = TempDir::new().unwrap();
531
532        // Plant the symlink *inside* the confinement root (base/my-server),
533        // matching the threat model: a symlink already present there when
534        // `save_skill` is called.
535        let server_dir = base.path().join("my-server");
536        tokio::fs::create_dir_all(&server_dir).await.unwrap();
537        std::os::unix::fs::symlink(outside.path(), server_dir.join("escape")).unwrap();
538
539        let err =
540            resolve_skill_output_path(base.path(), "my-server", Some(Path::new("escape/SKILL.md")))
541                .await
542                .unwrap_err();
543        assert!(matches!(err, OutputPathError::Escape { .. }));
544
545        // Nothing should have been written outside the base directory.
546        assert!(!outside.path().join("SKILL.md").exists());
547    }
548
549    #[tokio::test]
550    #[cfg(unix)]
551    async fn symlinked_file_escape_is_rejected() {
552        let base = TempDir::new().unwrap();
553        let outside = TempDir::new().unwrap();
554        let outside_file = outside.path().join("real.md");
555        tokio::fs::write(&outside_file, "outside").await.unwrap();
556
557        let server_dir = base.path().join("my-server");
558        tokio::fs::create_dir_all(&server_dir).await.unwrap();
559        std::os::unix::fs::symlink(&outside_file, server_dir.join("SKILL.md")).unwrap();
560
561        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
562            .await
563            .unwrap_err();
564        assert!(matches!(err, OutputPathError::Escape { .. }));
565    }
566
567    /// S1 regression guard, now running through the unified walk shared
568    /// with the `server_id` (S4) and intermediate-component (S2) checks.
569    #[tokio::test]
570    #[cfg(unix)]
571    async fn dangling_symlink_at_final_component_is_rejected() {
572        // Regression for the bypass where `canonicalize` fails on a symlink
573        // whose target doesn't exist, and the old code fell through to
574        // `Ok(final_path)` — a subsequent write would follow the link
575        // outside the base directory.
576        let base = TempDir::new().unwrap();
577        let outside = TempDir::new().unwrap();
578        let dangling_target = outside.path().join("does-not-exist.md");
579
580        let server_dir = base.path().join("my-server");
581        tokio::fs::create_dir_all(&server_dir).await.unwrap();
582        std::os::unix::fs::symlink(&dangling_target, server_dir.join("SKILL.md")).unwrap();
583
584        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
585            .await
586            .unwrap_err();
587        assert!(matches!(err, OutputPathError::Escape { .. }));
588        assert!(!dangling_target.exists());
589    }
590
591    #[tokio::test]
592    async fn intermediate_component_that_is_a_regular_file_is_rejected() {
593        let base = TempDir::new().unwrap();
594        let server_dir = base.path().join("my-server");
595        tokio::fs::create_dir_all(&server_dir).await.unwrap();
596        tokio::fs::write(server_dir.join("not-a-dir"), "oops")
597            .await
598            .unwrap();
599
600        let err = resolve_skill_output_path(
601            base.path(),
602            "my-server",
603            Some(Path::new("not-a-dir/SKILL.md")),
604        )
605        .await
606        .unwrap_err();
607        assert!(matches!(err, OutputPathError::NotADirectory { .. }));
608    }
609
610    #[tokio::test]
611    async fn final_path_that_is_an_existing_directory_is_rejected() {
612        let base = TempDir::new().unwrap();
613        let server_dir = base.path().join("my-server");
614        tokio::fs::create_dir_all(server_dir.join("SKILL.md"))
615            .await
616            .unwrap();
617
618        let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
619            .await
620            .unwrap_err();
621        assert!(matches!(err, OutputPathError::NotAFile { .. }));
622    }
623}