From 7b259150f6fa45d2aad6ee88324c2f59c58cb5ad Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Fri, 21 Aug 2026 23:48:46 +0800 Subject: [PATCH 1/2] ci: validate referenced component paths in plugin manifests validate-plugins.mjs only checks plugin.json against the JSON schema and that the marketplace name matches. A typo'd component path (skills/agents/ commands/rules/hooks/mcpServers/logo) passes schema validation and CI but silently fails to load in Cursor. Add an existence check for every path declared in plugin.json, resolved relative to the plugin directory, with inline hooks/mcpServers objects and absolute-URL logos skipped. Also add scripts/** to the validate-plugins workflow paths filter so a PR that only changes the validator itself triggers the job that runs it. --- .github/workflows/validate-plugins.yml | 1 + scripts/validate-plugins.mjs | 70 +++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 03e8e192..f9932072 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -6,6 +6,7 @@ on: - ".cursor-plugin/marketplace.json" - "**/plugin.json" - "schemas/**" + - "scripts/**" jobs: validate: diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index 6a787085..5906c427 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { readFileSync, existsSync } from "fs"; -import { resolve, dirname } from "path"; +import { existsSync, readFileSync } from "fs"; +import { dirname, relative, resolve } from "path"; import { fileURLToPath } from "url"; import Ajv from "ajv"; import addFormats from "ajv-formats"; @@ -31,6 +31,67 @@ function fail(message) { errors++; } +// Fields whose declared value is a path (or glob) relative to the plugin +// directory. `hooks`/`mcpServers` may also be inline objects and `logo` may +// be an absolute URL per the schema — those are skipped by the caller. +const COMPONENT_PATH_FIELDS = ["skills", "agents", "commands", "rules"]; +const COMPONENT_SINGLE_PATH_FIELDS = ["hooks", "mcpServers", "logo"]; + +function isAbsoluteUrl(value) { + return /^(?:https?:|data:)/i.test(value); +} + +// Verify that a path declared in plugin.json resolves to an existing file or +// directory under the plugin directory. Schema validation only checks the +// shape of plugin.json, so a typo'd component path (e.g. "skils/" or a +// renamed hook file) currently passes CI and silently fails to load in +// Cursor. Glob patterns are checked against their static directory prefix so +// a typo in the base directory is still caught. +function checkReferencedPath(pluginName, pluginDir, field, declared) { + if (typeof declared !== "string" || declared.trim().length === 0) return; + if (isAbsoluteUrl(declared)) return; + + const normalized = declared.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!normalized || normalized.startsWith("/")) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must be relative to the plugin directory` + ); + return; + } + if (normalized.split(/[\\/]/).includes("..")) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must not escape the plugin directory` + ); + return; + } + + const globIndex = normalized.search(/[*?[\]]/); + const staticPart = + globIndex >= 0 + ? normalized.slice(0, globIndex).replace(/\/+$/, "") + : normalized; + const fullPath = resolve(pluginDir, staticPart); + if (!existsSync(fullPath)) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" does not exist (resolved to ${relative(root, fullPath)})` + ); + } +} + +// Check every component path declared by a plugin.json. Strings that may also +// be inline objects (hooks/mcpServers) are skipped, not treated as paths. +function checkPluginComponentPaths(pluginName, pluginDir, pluginJson) { + for (const field of [...COMPONENT_PATH_FIELDS, ...COMPONENT_SINGLE_PATH_FIELDS]) { + const value = pluginJson[field]; + if (value === undefined) continue; + const patterns = Array.isArray(value) ? value : [value]; + for (const pattern of patterns) { + if (typeof pattern !== "string") continue; + checkReferencedPath(pluginName, pluginDir, field, pattern); + } + } +} + // 1. Validate marketplace.json const marketplacePath = resolve(root, ".cursor-plugin/marketplace.json"); @@ -90,6 +151,11 @@ for (const entry of marketplace.plugins ?? []) { `Plugin "${entry.name}": marketplace name does not match plugin.json name "${pluginJson.name}"` ); } + + // Check that component paths declared in plugin.json exist under the plugin + // directory. A typo'd skill/hook path passes schema validation but silently + // fails to load in Cursor, so catch it at review time. + checkPluginComponentPaths(entry.name, pluginDir, pluginJson); } // 3. Report results From 0ac5375de7094189953a995c808ba0c23acf38ad Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Sat, 22 Aug 2026 14:55:28 +0800 Subject: [PATCH 2/2] ci: reject empty paths and remote URLs on non-logo fields Addresses Bugbot review on #244: - Absolute URLs are only valid for `logo` per the schema; a URL in any other component field (skills/agents/commands/rules/hooks/mcpServers) was silently skipping the existence check and passing CI. - An explicitly empty/whitespace component path now fails instead of being treated as 'not a path'. --- scripts/validate-plugins.mjs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index 5906c427..06c0b180 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -48,8 +48,23 @@ function isAbsoluteUrl(value) { // Cursor. Glob patterns are checked against their static directory prefix so // a typo in the base directory is still caught. function checkReferencedPath(pluginName, pluginDir, field, declared) { - if (typeof declared !== "string" || declared.trim().length === 0) return; - if (isAbsoluteUrl(declared)) return; + if (typeof declared !== "string") return; + + if (declared.trim().length === 0) { + fail(`Plugin "${pluginName}": ${field} path must not be empty`); + return; + } + + // The schema allows `logo` to be an absolute URL; every other component + // field is a local path, so a remote-looking value there is a mistake the + // existence check would otherwise silently skip. + if (isAbsoluteUrl(declared)) { + if (field === "logo") return; + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must be a local path relative to the plugin directory` + ); + return; + } const normalized = declared.replace(/^\.\//, "").replace(/\/+$/, ""); if (!normalized || normalized.startsWith("/")) {