fix(plugins): keep catalog sources inside the catalog and names unambiguous

F01-03 (Refs #6561), F01-04 catalog half (Refs #6556).

F01-03: `resolve_spec` joined a relative local source to the catalog
directory without checking `..`, and passed an absolute local source
through unchanged, so catalog metadata could point the installer at any
directory on the machine (Codex `{"source":"local","path":"/etc"}`,
Claude `./../x`, Kimi raw paths). A catalog local source must now be
relative with only normal/`.` components; otherwise the candidate resolves
to Unsupported with the reason. Installing an outside directory stays an
explicit `/plugin install path:...`.

F01-04 (catalog): the Codewhale-native parser stored entry names verbatim
(control characters, unbounded length) while the other formats normalize
to plugin names, and no format detected duplicate names, although install,
`/plugin show`, and registry lookup all resolve by name (first match wins).
Native names must satisfy the plugin-name rules or the entry is dropped
with INVALID_NAME; after every format's parse, entries sharing a name each
get a DUPLICATE_NAME error, so none of them is installable.

Validation: new tests `catalog_local_sources_cannot_leave_the_catalog_directory`
and `catalog_names_are_validated_and_duplicates_fail_closed`; all 33
plugins::marketplace tests pass (incl. the bundled first-party catalog:
0 errors). Focused run: `403 passed; 1 failed` (failure unrelated: T07-01
fixture).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 01:09:06 -07:00
1 parent 88cdc1ca5c
commit d940a3e894
4 files changed
+153 -16

No files matched your search

+30 -15
View File
@@ -8,7 +8,7 @@
//! command and the HTTP API can never disagree about what would be fetched.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use super::parsers::{self, MarketplaceDocument};
use super::store::StoredMarketplaceCatalog;
@@ -151,17 +151,18 @@ pub fn resolve_candidate_install<'a>(
};
}
match &candidate.install_plan {
MarketplaceInstallPlan::Supported { spec, source_kind } => {
CatalogInstallResolution::Supported {
spec: resolve_spec(
&entry.source_path,
entry.catalog.format,
&candidate.source,
spec,
),
MarketplaceInstallPlan::Supported { spec, source_kind } => match resolve_spec(
&entry.source_path,
entry.catalog.format,
&candidate.source,
spec,
) {
Ok(spec) => CatalogInstallResolution::Supported {
spec,
source_kind: source_kind.clone(),
}
}
},
Err(reason) => CatalogInstallResolution::Unsupported { reason },
},
MarketplaceInstallPlan::Unsupported { reason, .. } => {
CatalogInstallResolution::Unsupported {
reason: reason.clone(),
@@ -170,14 +171,28 @@ pub fn resolve_candidate_install<'a>(
}
}
/// A catalog's local source must name a directory inside the catalog's own
/// tree. An absolute path, a root or drive prefix, or any `..` component would
/// let catalog metadata point the installer at an arbitrary directory on this
/// machine, so such an entry is not installable from the catalog; installing a
/// directory outside it stays an explicit `/plugin install path:...`.
fn resolve_spec(
source_path: &str,
format: MarketplaceFormat,
source: &MarketplaceSourceSpec,
spec: &str,
) -> String {
) -> Result<String, String> {
if let MarketplaceSourceSpec::LocalPath { path } = source
&& !path
.components()
.all(|part| matches!(part, Component::Normal(_) | Component::CurDir))
{
return Err(format!(
"Local catalog source `{}` leaves the catalog directory; only paths inside it (no absolute paths or `..`) are installable from a catalog",
path.display()
));
}
if let MarketplaceSourceSpec::LocalPath { path } = source
&& path.is_relative()
&& let Some(dir) = Path::new(source_path).parent()
{
// Claude keeps its catalog in a manifest-only metadata directory;
@@ -189,9 +204,9 @@ fn resolve_spec(
} else {
dir
};
return format!("path:{}", dir.join(path).display());
return Ok(format!("path:{}", dir.join(path).display()));
}
spec.to_string()
Ok(spec.to_string())
}
/// Resolve a user-supplied document path to an existing regular file without
@@ -171,6 +171,23 @@ fn parse_codewhale_entry(
));
return None;
};
// The name is the candidate's identity (registry lookup, `/plugin show`,
// install), so it is held to the plugin-name rules the other formats
// normalize to, rather than stored verbatim with controls or unbounded.
if crate::plugins::manifest::validate_plugin_name(name).is_err()
&& !crate::plugins::agent_plugin::is_standard_plugin_name(name)
{
diagnostics.push(MarketplaceDiagnostic::error(
"INVALID_NAME",
format!(
"Codewhale plugin at index {index} has an invalid name {:?}: use lowercase letters, digits, and internal hyphens or dots",
name.chars().take(80).collect::<String>()
),
None,
Some(index),
));
return None;
}
let name = name.to_string();
let (source, bad_source) = str_field(obj, "source");
@@ -77,12 +77,39 @@ pub fn parse_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
explicit => explicit,
};
match format {
let mut catalog = match format {
MarketplaceFormat::Kimi => kimi::parse_kimi_catalog(document),
MarketplaceFormat::Claude => claude::parse_claude_catalog(document),
MarketplaceFormat::Codex => codex::parse_codex_catalog(document),
MarketplaceFormat::Codewhale => codewhale::parse_codewhale_catalog(document),
MarketplaceFormat::Auto => unreachable!("resolved above"),
};
flag_duplicate_names(&mut catalog);
catalog
}
/// A candidate's name is its identity: `/plugin show`, install, and registry
/// lookups all resolve by it, and a lookup returns the first match. Entries
/// sharing a name (after each format's normalization) are ambiguous, so every
/// one of them carries an error and none installs from the catalog.
fn flag_duplicate_names(catalog: &mut super::types::MarketplaceCatalog) {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for candidate in &catalog.candidates {
*counts.entry(candidate.name.clone()).or_default() += 1;
}
for candidate in &mut catalog.candidates {
let count = counts.get(&candidate.name).copied().unwrap_or(0);
if count > 1 {
candidate.diagnostics.push(MarketplaceDiagnostic::error(
"DUPLICATE_NAME",
format!(
"{count} catalog entries are named `{}`; the entry's identity is ambiguous",
candidate.name
),
Some(candidate.name.clone()),
None,
));
}
}
}
@@ -607,3 +607,81 @@ fn claude_relative_install_source_is_rooted_outside_catalog_metadata() {
tmp.path().join("external_plugins/linear")
);
}
#[test]
fn catalog_names_are_validated_and_duplicates_fail_closed() {
let body = r#"{
"name": "team",
"plugins": [
{"name": "fmt", "source": "github:owner/fmt"},
{"name": "fmt", "source": "github:someone-else/fmt"},
{"name": "bad\u001b[31mname", "source": "github:owner/x"},
{"name": "ok", "source": "github:owner/ok"}
]
}"#;
let catalog = parse_auto("native", body);
assert_eq!(catalog.format, MarketplaceFormat::Codewhale);
assert_eq!(
catalog.total_candidates(),
3,
"a control-bearing name never becomes a candidate"
);
assert!(catalog.diagnostics.iter().any(|d| d.code == "INVALID_NAME"));
let duplicates: Vec<_> = catalog
.candidates
.iter()
.filter(|candidate| candidate.name == "fmt")
.collect();
assert_eq!(duplicates.len(), 2);
for candidate in duplicates {
assert!(
candidate
.diagnostics
.iter()
.any(|d| d.code == "DUPLICATE_NAME"),
"an ambiguous identity must not be installable"
);
assert!(candidate.has_errors());
}
assert!(!catalog.candidate_by_name("ok").unwrap().has_errors());
}
#[test]
fn catalog_local_sources_cannot_leave_the_catalog_directory() {
use super::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("marketplace.json");
std::fs::write(
&path,
r#"{"name":"n","plugins":[
{"name":"absolute","source":{"source":"local","path":"/etc"}},
{"name":"parent","source":{"source":"local","path":"./plugins/../../outside"}},
{"name":"inside","source":{"source":"local","path":"./plugins/inside"}}
]}"#,
)
.unwrap();
let loaded = load_catalog_document("n", tmp.path(), path.to_str().unwrap()).unwrap();
assert_eq!(loaded.entry.catalog.format, MarketplaceFormat::Codex);
let registry = crate::plugins::PluginRegistry::empty(tmp.path());
for name in ["absolute", "parent"] {
let candidate = loaded.entry.catalog.candidate_by_name(name).unwrap();
match resolve_candidate_install(&loaded.entry, candidate, &registry) {
CatalogInstallResolution::Unsupported { reason } => {
assert!(reason.contains("leaves the catalog directory"), "{reason}");
}
_ => panic!("`{name}` must not resolve outside the catalog"),
}
}
let inside = loaded.entry.catalog.candidate_by_name("inside").unwrap();
let CatalogInstallResolution::Supported { spec, .. } =
resolve_candidate_install(&loaded.entry, inside, &registry)
else {
panic!("a path inside the catalog stays installable")
};
assert_eq!(
std::path::Path::new(spec.strip_prefix("path:").unwrap()),
tmp.path().join("./plugins/inside")
);
}