fix(skills): reject multi-skill Claude plugin archives
Document the portable SKILL.md compatibility boundary for Claude Code plugin bundles and keep /skill install from silently flattening plugin archives that carry multiple skills plus plugin.json runtime metadata. Reported by @AiurArtanis in #2743. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,10 @@
|
||||
//! * No `+x` is granted on extracted files. The optional `/skill trust <name>`
|
||||
//! command writes a `.trusted` marker; tool-execution gating is a separate
|
||||
//! concern that lives next to the tool registry.
|
||||
//! * Claude Code plugin archives that contain multiple skills are rejected with
|
||||
//! an explicit migration message. CodeWhale can install individual
|
||||
//! `SKILL.md` bundles, including `.claude/skills/<name>/SKILL.md`, but it
|
||||
//! does not execute `plugin.json` plugin runtimes or custom command bundles.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
@@ -228,6 +232,10 @@ pub enum InstallError {
|
||||
MissingFrontmatterField(&'static str),
|
||||
#[error("symlinks are not allowed in skill tarballs")]
|
||||
SymlinkRejected,
|
||||
#[error(
|
||||
"Claude Code plugin archive contains multiple SKILL.md entries; CodeWhale installs one SKILL.md bundle at a time and does not run plugin.json/custom-command runtimes. Install or migrate an individual skills/<name> directory instead"
|
||||
)]
|
||||
ClaudePluginBundle,
|
||||
#[error("skill '{0}' is already installed; use update or remove it first")]
|
||||
AlreadyInstalled(String),
|
||||
#[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
|
||||
@@ -1081,6 +1089,8 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
let mut total_size: u64 = 0;
|
||||
let mut prefix: Option<String> = None;
|
||||
let mut skill_md_relative: Option<(SkillMdCandidate, Vec<u8>)> = None;
|
||||
let mut skill_md_candidate_count: usize = 0;
|
||||
let mut has_claude_plugin_manifest = false;
|
||||
let mut link_paths: Vec<String> = Vec::new();
|
||||
|
||||
for entry in archive
|
||||
@@ -1098,6 +1108,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
if !is_safe_path(&path) {
|
||||
return Err(InstallError::PathTraversal(path_str).into());
|
||||
}
|
||||
if is_claude_plugin_manifest_path(&path) {
|
||||
has_claude_plugin_manifest = true;
|
||||
}
|
||||
|
||||
// Track total size against `max_size` (uncompressed). We honor `header
|
||||
// .size` rather than streaming-read every file; tar archives are
|
||||
@@ -1144,6 +1157,7 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
if entry_type.is_file() {
|
||||
let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or(""));
|
||||
if let Some(candidate) = skill_md_candidate(&stripped) {
|
||||
skill_md_candidate_count += 1;
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
@@ -1162,6 +1176,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
}
|
||||
|
||||
let prefix = prefix.unwrap_or_default();
|
||||
if has_claude_plugin_manifest && skill_md_candidate_count > 1 {
|
||||
return Err(InstallError::ClaudePluginBundle.into());
|
||||
}
|
||||
let (skill_md, skill_md_bytes) = skill_md_relative
|
||||
.ok_or(InstallError::MissingSkillMd)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
@@ -1232,6 +1249,21 @@ fn skill_md_candidate(stripped_path: &str) -> Option<SkillMdCandidate> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_claude_plugin_manifest_path(path: &Path) -> bool {
|
||||
let parts: Vec<String> = path
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
Component::Normal(part) => Some(part.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
parts.windows(2).any(|window| {
|
||||
window[0].eq_ignore_ascii_case(".claude-plugin")
|
||||
&& window[1].eq_ignore_ascii_case("plugin.json")
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
|
||||
@@ -363,6 +363,48 @@ async fn install_accepts_nested_workflow_pack_skill_directory() {
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_multi_skill_claude_plugin_archive() {
|
||||
let tarball = make_tarball(&[
|
||||
(
|
||||
"repo-main/.claude-plugin/plugin.json",
|
||||
br#"{"name":"workflow-pack","version":"1.0.0"}"#,
|
||||
),
|
||||
(
|
||||
"repo-main/skills/plan/SKILL.md",
|
||||
&skill_md("plan", "Planning skill"),
|
||||
),
|
||||
(
|
||||
"repo-main/skills/review/SKILL.md",
|
||||
&skill_md("review", "Review skill"),
|
||||
),
|
||||
]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("multi-skill Claude plugin archive should not be flattened");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("Claude Code plugin archive contains multiple SKILL.md entries"),
|
||||
"expected Claude plugin compatibility error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||
"rejected plugin archive must not write an installed skill"
|
||||
);
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_accepts_single_skill_subdirectory_archive() {
|
||||
let tarball = make_tarball(&[
|
||||
|
||||
Reference in New Issue
Block a user