Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ desktop.ini
/docs/.doctrees
*.pdf
*.html
!src/http/static/index.html
!crates/fastskill-core/src/http/static/index.html
*.epub
*.mobi
*.azw3
Expand Down
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ A skill physically present in the **skills directory** (`.claude/skills/` by def
**Reconciliation**:
The comparison of the three states — Manifest (desired), Lock (pinned), skills directory (actual) — producing a status per skill: `ok`, `missing`, `extraneous`, `mismatch`. Owned by `list`.

**Version constraint**:
The *allowed range* a Manifest dependency accepts, used only to filter candidate versions during resolution — distinct from the Lock, which pins the one chosen version. A **bare version (`1.2.3`) means exactly that version**, not a compatible range; ranges are opt-in via explicit `^`/`~`/`>=`/`<=`/comma operators. See [ADR-0004](./docs/adr/0004-bare-version-is-exact.md).
_Avoid_: version requirement, semver range (a bare version is *not* a range here).

### Discovery axes

These two axes — not the verb names — are what actually distinguish the read-side commands. The verbs should expose them, not hide them.
Expand Down
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
toml = "0.8"
# Format-preserving TOML editing (version bump must keep id/[tool]/comments)
toml_edit = "0.22"

# Error handling
anyhow = "1.0"
Expand Down
2 changes: 2 additions & 0 deletions crates/fastskill-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ tempfile.workspace = true
insta = "1.47"
assert_cmd = "2.2"
predicates = "3.0"
wiremock = "0.5"
zip.workspace = true

[lints]
workspace = true
63 changes: 63 additions & 0 deletions crates/fastskill-cli/src/commands/add/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ pub async fn copy_dir_recursive(src: &Path, dst: &Path) -> CliResult<()> {
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());

// `read_dir`'s file_type does not follow symlinks, so a symlink entry is
// neither a dir nor a regular file. Reject it (SEC-4) rather than letting
// it fall into `tokio::fs::copy`, which *follows* the link and copies the
// target's contents (e.g. `creds -> /etc/passwd` would exfiltrate secrets).
// This matches the zip extractor's symlink-rejection stance.
if ty.is_symlink() {
return Err(CliError::Validation(format!(
"refusing to copy symlink: {}",
src_path.display()
)));
}

if ty.is_dir() {
// Recursively copy subdirectory (boxed to handle async recursion)
Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?;
Expand Down Expand Up @@ -302,6 +314,57 @@ mod tests {
dir
}

#[cfg(unix)]
#[tokio::test]
async fn test_copy_dir_recursive_rejects_symlink() {
use std::os::unix::fs::symlink;

let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("SKILL.md"), "# skill\n").unwrap();

// A secret file outside the skill, and a symlink pointing at it.
let secret = tmp.path().join("secret.txt");
fs::write(&secret, "TOP SECRET").unwrap();
symlink(&secret, src.join("creds")).unwrap();

let dst = tmp.path().join("dst");
let result = copy_dir_recursive(&src, &dst).await;

match result {
Err(CliError::Validation(msg)) => {
assert!(
msg.contains("symlink"),
"expected symlink rejection, got: {msg}"
);
}
other => unreachable!("expected Validation error, got {other:?}"),
}

// The dereferenced secret must NOT have been copied into the destination.
assert!(
!dst.join("creds").exists(),
"symlinked file must not be copied (no content exfiltration)"
);
}

#[cfg(unix)]
#[tokio::test]
async fn test_copy_dir_recursive_copies_regular_tree() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("SKILL.md"), "# skill\n").unwrap();
fs::write(src.join("nested/file.txt"), "data").unwrap();

let dst = tmp.path().join("dst");
copy_dir_recursive(&src, &dst).await.unwrap();

assert!(dst.join("SKILL.md").exists());
assert!(dst.join("nested/file.txt").exists());
}

#[tokio::test]
async fn test_install_skill_editable_creates_symlink() {
let tmp = TempDir::new().unwrap();
Expand Down
Loading
Loading