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:
Hunter B
2026-06-13 08:20:36 -07:00
parent 38ce04790a
commit 877b44935e
5 changed files with 115 additions and 3 deletions
+32
View File
@@ -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);
+42
View File
@@ -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(&[
+35
View File
@@ -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.
+3 -1
View File
@@ -1006,7 +1006,9 @@ If you are upgrading from older releases:
agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem
`~/.claude/skills`. First launch installs versioned bundled skills for common
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
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
+3 -2
View File
@@ -404,8 +404,9 @@ work. Read the local guidance before editing, and keep any contribution within
the repository's conventions.
Next: see [SKILL_INVOCATION_DESIGN.md](SKILL_INVOCATION_DESIGN.md) for skill
activation behavior and [CONFIGURATION.md](CONFIGURATION.md) for config paths
and project authority.
activation behavior, [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for
Claude Code skill/plugin compatibility, and [CONFIGURATION.md](CONFIGURATION.md)
for config paths and project authority.
## 10. Getting Help