Skip to content

Commit 827fff5

Browse files
author
Fernando Pinho
committed
security: reject path traversal and symlinks in copy + .skills.toml
Fixes four path-safety issues that, in combination, allowed a malicious skills library or a crafted .skills.toml (e.g. mergeable via PR) to exfiltrate arbitrary files through the round-trip (read on `add`, leak on `push`) and to delete arbitrary directories outside the project or library root on `pull` / `push` / `detect`. Reported privately on 2026-05-19 by firebaguette via the Umanio Discord; all four issues are addressed below. 1. Symlink follow in fs_util::copy_dir_all. A symlink inside a skill folder bypassed `entry.file_type().is_dir()`, fell into the file branch, and was dereferenced by `fs::copy`. A subsequent `push` would have published the symlink target's content to the (possibly public) library. Symlinks are now hard-rejected by `copy_dir_all` at both the top-level source and any descendant entry, and `replace_folder_contents` refuses a symlinked destination so `remove_dir_all` cannot be tricked. 2. Path traversal via `destination` and `source_path` in .skills.toml. Both fields deserialized as PathBuf with zero validation. Because Path::join lets an absolute right-hand side replace the base, a `.skills.toml` entry like `destination = "/home/user/.ssh"` made `cwd.join(...)` resolve outside the project and the downstream `remove_dir_all` wipe arbitrary directories. `..` traversal was equally unguarded. New `InstalledSkill::validate` runs at load time and rejects absolute paths, parent traversal, and Windows-prefix components for both fields. The same check is wired (defense-in-depth) at every destructive call site via the new `path_safety::safe_join` helper. 3. `detect --target` accepted `..` even though it rejected absolute. Validation in `commands::detect::resolve_target` and the interactive custom-path prompt now go through `validate_relative_subpath`, rejecting any non-Normal/CurDir component. 4. Fork-name validation accepted `.` and `..` literally. The `validate_fork_name` helpers in push.rs and pull.rs only rejected `/` and `\`, so a fork named `..` would have produced a Path::join resolving to the parent directory. `.` and `..` are now explicit rejections. Threat-model note: the fix is purely lexical (component-level) plus an explicit symlink check at copy time. No filesystem canonicalize calls were added, avoiding TOCTOU windows and keeping the validation pure-functional (AppError::Config -> exit code 2). 34 new unit tests cover each rejection class and each attack scenario.
1 parent 04ee4eb commit 827fff5

10 files changed

Lines changed: 714 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.1.2] - 2026-05-20
10+
11+
### Security
12+
13+
Fix four path-safety vulnerabilities that, in combination, allowed a malicious skills library or a crafted `.skills.toml` (e.g. mergeable via PR) to **exfiltrate** arbitrary files through the round-trip (read on `skillctl add`, leak on `skillctl push`) and to **delete arbitrary directories** outside the project or library root on `skillctl pull` / `push` / `detect`. Reported privately on 2026-05-19 by **firebaguette** via the Umanio Discord; all four issues are addressed in this release.
14+
15+
- **Symlink follow in `fs_util::copy_dir_all`.** A symlink inside a skill folder (e.g. `niania → /home/user/.aws/credentials`) bypassed `entry.file_type().is_dir()`, fell into the file branch, and was dereferenced by `fs::copy` — copying the symlink target into the project. A subsequent `skillctl push` would have published the secret to the (possibly public) library. Symlinks are now hard-rejected by `copy_dir_all` at both the top-level source and any descendant entry, and `replace_folder_contents` refuses a symlinked destination so `remove_dir_all` cannot be tricked.
16+
- **Path traversal via `destination` and `source_path` in `.skills.toml`.** Both fields were deserialized as `PathBuf` with zero validation. Because `Path::join` lets an absolute right-hand side replace the base, a `.skills.toml` entry like `destination = "/home/seb/.ssh"` made `cwd.join(...)` resolve outside the project and `replace_folder_contents``remove_dir_all` wipe arbitrary directories. `..` traversal was equally unguarded. New `InstalledSkill::validate` runs at `project_config::load` time and rejects absolute paths, `..`, and Windows-prefix components for both fields; the same check is wired (defense-in-depth) at every destructive call site in `push.rs` / `pull.rs` via the new `path_safety::safe_join` helper.
17+
- **`detect --target` accepted `..` even though it rejected absolute paths.** Validation in `commands::detect::resolve_target` now goes through the same `validate_relative_subpath` helper, rejecting any non-`Normal`/`CurDir` component. The interactive "custom path" prompt was tightened to match.
18+
- **Fork-name validation accepted `.` and `..` literally.** `validate_fork_name` in both `push.rs` and `pull.rs` only rejected `/` and `\`, so a fork named `..` would have produced a `Path::join` resolving to the parent directory, then `fs::rename` could have clobbered it. `.` and `..` are now explicit rejections.
19+
20+
Threat-model note: the fix is purely lexical (component-level) plus an explicit symlink check at copy time. No filesystem `canonicalize` calls were added, avoiding TOCTOU windows and keeping the validation pure-functional (`AppError::Config`, exit code 2). 34 new unit tests cover each rejection class and each attack scenario end-to-end.
21+
922
### Changed
1023

1124
- README and crate description reframed around "agent skills" terminology to reflect the multi-tool nature of the `SKILL.md` convention (Claude Code, Codex, Cursor, OpenCode, and others in the [open agent skills ecosystem](https://skills.sh)) — no behavior change.

Cargo.lock

Lines changed: 233 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "skillctl"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "CLI to manage your personal agent skills library across projects"
@@ -35,6 +35,9 @@ serde_json = "1.0.149"
3535
time = { version = "0.3.47", features = ["formatting"] }
3636
toml = "1.1.2"
3737

38+
[dev-dependencies]
39+
tempfile = "3.14.0"
40+
3841
# The profile that 'dist' will build with
3942
[profile.dist]
4043
inherits = "release"

0 commit comments

Comments
 (0)