diff --git a/crates/compass-cli/src/install_commands.rs b/crates/compass-cli/src/install_commands.rs index 9fa932f4..e1bb1749 100644 --- a/crates/compass-cli/src/install_commands.rs +++ b/crates/compass-cli/src/install_commands.rs @@ -3853,6 +3853,16 @@ fn validate_skill_destination(destination: &Path, default_root: &Path) -> Result destination.display() )); }; + let canonical_boundary = match fs::canonicalize(&boundary) { + Ok(path) => Some(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "error: could not resolve skill destination boundary {}: {error}", + boundary.display() + )); + } + }; let mut current = destination.parent(); while let Some(path) = current { @@ -3861,11 +3871,23 @@ fn validate_skill_destination(destination: &Path, default_root: &Path) -> Result } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "error: skill destination {} traverses symbolic link {}", - destination.display(), - path.display() - )); + let resolved = fs::canonicalize(path).map_err(|error| { + format!( + "error: could not resolve symbolic link {} while validating skill destination {}: {error}", + path.display(), + destination.display() + ) + })?; + if canonical_boundary + .as_ref() + .is_some_and(|boundary| !resolved.starts_with(boundary)) + { + return Err(format!( + "error: skill destination {} resolves outside the selected scope through symbolic link {}", + destination.display(), + path.display() + )); + } } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} diff --git a/crates/compass-cli/tests/install_cli.rs b/crates/compass-cli/tests/install_cli.rs index 78501c9c..673b6762 100644 --- a/crates/compass-cli/tests/install_cli.rs +++ b/crates/compass-cli/tests/install_cli.rs @@ -156,6 +156,44 @@ fn project_codex_install_creates_native_compass_skill() -> Result<(), Box Result<(), Box> { + let fixture = InstallFixture::new()?; + let linked_root = fixture.project.join(".agents/skills"); + fs::create_dir_all(&linked_root)?; + fs::create_dir_all(fixture.project.join(".claude"))?; + std::os::unix::fs::symlink("../.agents/skills", fixture.project.join(".claude/skills"))?; + + let output = fixture.run(&["install", "claude"])?; + assert_success("Claude install through in-scope skills symlink", &output); + assert!(linked_root.join("compass/SKILL.md").is_file()); + assert!(fixture.project.join(".claude/skills").is_symlink()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn project_claude_install_rejects_an_out_of_scope_skills_symlink() -> Result<(), Box> { + let fixture = InstallFixture::new()?; + let outside_root = fixture + .project + .parent() + .ok_or("fixture parent")? + .join("external-claude-skills"); + fs::create_dir_all(&outside_root)?; + fs::create_dir_all(fixture.project.join(".claude"))?; + fs::write(outside_root.join("sentinel"), "keep")?; + std::os::unix::fs::symlink(&outside_root, fixture.project.join(".claude/skills"))?; + + let output = fixture.run(&["install", "claude"])?; + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("outside the selected scope")); + assert!(outside_root.join("sentinel").is_file()); + assert!(!outside_root.join("compass").exists()); + Ok(()) +} + #[test] fn every_project_platform_installs_native_content() -> Result<(), Box> { for platform in PROJECT_PLATFORMS {