diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 26a5bf8..7c0e538 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "ask-questions-if-underspecified", "version": "1.0.1", - "description": "Clarify requirements before implementing. When doubting, ask questions.", + "description": "Clarify ambiguous requirements by asking questions before implementing. Only when invoked explicitly.", "author": { "name": "Kevin Valerio", "email": "opensource@trailofbits.com", @@ -41,12 +41,21 @@ { "name": "burpsuite-project-parser", "version": "1.0.0", - "description": "Search and extract data from Burp Suite project files (.burp) for use in Claude", + "description": "Search and extract data from Burp Suite project files (.burp) for security analysis", "author": { "name": "Will Vandevanter" }, "source": "./plugins/burpsuite-project-parser" }, + { + "name": "claude-in-chrome-troubleshooting", + "version": "1.0.0", + "description": "Diagnose and fix Claude in Chrome MCP extension connectivity issues", + "author": { + "name": "Dan Guido" + }, + "source": "./plugins/claude-in-chrome-troubleshooting" + }, { "name": "constant-time-analysis", "version": "0.1.0", @@ -226,7 +235,7 @@ { "name": "insecure-defaults", "version": "1.0.0", - "description": "Detects and verifies insecure default configurations including hardcoded credentials, fallback secrets, weak authentication defaults, and dangerous configuration values that remain active in production", + "description": "Detects insecure default configurations including hardcoded credentials, fallback secrets, weak authentication defaults, and dangerous values in production", "author": { "name": "Trail of Bits", "email": "opensource@trailofbits.com", @@ -236,7 +245,7 @@ }, { "name": "second-opinion", - "version": "1.0.0", + "version": "1.2.0", "description": "Runs code reviews using external LLM CLIs (OpenAI Codex, Google Gemini) on uncommitted changes, branch diffs, or specific commits", "author": { "name": "Dan Guido" diff --git a/.github/scripts/validate_plugin_metadata.py b/.github/scripts/validate_plugin_metadata.py new file mode 100644 index 0000000..9de6fb9 --- /dev/null +++ b/.github/scripts/validate_plugin_metadata.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Validate plugin metadata consistency across all configuration files. + +Checks that plugins have: +1. A valid .claude-plugin/plugin.json +2. An entry in .claude-plugin/marketplace.json +3. An entry in README.md +4. An entry in CODEOWNERS +""" + +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class ValidationError: + """A single validation error.""" + + plugin: str + message: str + + def __str__(self) -> str: + return f"{self.plugin}: {self.message}" + + +def scan_plugins_directory(plugins_dir: Path) -> set[str]: + """Scan plugins/ directory and return all plugin directory names.""" + if not plugins_dir.is_dir(): + return set() + + return {p.name for p in plugins_dir.iterdir() if p.is_dir() and not p.name.startswith(".")} + + +def parse_marketplace(marketplace_path: Path) -> dict[str, dict]: + """Parse marketplace.json and return plugin_name -> plugin_data mapping.""" + if not marketplace_path.exists(): + return {} + + data = json.loads(marketplace_path.read_text()) + return {p["name"]: p for p in data.get("plugins", []) if p.get("name")} + + +def parse_codeowners(codeowners_path: Path) -> set[str]: + """Parse CODEOWNERS and return set of plugin names with entries.""" + if not codeowners_path.exists(): + return set() + + plugins = set() + # Leading / matches CODEOWNERS path format (e.g., /plugins/foo/) + pattern = re.compile(r"^/plugins/([^/]+)/") + + for line in codeowners_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and (match := pattern.match(line)): + plugins.add(match.group(1)) + + return plugins + + +def parse_readme(readme_path: Path) -> set[str]: + """Parse README.md and return set of plugin names mentioned in tables.""" + if not readme_path.exists(): + return set() + + plugins = set() + # Matches [text](plugins/name) or [text](./plugins/name) + pattern = re.compile(r"\[[^\]]+\]\(\.?/?plugins/([^/)]+)") + + for line in readme_path.read_text().splitlines(): + for match in pattern.finditer(line): + plugins.add(match.group(1)) + + return plugins + + +def parse_plugin_json(plugin_path: Path) -> dict | None: + """Parse plugin.json and return data, or None if missing/invalid.""" + json_path = plugin_path / ".claude-plugin" / "plugin.json" + if not json_path.exists(): + return None + + try: + return json.loads(json_path.read_text()) + except json.JSONDecodeError: + return None + + +def validate_plugin_json( + plugin_data: dict | None, + plugin_path: Path, + plugin_name: str, +) -> list[str]: + """Validate plugin.json exists and has required fields.""" + json_path = plugin_path / ".claude-plugin" / "plugin.json" + + if not json_path.exists(): + return ["missing .claude-plugin/plugin.json"] + + if plugin_data is None: + return [".claude-plugin/plugin.json is invalid JSON"] + + errors = [] + + if "name" not in plugin_data: + errors.append(".claude-plugin/plugin.json missing 'name' field") + elif plugin_data["name"] != plugin_name: + errors.append( + f".claude-plugin/plugin.json name '{plugin_data['name']}' " + f"doesn't match directory name '{plugin_name}'" + ) + + if "description" not in plugin_data: + errors.append(".claude-plugin/plugin.json missing 'description' field") + + if "version" not in plugin_data: + errors.append(".claude-plugin/plugin.json missing 'version' field") + + return errors + + +def validate_marketplace_entry( + marketplace_plugins: dict[str, dict], + plugin_data: dict | None, + plugin_name: str, +) -> list[str]: + """Validate plugin has matching entry in marketplace.json.""" + if plugin_name not in marketplace_plugins: + return ["not found in .claude-plugin/marketplace.json"] + + if plugin_data is None: + return [] + + errors = [] + marketplace_entry = marketplace_plugins[plugin_name] + + if plugin_data.get("name") != marketplace_entry.get("name"): + errors.append( + f"name mismatch: plugin.json has '{plugin_data.get('name')}', " + f"marketplace.json has '{marketplace_entry.get('name')}'" + ) + + if plugin_data.get("version") != marketplace_entry.get("version"): + errors.append( + f"version mismatch: plugin.json has '{plugin_data.get('version')}', " + f"marketplace.json has '{marketplace_entry.get('version')}'" + ) + + if plugin_data.get("description") != marketplace_entry.get("description"): + errors.append("description mismatch between plugin.json and marketplace.json") + + expected_source = f"./plugins/{plugin_name}" + actual_source = marketplace_entry.get("source", "") + if actual_source != expected_source: + errors.append(f"marketplace.json source '{actual_source}' should be '{expected_source}'") + + return errors + + +def validate_plugins( + plugins_to_check: set[str], + repo_root: Path, +) -> list[ValidationError]: + """Validate all specified plugins and return errors.""" + errors = [] + + plugins_dir = repo_root / "plugins" + marketplace_plugins = parse_marketplace(repo_root / ".claude-plugin" / "marketplace.json") + codeowners_plugins = parse_codeowners(repo_root / "CODEOWNERS") + readme_plugins = parse_readme(repo_root / "README.md") + + for plugin_name in sorted(plugins_to_check): + plugin_path = plugins_dir / plugin_name + plugin_exists = plugin_path.is_dir() + + if plugin_exists: + plugin_data = parse_plugin_json(plugin_path) + + for msg in validate_plugin_json(plugin_data, plugin_path, plugin_name): + errors.append(ValidationError(plugin_name, msg)) + + for msg in validate_marketplace_entry(marketplace_plugins, plugin_data, plugin_name): + errors.append(ValidationError(plugin_name, msg)) + + if plugin_name not in codeowners_plugins: + errors.append(ValidationError(plugin_name, "not found in CODEOWNERS")) + + if plugin_name not in readme_plugins: + errors.append(ValidationError(plugin_name, "not found in README.md")) + else: + if plugin_name in marketplace_plugins: + errors.append( + ValidationError( + plugin_name, + "deleted but still in .claude-plugin/marketplace.json", + ) + ) + if plugin_name in codeowners_plugins: + errors.append(ValidationError(plugin_name, "deleted but still in CODEOWNERS")) + if plugin_name in readme_plugins: + errors.append(ValidationError(plugin_name, "deleted but still in README.md")) + + return errors + + +def main() -> int: + """Validate plugin metadata consistency.""" + repo_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent.parent.parent + + plugins_to_check = scan_plugins_directory(repo_root / "plugins") + if not plugins_to_check: + print(f"No plugins found in {repo_root / 'plugins'}") + return 0 + + print(f"Checking {len(plugins_to_check)} plugin(s): {', '.join(sorted(plugins_to_check))}") + + errors = validate_plugins(plugins_to_check, repo_root) + + if not errors: + print("All plugin metadata is in sync") + return 0 + + print(f"\nFound {len(errors)} error(s):\n") + for error in errors: + print(f" - {error}") + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac4a26a..10ee00c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,7 +36,7 @@ jobs: with: persist-credentials: false - name: Run shellcheck - run: find plugins -name "*.sh" -type f -exec shellcheck -x {} + + run: find plugins -name "*.sh" -type f -exec shellcheck --severity=warning -x {} + shfmt: name: Shell (shfmt) @@ -65,5 +65,5 @@ jobs: sudo apt-get install -y bats curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Run hook tests - run: bats plugins/modern-python/hooks/*.bats + - name: Run bats tests + run: find plugins -name "*.bats" -type f -print0 | xargs -0 --no-run-if-empty bats diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ee825b4..83bbb83 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,49 +22,6 @@ jobs: with: persist-credentials: false - - name: Validate JSON files - run: | - echo "Validating marketplace.json..." - python3 -m json.tool .claude-plugin/marketplace.json > /dev/null - - echo "Validating plugin.json files..." - for f in plugins/*/.claude-plugin/plugin.json; do - echo " Checking $f" - python3 -m json.tool "$f" > /dev/null - done - - - name: Check marketplace consistency - run: | - echo "Checking all marketplace plugins have directories..." - python3 -c " - import json - import os - import sys - - with open('.claude-plugin/marketplace.json') as f: - marketplace = json.load(f) - - errors = [] - for plugin in marketplace['plugins']: - name = plugin['name'] - source = plugin['source'] - # source is like ./plugins/name - path = source.lstrip('./') - if not os.path.isdir(path): - errors.append(f'Plugin {name}: directory {path} not found') - - plugin_json = os.path.join(path, '.claude-plugin', 'plugin.json') - if not os.path.isfile(plugin_json): - errors.append(f'Plugin {name}: missing {plugin_json}') - - if errors: - for e in errors: - print(f'ERROR: {e}', file=sys.stderr) - sys.exit(1) - - print(f'All {len(marketplace[\"plugins\"])} plugins validated') - " - - name: Validate SKILL.md frontmatter run: | echo "Checking SKILL.md frontmatter..." @@ -115,7 +72,7 @@ jobs: run: | echo "Checking for hardcoded user paths..." # Exclude /path/to (example paths) and /home/vscode (standard devcontainer user) - if grep -rE '/home/[a-z]|/Users/[A-Z]' plugins/ --include='*.md' --include='*.py' --include='*.json' | grep -v '/path/to' | grep -v '/home/vscode'; then + if grep -rPn '(?/dev/null | "$2"' _ "$url" "$FETCH_HOOK" + local safe_path + safe_path="$(_make_path_without_gh)" + run env PATH="$safe_path" bash -c 'jq -n --arg url "$1" '"'"'{"tool_input":{"url":$url,"prompt":"test"}}'"'"' 2>/dev/null | "$2"' _ "$url" "$FETCH_HOOK" + rm -rf "$safe_path" } run_curl_hook_no_gh() { local cmd="$1" - run env PATH=/usr/bin:/bin bash -c 'jq -n --arg cmd "$1" '"'"'{"tool_input":{"command":$cmd}}'"'"' 2>/dev/null | "$2"' _ "$cmd" "$CURL_HOOK" + local safe_path + safe_path="$(_make_path_without_gh)" + run env PATH="$safe_path" bash -c 'jq -n --arg cmd "$1" '"'"'{"tool_input":{"command":$cmd}}'"'"' 2>/dev/null | "$2"' _ "$cmd" "$CURL_HOOK" + rm -rf "$safe_path" } # Assert the hook allowed the action (exit 0, no output) diff --git a/plugins/insecure-defaults/.claude-plugin/plugin.json b/plugins/insecure-defaults/.claude-plugin/plugin.json index de0d535..8004fe0 100644 --- a/plugins/insecure-defaults/.claude-plugin/plugin.json +++ b/plugins/insecure-defaults/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "insecure-defaults", "version": "1.0.0", - "description": "Detects and verifies insecure default configurations", + "description": "Detects insecure default configurations including hardcoded credentials, fallback secrets, weak authentication defaults, and dangerous values in production", "author": { "name": "Trail of Bits", "email": "opensource@trailofbits.com",