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>`
|
//! * No `+x` is granted on extracted files. The optional `/skill trust <name>`
|
||||||
//! command writes a `.trusted` marker; tool-execution gating is a separate
|
//! command writes a `.trusted` marker; tool-execution gating is a separate
|
||||||
//! concern that lives next to the tool registry.
|
//! 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::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -228,6 +232,10 @@ pub enum InstallError {
|
|||||||
MissingFrontmatterField(&'static str),
|
MissingFrontmatterField(&'static str),
|
||||||
#[error("symlinks are not allowed in skill tarballs")]
|
#[error("symlinks are not allowed in skill tarballs")]
|
||||||
SymlinkRejected,
|
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")]
|
#[error("skill '{0}' is already installed; use update or remove it first")]
|
||||||
AlreadyInstalled(String),
|
AlreadyInstalled(String),
|
||||||
#[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
|
#[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 total_size: u64 = 0;
|
||||||
let mut prefix: Option<String> = None;
|
let mut prefix: Option<String> = None;
|
||||||
let mut skill_md_relative: Option<(SkillMdCandidate, Vec<u8>)> = 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();
|
let mut link_paths: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for entry in archive
|
for entry in archive
|
||||||
@@ -1098,6 +1108,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
|||||||
if !is_safe_path(&path) {
|
if !is_safe_path(&path) {
|
||||||
return Err(InstallError::PathTraversal(path_str).into());
|
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
|
// Track total size against `max_size` (uncompressed). We honor `header
|
||||||
// .size` rather than streaming-read every file; tar archives are
|
// .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() {
|
if entry_type.is_file() {
|
||||||
let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or(""));
|
let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or(""));
|
||||||
if let Some(candidate) = skill_md_candidate(&stripped) {
|
if let Some(candidate) = skill_md_candidate(&stripped) {
|
||||||
|
skill_md_candidate_count += 1;
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
entry
|
entry
|
||||||
.read_to_end(&mut buf)
|
.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();
|
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
|
let (skill_md, skill_md_bytes) = skill_md_relative
|
||||||
.ok_or(InstallError::MissingSkillMd)
|
.ok_or(InstallError::MissingSkillMd)
|
||||||
.map_err(anyhow::Error::from)?;
|
.map_err(anyhow::Error::from)?;
|
||||||
@@ -1232,6 +1249,21 @@ fn skill_md_candidate(stripped_path: &str) -> Option<SkillMdCandidate> {
|
|||||||
None
|
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<()> {
|
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||||
let cursor = std::io::Cursor::new(bytes);
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
let gz = GzDecoder::new(cursor);
|
let gz = GzDecoder::new(cursor);
|
||||||
|
|||||||
@@ -363,6 +363,48 @@ async fn install_accepts_nested_workflow_pack_skill_directory() {
|
|||||||
shutdown(tx, handle);
|
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]
|
#[tokio::test]
|
||||||
async fn install_accepts_single_skill_subdirectory_archive() {
|
async fn install_accepts_single_skill_subdirectory_archive() {
|
||||||
let tarball = make_tarball(&[
|
let tarball = make_tarball(&[
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Claude Plugin Compatibility
|
||||||
|
|
||||||
|
CodeWhale treats Claude Code skill folders as instruction bundles when they are
|
||||||
|
plain `SKILL.md` directories. It does not run Claude Code plugin runtimes.
|
||||||
|
|
||||||
|
## Supported
|
||||||
|
|
||||||
|
- Workspace or global `.claude/skills/<name>/SKILL.md` directories discovered by
|
||||||
|
the normal skill registry.
|
||||||
|
- GitHub or tarball installs that contain one selected skill directory such as
|
||||||
|
`skills/<name>/SKILL.md`, `.agents/skills/<name>/SKILL.md`,
|
||||||
|
`.claude/skills/<name>/SKILL.md`, or a nested package layout ending in
|
||||||
|
`skills/<name>/SKILL.md`.
|
||||||
|
- Companion files inside the selected skill directory, such as `references/`,
|
||||||
|
`examples/`, or scripts that are only used after the skill is explicitly
|
||||||
|
loaded and trusted.
|
||||||
|
|
||||||
|
## Not Supported As A Plugin Runtime
|
||||||
|
|
||||||
|
Claude Code plugin features remain outside the v0.8.60 compatibility boundary:
|
||||||
|
|
||||||
|
- `.claude-plugin/plugin.json` metadata and activation semantics.
|
||||||
|
- Custom slash-command bundles.
|
||||||
|
- Plugin build steps, compiled TypeScript agents, dashboard servers, shared
|
||||||
|
plugin state, or token-gated service processes.
|
||||||
|
- Frontmatter fields that require Claude-specific runtime behavior, such as
|
||||||
|
`model: inherit`.
|
||||||
|
|
||||||
|
If a Claude Code plugin repository contains multiple skills, install or migrate
|
||||||
|
one `skills/<name>` directory at a time. `/skill install` rejects multi-skill
|
||||||
|
plugin archives with a clear message so it never silently chooses one skill and
|
||||||
|
drops the plugin runtime behavior.
|
||||||
|
|
||||||
|
For richer integrations, wrap the plugin's executable surface as MCP, hooks, or
|
||||||
|
a CodeWhale skill that names the external command explicitly.
|
||||||
@@ -1006,7 +1006,9 @@ If you are upgrading from older releases:
|
|||||||
agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem
|
agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem
|
||||||
`~/.claude/skills`. First launch installs versioned bundled skills for common
|
`~/.claude/skills`. First launch installs versioned bundled skills for common
|
||||||
workflows including skill creation, delegation, MCP/plugin scaffolding,
|
workflows including skill creation, delegation, MCP/plugin scaffolding,
|
||||||
documents, presentations, spreadsheets, PDFs, and Feishu/Lark.
|
documents, presentations, spreadsheets, PDFs, and Feishu/Lark. See
|
||||||
|
[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary
|
||||||
|
between portable `SKILL.md` bundles and Claude Code plugin runtimes.
|
||||||
- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with
|
- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with
|
||||||
legacy `~/.deepseek/mcp.json` fallback when the CodeWhale path is absent.
|
legacy `~/.deepseek/mcp.json` fallback when the CodeWhale path is absent.
|
||||||
It is visible in `/config` and can be changed from the TUI. The new path is
|
It is visible in `/config` and can be changed from the TUI. The new path is
|
||||||
|
|||||||
+3
-2
@@ -404,8 +404,9 @@ work. Read the local guidance before editing, and keep any contribution within
|
|||||||
the repository's conventions.
|
the repository's conventions.
|
||||||
|
|
||||||
Next: see [SKILL_INVOCATION_DESIGN.md](SKILL_INVOCATION_DESIGN.md) for skill
|
Next: see [SKILL_INVOCATION_DESIGN.md](SKILL_INVOCATION_DESIGN.md) for skill
|
||||||
activation behavior and [CONFIGURATION.md](CONFIGURATION.md) for config paths
|
activation behavior, [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for
|
||||||
and project authority.
|
Claude Code skill/plugin compatibility, and [CONFIGURATION.md](CONFIGURATION.md)
|
||||||
|
for config paths and project authority.
|
||||||
|
|
||||||
## 10. Getting Help
|
## 10. Getting Help
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user