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(¤t),
279 });
280 }
281 match tokio::fs::symlink_metadata(¤t).await {
282 Ok(_) => {
283 let resolved = tokio::fs::canonicalize(¤t).await?;
284 if !resolved.starts_with(&server_dir) {
285 return Err(OutputPathError::Escape {
286 path: sanitize_path_for_error(¤t),
287 });
288 }
289 if !tokio::fs::metadata(&resolved).await?.is_dir() {
290 return Err(OutputPathError::NotADirectory {
291 path: sanitize_path_for_error(¤t),
292 });
293 }
294 current = resolved;
295 }
296 Err(_) => {
297 tokio::fs::create_dir(¤t).await.map_err(|source| {
298 OutputPathError::CreateDir {
299 path: sanitize_path_for_error(¤t),
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}