diff --git a/skills/clawsec-clawhub-checker/README.md b/skills/clawsec-clawhub-checker/README.md new file mode 100644 index 0000000..b084ec6 --- /dev/null +++ b/skills/clawsec-clawhub-checker/README.md @@ -0,0 +1,133 @@ +# ClawSec ClawHub Checker + +A ClawSec suite skill that enhances the guarded skill installer with ClawHub reputation checks and VirusTotal Code Insight integration. + +## Purpose + +Adds a second layer of security to skill installation by: +1. Checking ClawHub's VirusTotal Code Insight reputation scores +2. Analyzing skill age, author reputation, and download statistics +3. Requiring double confirmation for suspicious skills +4. Integrating with existing ClawSec advisory checks + +## Architecture + +``` +clawsec-suite (base) +└── clawsec-clawhub-checker (enhancement) + ├── enhanced_guarded_install.mjs - Main enhanced installer + ├── check_clawhub_reputation.mjs - Reputation checking logic + ├── setup_reputation_hook.mjs - Integration script + └── hooks/ - Enhanced advisory guardian hook +``` + +## Installation + +```bash +# First install the base suite +npx clawhub install clawsec-suite + +# Then install the checker +npx clawhub install clawsec-clawhub-checker + +# Run setup to integrate with existing suite +node scripts/setup_reputation_hook.mjs + +# Restart OpenClaw gateway +openclaw gateway restart +``` + +Setup installs these scripts into `clawsec-suite/scripts`: +- `enhanced_guarded_install.mjs` +- `guarded_skill_install_wrapper.mjs` (drop-in wrapper) +- `check_clawhub_reputation.mjs` + +The original `guarded_skill_install.mjs` remains unchanged. + +## Usage + +### Enhanced Guarded Installer + +```bash +# Basic usage via wrapper (includes reputation checks) +node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0 + +# Direct usage (enhanced script) +node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0 + +# With reputation confirmation override +node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation + +# Adjust reputation threshold (default: 70) +node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --reputation-threshold 80 +``` + +### Reputation Check Only + +```bash +# Check reputation without installation +node scripts/check_clawhub_reputation.mjs some-skill 1.0.0 70 +``` + +## Exit Codes + +- `0` - Safe to install +- `42` - Advisory match found (requires `--confirm-advisory`) +- `43` - Reputation warning (requires `--confirm-reputation`) - **NEW** +- `1` - Error + +## Reputation Signals Checked + +1. **VirusTotal Code Insight** - Malicious code patterns +2. **Skill Age** - New skills (<7 days) are riskier +3. **Author Reputation** - Number of published skills +4. **Update Frequency** - Stale skills (>90 days) +5. **Download Statistics** - Low download counts +6. **Version Existence** - Specified version availability + +## Configuration + +Environment variables: +- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum score (0-100, default: 70) + +## Integration Points + +1. **Enhanced `guarded_skill_install.mjs`** - Wraps original with reputation checks + via `guarded_skill_install_wrapper.mjs` and `enhanced_guarded_install.mjs` +2. **Updated advisory guardian hook** - Adds reputation warnings to alerts +3. **Catalog entry in clawsec-suite** - Listed as available enhancement + +## Development + +### Files + +- `SKILL.md` - Main documentation +- `skill.json` - Skill metadata and SBOM +- `scripts/enhanced_guarded_install.mjs` - Enhanced installer +- `scripts/check_clawhub_reputation.mjs` - Reputation logic +- `scripts/setup_reputation_hook.mjs` - Integration script +- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook module + +### Testing + +```bash +# Test reputation check +node scripts/check_clawhub_reputation.mjs clawsec-suite + +# Test enhanced installer (dry run) +node scripts/enhanced_guarded_install.mjs --skill test-skill --dry-run + +# Test setup +node scripts/setup_reputation_hook.mjs +``` + +## Security Considerations + +- Reputation checks are **heuristic**, not definitive +- **False positives** possible with legitimate novel skills +- Always **review skill code** before overriding warnings +- This is **defense-in-depth**, not replacement for advisory feeds + +## License + +MIT - Part of the ClawSec security suite diff --git a/skills/clawsec-clawhub-checker/SKILL.md b/skills/clawsec-clawhub-checker/SKILL.md new file mode 100644 index 0000000..1c97b3d --- /dev/null +++ b/skills/clawsec-clawhub-checker/SKILL.md @@ -0,0 +1,149 @@ +--- +name: clawsec-clawhub-checker +version: 0.0.1 +description: ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks. +homepage: https://clawsec.prompt.security +clawdis: + emoji: "🛡️" + requires: + bins: [clawhub, curl, jq] + depends_on: [clawsec-suite] +--- + +# ClawSec ClawHub Checker + +Enhances the ClawSec suite's guarded skill installer with ClawHub reputation checks. Adds a second layer of security by checking VirusTotal Code Insight scores and other reputation signals before allowing skill installation. + +## What It Does + +1. **Wraps `clawhub install`** - Intercepts skill installation requests +2. **Checks VirusTotal reputation** - Uses ClawHub's built-in VirusTotal Code Insight +3. **Adds double confirmation** - For suspicious skills (reputation score below threshold) +4. **Integrates with advisory feed** - Works alongside existing clawsec-suite advisories +5. **Provides detailed reports** - Shows why a skill is flagged as suspicious + +## Installation + +This skill must be installed **after** `clawsec-suite`: + +```bash +# First install the suite +npx clawhub@latest install clawsec-suite + +# Then install the checker +npx clawhub@latest install clawsec-clawhub-checker + +# Run the setup script to integrate with clawsec-suite +node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs + +# Restart OpenClaw gateway for changes to take effect +openclaw gateway restart +``` + +After setup, the checker adds `enhanced_guarded_install.mjs` and +`guarded_skill_install_wrapper.mjs` under `clawsec-suite/scripts` and updates the advisory +guardian hook. The original `guarded_skill_install.mjs` is not replaced. + +## How It Works + +### Enhanced Guarded Installer + +After setup, run the wrapper (drop-in path) or the enhanced script directly: +```bash +# Recommended drop-in wrapper +node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0 + +# Or call the enhanced script directly +node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0 +``` + +The enhanced flow: +1. **Advisory check** (existing) - Checks clawsec advisory feed +2. **Reputation check** (new) - Queries ClawHub for VirusTotal scores +3. **Risk assessment** - Combines advisory + reputation signals +4. **Double confirmation** - If risky, requires explicit `--confirm-reputation` + +### Reputation Signals Checked + +1. **VirusTotal Code Insight** - Malicious code patterns, external dependencies (Docker usage, network calls, eval usage, crypto keys) +2. **Skill age & updates** - New skills vs established ones +3. **Author reputation** - Other skills by same author +4. **Download statistics** - Popularity signals + +### Exit Codes + +- `0` - Safe to install (no advisories, good reputation) +- `42` - Advisory match found (existing behavior) +- `43` - Reputation warning (new - requires `--confirm-reputation`) +- `1` - Error + +## Configuration + +Environment variables: +- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum reputation score (0-100, default: 70) + +## Integration with Existing Suite + +The checker enhances but doesn't replace existing security: +- **Advisory feed still primary** - Known malicious skills blocked first +- **Reputation is secondary** - Unknown/suspicious skills get extra scrutiny +- **Double confirmation preserved** - Both layers require explicit user approval + +## Example Usage + +```bash +# Try to install a skill +node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 + +# Output might show: +# WARNING: Skill "suspicious-skill" has low reputation score (45/100) +# - Flagged by VirusTotal Code Insight: crypto keys, external APIs, eval usage +# - Author has no other published skills +# - Skill is less than 7 days old +# +# To install despite reputation warning, run: +# node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation + +# Install with confirmation +node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation +``` + +## Safety Notes + +- This is a **defense-in-depth** layer, not a replacement for advisory feeds +- VirusTotal scores are **heuristic**, not definitive +- **False positives possible** - Legitimate skills with novel patterns might be flagged +- Always **review skill code** before installing with `--confirm-reputation` + +## Current Limitations + +### Missing OpenClaw Internal Check Data +ClawHub shows two security badges on skill pages: +1. **VirusTotal Code Insight** - ✅ Our checker catches these flags +2. **OpenClaw internal check** - ❌ Not exposed via API (only on website) + +Example from `clawsec-suite` page: +- VirusTotal: "Benign" ✓ +- OpenClaw internal check: "The package is internally consistent with a feed-monitoring / advisory-guardian purpose, but a few operational details and optional bypasses deserve attention before installing." + +**Our checker cannot access OpenClaw internal check warnings** as they're not exposed via `clawhub` CLI or API. + +### Recommendation for ClawHub +To enable complete reputation checking, ClawHub should expose internal check results via: +- `clawhub inspect --json` endpoint +- Additional API field for security tools +- Or include in `clawhub install` warning output + +### Workaround +Our heuristic checks (skill age, author reputation, downloads, updates) provide similar risk assessment but miss specific operational warnings about bypasses, missing signatures, etc. Always check the ClawHub website for complete security assessment. + +## Development + +To modify the reputation checking logic, edit: +- `scripts/enhanced_guarded_install.mjs` - Main enhanced installer +- `scripts/check_clawhub_reputation.mjs` - Reputation checking logic +- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook integration + +## License + +MIT - Part of the ClawSec security suite diff --git a/skills/clawsec-clawhub-checker/hooks/clawsec-advisory-guardian/lib/reputation.mjs b/skills/clawsec-clawhub-checker/hooks/clawsec-advisory-guardian/lib/reputation.mjs new file mode 100644 index 0000000..6dfc378 --- /dev/null +++ b/skills/clawsec-clawhub-checker/hooks/clawsec-advisory-guardian/lib/reputation.mjs @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +/** + * Check reputation for a skill + * @param {string} skillName - Skill name + * @param {string} version - Skill version + * @returns {Promise<{safe: boolean, score: number, warnings: string[]}>} + */ +export async function checkReputation(skillName, version) { + const result = { + safe: true, + score: 100, + warnings: [], + }; + + try { + // Try to get skill slug from directory name or skill.json + // For now, use skillName as slug (simplified) + const skillSlug = skillName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + + // Run the reputation check script + // Current file is at: .../hooks/clawsec-advisory-guardian/lib/reputation.mjs + // We need to go up 3 levels to get to the skill root directory + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const checkerDir = path.resolve(__dirname, '../../..'); + + const reputationCheck = spawnSync( + "node", + [ + `${checkerDir}/scripts/check_clawhub_reputation.mjs`, + skillSlug, + version || "", + "70" // Default threshold + ], + { encoding: "utf-8", cwd: checkerDir } + ); + + if (reputationCheck.status === 0) { + try { + const repResult = JSON.parse(reputationCheck.stdout); + result.safe = repResult.safe; + result.score = repResult.score; + result.warnings = repResult.warnings; + } catch (parseError) { + result.warnings.push(`Failed to parse reputation result: ${parseError.message}`); + result.score = 60; + result.safe = result.score >= 70; + } + } else if (reputationCheck.status === 43) { + // Reputation warning exit code + try { + const repResult = JSON.parse(reputationCheck.stdout); + result.safe = false; + result.score = repResult.score; + result.warnings = repResult.warnings; + } catch { + result.safe = false; + result.score = 50; + result.warnings.push("Skill flagged by reputation check"); + } + } else { + // Error running check + result.warnings.push(`Reputation check failed: ${reputationCheck.stderr || 'Unknown error'}`); + result.score = 60; + result.safe = result.score >= 70; + } + } catch (error) { + result.warnings.push(`Reputation check error: ${error.message}`); + result.score = 50; + result.safe = result.score >= 70; + } + + return result; +} + +/** + * Format reputation warning for alert messages + * @param {{score: number, warnings: string[]}} reputationInfo + * @returns {string} + */ +export function formatReputationWarning(reputationInfo) { + if (!reputationInfo || reputationInfo.score >= 70) return ""; + + const lines = [ + `\n⚠️ **REPUTATION WARNING** (Score: ${reputationInfo.score}/100)`, + ]; + + if (reputationInfo.warnings.length > 0) { + lines.push(""); + reputationInfo.warnings.forEach(w => lines.push(`• ${w}`)); + } + + lines.push(""); + lines.push("This skill has low reputation score. Review carefully before installation."); + + return lines.join("\n"); +} diff --git a/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs b/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs new file mode 100644 index 0000000..8863b11 --- /dev/null +++ b/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** + * Check ClawHub reputation for a skill + * @param {string} skillSlug - Skill slug to check + * @param {string} version - Optional version + * @param {number} threshold - Minimum reputation score (0-100) + * @returns {Promise<{safe: boolean, score: number, warnings: string[], virustotal: string[]}>} + */ +export async function checkClawhubReputation(skillSlug, version, threshold = 70) { + const result = { + safe: true, + score: 100, // Default score if no checks fail + warnings: [], + virustotal: [], + }; + + // Input validation — reject anything that isn't a safe slug or semver + if (!/^[a-z0-9][a-z0-9-]*$/.test(skillSlug)) { + result.warnings.push(`Invalid skill slug: ${skillSlug}`); + result.score = 0; + result.safe = false; + return result; + } + // Semver validation: supports major.minor.patch with optional pre-release and build metadata + // Examples: 1.0.0, 1.0.0-alpha.1, 1.0.0-beta+20130313144700 + // More restrictive than full semver spec for security (prevents command injection) + if (version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(version)) { + result.warnings.push(`Invalid version format: ${version}`); + result.score = 0; + result.safe = false; + return result; + } + + try { + // Check 1: Try to inspect the skill via clawhub + const inspectResult = spawnSync( + "clawhub", + ["inspect", skillSlug, "--json"], + { encoding: "utf-8" } + ); + + if (inspectResult.status !== 0) { + // Skill doesn't exist or can't be inspected + result.warnings.push(`Skill "${skillSlug}" not found or cannot be inspected`); + result.score = Math.min(result.score, 50); + } else { + try { + const skillInfo = JSON.parse(inspectResult.stdout); + + // Check 2: Skill age (new skills are riskier) + if (skillInfo.skill?.createdAt) { + const createdMs = skillInfo.skill.createdAt; + const ageDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24); + + if (ageDays < 7) { + result.warnings.push(`Skill is less than 7 days old (${ageDays.toFixed(1)} days)`); + result.score -= 15; + } else if (ageDays < 30) { + result.warnings.push(`Skill is less than 30 days old (${ageDays.toFixed(1)} days)`); + result.score -= 5; + } + } + + // Check 3: Update frequency (stale skills are riskier) + if (skillInfo.skill?.updatedAt && skillInfo.skill?.createdAt) { + const updatedMs = skillInfo.skill.updatedAt; + const createdMs = skillInfo.skill.createdAt; + const updateAgeDays = (Date.now() - updatedMs) / (1000 * 60 * 60 * 24); + const totalAgeDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24); + + if (updateAgeDays > 90 && totalAgeDays > 90) { + result.warnings.push(`Skill hasn't been updated in ${updateAgeDays.toFixed(0)} days`); + result.score -= 10; + } + } + + // Check 4: Author reputation + if (skillInfo.owner?.handle) { + const authorResult = spawnSync( + "clawhub", + ["search", skillInfo.owner.handle], + { encoding: "utf-8" } + ); + + if (authorResult.status === 0) { + const lines = authorResult.stdout.trim().split('\n').filter(l => l); + const skillCount = lines.length - 1; // First line is header + + if (skillCount === 1) { + result.warnings.push(`Author "${skillInfo.owner.handle}" has only 1 published skill`); + result.score -= 10; + } else if (skillCount < 3) { + result.warnings.push(`Author "${skillInfo.owner.handle}" has only ${skillCount} published skills`); + result.score -= 5; + } + } + } + + // Check 5: Download statistics + if (skillInfo.skill?.stats?.downloads !== undefined) { + const downloads = skillInfo.skill.stats.downloads; + if (downloads < 10) { + result.warnings.push(`Low download count: ${downloads}`); + result.score -= 10; + } else if (downloads < 100) { + result.warnings.push(`Moderate download count: ${downloads}`); + result.score -= 5; + } + } + + } catch (parseError) { + result.warnings.push(`Failed to parse skill information: ${parseError.message}`); + result.score = Math.min(result.score, 60); + } + } + + // Check 6: Try installation to detect VirusTotal Code Insight warnings + // Note: This approach has potential side effects: + // - May download/cache skill metadata before declining + // - Depends on clawhub's prompting behavior (sending "n\n" to decline) + // - If clawhub inspect provided security flags, we'd use that instead + // This is the only way to programmatically access VirusTotal warnings currently + const installArgs = ["install", skillSlug]; + if (version) installArgs.push("--version", version); + const installCheck = spawnSync("clawhub", installArgs, { + input: "n\n", // Automatically decline the installation prompt + encoding: "utf-8", + }); + + const output = (installCheck.stdout || "") + (installCheck.stderr || ""); + if (output.includes("suspicious") || output.includes("VirusTotal") || output.includes("flagged")) { + result.virustotal.push("Flagged by ClawHub's VirusTotal Code Insight"); + result.score -= 40; // More severe penalty for VirusTotal flag + + // Extract specific warnings + const lines = output.split('\n'); + for (const line of lines) { + if (line.includes("Warning:") || line.includes("risky patterns") || + line.includes("crypto keys") || line.includes("external APIs") || + line.includes("eval") || line.includes("VirusTotal Code Insight")) { + const cleanLine = line.trim().replace(/^⚠️\s*/, '').replace(/^\s*Warning:\s*/, ''); + if (cleanLine && !result.virustotal.includes(cleanLine)) { + result.virustotal.push(cleanLine); + } + } + } + } + + // Check 7: If version specified, check if it exists + if (version) { + const versionCheck = spawnSync( + "clawhub", + ["inspect", skillSlug, "--version", version, "--json"], + { encoding: "utf-8" } + ); + + if (versionCheck.status !== 0) { + result.warnings.push(`Version ${version} not found for skill ${skillSlug}`); + result.score -= 20; + } + } + + // Ensure score is within bounds + result.score = Math.max(0, Math.min(100, result.score)); + result.safe = result.score >= threshold; + + // Add summary warning if below threshold + if (!result.safe) { + result.warnings.unshift(`Reputation score ${result.score}/100 below threshold ${threshold}/100`); + } + + } catch (error) { + result.warnings.push(`Reputation check error: ${error.message}`); + result.score = 50; + result.safe = result.score >= threshold; + } + + return result; +} + +// CLI interface for direct usage +const isCliEntrypoint = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; + +if (isCliEntrypoint) { + async function main() { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node check_clawhub_reputation.mjs [version] [threshold]"); + process.exit(1); + } + + const skillSlug = args[0]; + const version = args[1] || ""; + let threshold = 70; + if (args[2] !== undefined) { + const parsedThreshold = parseInt(args[2], 10); + if (!Number.isInteger(parsedThreshold) || parsedThreshold < 0 || parsedThreshold > 100) { + console.error( + `Invalid threshold: "${args[2]}". Threshold must be an integer between 0 and 100.` + ); + process.exit(1); + } + threshold = parsedThreshold; + } + + const result = await checkClawhubReputation(skillSlug, version, threshold); + + console.log(JSON.stringify(result, null, 2)); + + if (!result.safe) { + process.exit(43); + } + } + + main().catch(console.error); +} diff --git a/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs b/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs new file mode 100644 index 0000000..b15f1d1 --- /dev/null +++ b/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { checkClawhubReputation } from "./check_clawhub_reputation.mjs"; + +const EXIT_ADVISORY_CONFIRM_REQUIRED = 42; +const EXIT_REPUTATION_CONFIRM_REQUIRED = 43; + +function printUsage() { + process.stderr.write( + [ + "Usage:", + " node scripts/enhanced_guarded_install.mjs --skill [--version ] [--confirm-advisory] [--confirm-reputation] [--dry-run] [--reputation-threshold ]", + "", + "Examples:", + " node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1", + " node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory --confirm-reputation", + " node scripts/enhanced_guarded_install.mjs --skill suspicious-skill --reputation-threshold 80", + "", + "Exit codes:", + " 0 success / no advisory or reputation block", + " 42 advisory matched and second confirmation is required", + " 43 reputation warning and second confirmation is required", + " 1 error", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + // Parse and validate CLAWHUB_REPUTATION_THRESHOLD environment variable + let defaultThreshold = 70; + const envThreshold = process.env.CLAWHUB_REPUTATION_THRESHOLD; + + if (envThreshold !== undefined && envThreshold !== "") { + const parsedEnv = parseInt(envThreshold, 10); + if (Number.isNaN(parsedEnv) || parsedEnv < 0 || parsedEnv > 100) { + throw new Error( + `Invalid CLAWHUB_REPUTATION_THRESHOLD environment variable: "${envThreshold}". Must be between 0 and 100.` + ); + } + defaultThreshold = parsedEnv; + } + + const parsed = { + skill: "", + version: "", + confirmAdvisory: false, + confirmReputation: false, + dryRun: false, + reputationThreshold: defaultThreshold, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + + if (token === "--skill") { + parsed.skill = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + if (token === "--version") { + parsed.version = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + if (token === "--confirm-advisory") { + parsed.confirmAdvisory = true; + continue; + } + if (token === "--confirm-reputation") { + parsed.confirmReputation = true; + continue; + } + if (token === "--dry-run") { + parsed.dryRun = true; + continue; + } + if (token === "--reputation-threshold") { + parsed.reputationThreshold = parseInt(String(argv[i + 1] ?? "70"), 10); + i += 1; + continue; + } + if (token === "--help" || token === "-h") { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${token}`); + } + + if (!parsed.skill) { + throw new Error("Missing required argument: --skill"); + } + // Must start with alphanumeric, then can contain hyphens (matches check_clawhub_reputation.mjs validation) + if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.skill)) { + throw new Error("Invalid --skill value. Must start with a letter or digit, followed by lowercase letters, digits, and hyphens."); + } + if (parsed.version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(parsed.version)) { + throw new Error( + "Invalid --version value. Must be semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.45)." + ); + } + if (parsed.reputationThreshold < 0 || parsed.reputationThreshold > 100 || Number.isNaN(parsed.reputationThreshold)) { + throw new Error("Invalid --reputation-threshold value. Must be between 0 and 100."); + } + + return parsed; +} + +function buildOriginalArgs(argv) { + // Filter out reputation-specific arguments that the original script doesn't understand + const originalArgs = []; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + + if (token === "--confirm-reputation" || token === "--reputation-threshold") { + // Skip reputation-specific flags + if (token === "--reputation-threshold" && i + 1 < argv.length) { + // Also skip the value associated with --reputation-threshold + i += 1; + } + continue; + } + + originalArgs.push(token); + } + + return originalArgs; +} + +async function runOriginalGuardedInstall(args) { + // Find the original guarded_skill_install.mjs from clawsec-suite + const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite"); + const originalScript = path.join(suiteDir, "scripts", "guarded_skill_install.mjs"); + + try { + await fs.access(originalScript); + } catch { + throw new Error(`Original guarded_skill_install.mjs not found at ${originalScript}. Is clawsec-suite installed?`); + } + + // Pass through environment without modification + // The original guarded_skill_install.mjs handles --confirm-advisory properly + const child = spawnSync( + "node", + [originalScript, ...args.originalArgs], + { + stdio: "inherit", + env: process.env, + cwd: suiteDir, + }, + ); + + return { + exitCode: child.status ?? 1, + signal: child.signal, + }; +} + +async function main() { + try { + const cliArgs = process.argv.slice(2); + const args = parseArgs(cliArgs); + + // Build args for original script (excluding reputation-specific args) + args.originalArgs = buildOriginalArgs(cliArgs); + + // Step 1: Check reputation (unless already confirmed) + if (!args.confirmReputation) { + console.log(`Checking ClawHub reputation for ${args.skill}${args.version ? `@${args.version}` : ""}...`); + + const reputationResult = await checkClawhubReputation(args.skill, args.version, args.reputationThreshold); + + if (!reputationResult.safe) { + console.error("\n" + "=".repeat(80)); + console.error("REPUTATION WARNING"); + console.error("=".repeat(80)); + console.error(`Skill "${args.skill}" has low reputation score: ${reputationResult.score}/100`); + console.error(`Threshold: ${args.reputationThreshold}/100`); + console.error(""); + + if (reputationResult.warnings.length > 0) { + console.error("Warnings:"); + reputationResult.warnings.forEach(w => console.error(` • ${w}`)); + console.error(""); + } + + if (reputationResult.virustotal) { + console.error("VirusTotal Code Insight flags:"); + reputationResult.virustotal.forEach(v => console.error(` • ${v}`)); + console.error(""); + } + + console.error("To install despite reputation warning, run with --confirm-reputation flag:"); + console.error(` node ${process.argv[1]} --skill ${args.skill}${args.version ? ` --version ${args.version}` : ""} --confirm-reputation`); + console.error(""); + console.error("=".repeat(80)); + + process.exit(EXIT_REPUTATION_CONFIRM_REQUIRED); + } + + console.log(`✓ Reputation check passed: ${reputationResult.score}/100`); + } else { + console.log(`⚠️ Reputation confirmation override enabled for ${args.skill}`); + } + + // Step 2: Run original guarded installer (handles advisory checks) + console.log("\nRunning advisory checks..."); + const result = await runOriginalGuardedInstall(args); + + if (result.exitCode !== 0 && result.exitCode !== EXIT_ADVISORY_CONFIRM_REQUIRED) { + process.exit(result.exitCode); + } + + // If we get here, either success (0) or advisory confirmation required (42) + process.exit(result.exitCode); + + } catch (error) { + console.error("Error:", error.message); + process.exit(1); + } +} + +main(); diff --git a/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs b/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs new file mode 100644 index 0000000..cd70019 --- /dev/null +++ b/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +async function main() { + console.log("Setting up ClawHub reputation checker integration..."); + + // Paths + const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite"); + const checkerDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-clawhub-checker"); + const hookLibDir = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "lib"); + const suiteScriptsDir = path.join(suiteDir, "scripts"); + + try { + // Check if clawsec-suite is installed + await fs.access(suiteDir); + console.log(`✓ Found clawsec-suite at ${suiteDir}`); + + // Check if hook lib directory exists + await fs.access(hookLibDir); + console.log(`✓ Found advisory guardian hook at ${hookLibDir}`); + + // Copy reputation module to hook lib + const reputationModuleSrc = path.join(checkerDir, "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs"); + const reputationModuleDst = path.join(hookLibDir, "reputation.mjs"); + + await fs.copyFile(reputationModuleSrc, reputationModuleDst); + console.log(`✓ Copied reputation module to ${reputationModuleDst}`); + + // Update hook handler to import reputation module + const hookHandlerPath = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "handler.ts"); + let handlerContent = await fs.readFile(hookHandlerPath, "utf8"); + + // WARNING: This setup script uses string manipulation to modify handler.ts + // This is fragile and may break if the handler structure changes + // Consider using AST-based transformation or manual integration for production use + let handlerChanged = false; + const importLine = "import { checkReputation } from \"./lib/reputation.mjs\";"; + const reputationMarker = "// ClawHub reputation check for matched skills"; + + if (!handlerContent.includes(importLine)) { + // Add import after other imports + const importIndex = handlerContent.lastIndexOf("import"); + if (importIndex === -1) { + throw new Error("Could not find import statements in handler.ts. Manual integration required."); + } + + const lineEndIndex = handlerContent.indexOf("\n", importIndex); + handlerContent = handlerContent.slice(0, lineEndIndex + 1) + `${importLine}\n` + handlerContent.slice(lineEndIndex + 1); + handlerChanged = true; + } else { + console.log("✓ Hook handler already imports reputation module"); + } + + if (!handlerContent.includes(reputationMarker)) { + const findMatchesAnchors = [ + { line: "const allMatches = findMatches(feed, installedSkills);", variable: "allMatches" }, + { line: "const matches = findMatches(feed, installedSkills);", variable: "matches" }, + ]; + const matchedAnchor = findMatchesAnchors.find((entry) => handlerContent.includes(entry.line)); + + if (!matchedAnchor) { + throw new Error( + "Could not find findMatches assignment in handler.ts. Refusing partial setup. Manual integration required." + ); + } + + const anchorIndex = handlerContent.indexOf(matchedAnchor.line); + const insertIndex = handlerContent.indexOf("\n", anchorIndex) + 1; + const reputationCheckCode = ` + ${reputationMarker} + for (const match of ${matchedAnchor.variable}) { + const repResult = await checkReputation(match.skill.name, match.skill.version); + if (!repResult.safe) { + match.reputationWarning = true; + match.reputationScore = repResult.score; + match.reputationWarnings = repResult.warnings; + } + } +`; + handlerContent = handlerContent.slice(0, insertIndex) + reputationCheckCode + handlerContent.slice(insertIndex); + handlerChanged = true; + } else { + console.log("✓ Hook handler already has reputation scan block"); + } + + if (handlerChanged) { + await fs.writeFile(hookHandlerPath, handlerContent); + console.log("✓ Updated hook handler with reputation checks"); + } else { + console.log("✓ Hook handler already has required reputation integration"); + } + + // Copy enhanced installer and reputation checker scripts + const enhancedInstallerSrc = path.join(checkerDir, "scripts", "enhanced_guarded_install.mjs"); + const enhancedInstallerDst = path.join(suiteDir, "scripts", "enhanced_guarded_install.mjs"); + const reputationCheckSrc = path.join(checkerDir, "scripts", "check_clawhub_reputation.mjs"); + const reputationCheckDst = path.join(suiteScriptsDir, "check_clawhub_reputation.mjs"); + + await fs.copyFile(enhancedInstallerSrc, enhancedInstallerDst); + console.log(`✓ Installed enhanced guarded installer at ${enhancedInstallerDst}`); + + await fs.copyFile(reputationCheckSrc, reputationCheckDst); + console.log(`✓ Installed reputation check script at ${reputationCheckDst}`); + + // Create wrapper script that uses enhanced installer by default + const wrapperScript = `#!/usr/bin/env node + +// Wrapper that uses enhanced guarded installer with reputation checks +// This replaces the original guarded_skill_install.mjs in usage + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const enhancedScript = path.join(__dirname, "enhanced_guarded_install.mjs"); + +const result = spawnSync("node", [enhancedScript, ...process.argv.slice(2)], { + stdio: "inherit", +}); + +process.exit(result.status ?? 1); +`; + + const wrapperPath = path.join(suiteDir, "scripts", "guarded_skill_install_wrapper.mjs"); + await fs.writeFile(wrapperPath, wrapperScript); + await fs.chmod(wrapperPath, 0o755); + console.log(`✓ Created wrapper script at ${wrapperPath}`); + + console.log("\n" + "=".repeat(80)); + console.log("SETUP COMPLETE"); + console.log("=".repeat(80)); + console.log("\nThe ClawHub reputation checker has been integrated with clawsec-suite."); + console.log("\nWhat changed:"); + console.log("1. Enhanced guarded installer with reputation checks installed"); + console.log("2. Reputation check helper script installed"); + console.log("3. Advisory guardian hook updated to include reputation warnings"); + console.log("4. Wrapper script created for backward compatibility"); + console.log("\nUsage:"); + console.log(" node scripts/enhanced_guarded_install.mjs --skill [--version ]"); + console.log(" node scripts/guarded_skill_install_wrapper.mjs --skill [--version ]"); + console.log("\nNew exit code: 43 = Reputation warning (requires --confirm-reputation)"); + console.log("\nRestart OpenClaw gateway for hook changes to take effect."); + console.log("=".repeat(80)); + + } catch (error) { + console.error("Setup failed:", error.message); + console.error("\nMake sure:"); + console.error("1. clawsec-suite is installed (npx clawhub install clawsec-suite)"); + console.error("2. You have write permissions to the suite directory"); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/skills/clawsec-clawhub-checker/skill.json b/skills/clawsec-clawhub-checker/skill.json new file mode 100644 index 0000000..08c0ea7 --- /dev/null +++ b/skills/clawsec-clawhub-checker/skill.json @@ -0,0 +1,91 @@ +{ + "name": "clawsec-clawhub-checker", + "version": "0.0.1", + "description": "ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.", + "author": "abutbul", + "license": "MIT", + "homepage": "https://clawsec.prompt.security/", + "keywords": [ + "security", + "reputation", + "clawhub", + "virustotal", + "skills", + "installer", + "verification", + "defense-in-depth", + "openclaw" + ], + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true, + "description": "Skill documentation and usage guide" + }, + { + "path": "scripts/enhanced_guarded_install.mjs", + "required": true, + "description": "Enhanced guarded installer with reputation checks" + }, + { + "path": "scripts/check_clawhub_reputation.mjs", + "required": true, + "description": "ClawHub reputation checking logic" + }, + { + "path": "scripts/setup_reputation_hook.mjs", + "required": true, + "description": "Setup script to enhance existing advisory guardian hook" + }, + { + "path": "hooks/clawsec-advisory-guardian/lib/reputation.mjs", + "required": true, + "description": "Reputation checking module for advisory guardian hook" + }, + { + "path": "README.md", + "required": false, + "description": "Additional documentation and development guide" + }, + { + "path": "test/reputation_check.test.mjs", + "required": false, + "description": "Test suite for reputation checking functionality" + } + ] + }, + "dependencies": { + "clawsec-suite": ">=0.0.10" + }, + "integration": { + "clawsec-suite": { + "enhances": [ + "guarded_skill_install.mjs", + "clawsec-advisory-guardian hook" + ], + "adds_exit_codes": { + "43": "Reputation warning - requires --confirm-reputation" + }, + "adds_arguments": [ + "--confirm-reputation", + "--reputation-threshold" + ] + } + }, + "openclaw": { + "emoji": "🛡️", + "category": "security", + "requires": { + "bins": ["clawhub", "curl", "jq"] + }, + "triggers": [ + "clawhub reputation", + "skill reputation check", + "virustotal skill check", + "safe skill install", + "check skill safety", + "skill security score" + ] + } +} diff --git a/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs b/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs new file mode 100644 index 0000000..7b94ae0 --- /dev/null +++ b/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs @@ -0,0 +1,433 @@ +#!/usr/bin/env node + +/** + * Reputation check tests for clawsec-clawhub-checker. + * + * Tests cover: + * - Input validation (command injection prevention) + * - Reputation scoring with mocked clawhub output + * - formatReputationWarning output formatting + * - Enhanced installer argument parsing + * + * Run: node skills/clawsec-clawhub-checker/test/reputation_check.test.mjs + */ + +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CHECKER_SCRIPT = path.resolve(__dirname, "..", "scripts", "check_clawhub_reputation.mjs"); +const ENHANCED_INSTALL_SCRIPT = path.resolve(__dirname, "..", "scripts", "enhanced_guarded_install.mjs"); + +let passCount = 0; +let failCount = 0; + +function pass(name) { + passCount++; + console.log(`\u2713 ${name}`); +} + +function fail(name, error) { + failCount++; + console.error(`\u2717 ${name}`); + console.error(` ${String(error)}`); +} + +function runScript(scriptPath, args, env) { + return new Promise((resolve) => { + const proc = spawn("node", [scriptPath, ...args], { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +// ----------------------------------------------------------------------------- +// Test: Invalid skill slug is rejected (command injection prevention) +// ----------------------------------------------------------------------------- +async function testInvalidSlugRejected() { + const testName = "reputation_check: invalid slug with shell metacharacters is rejected"; + try { + const result = await runScript(CHECKER_SCRIPT, ['test; rm -rf /', '', '70']); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output: ${result.stdout}`); + return; + } + + if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid skill slug"))) { + pass(testName); + } else { + fail(testName, `Expected score 0 with invalid slug warning, got: ${JSON.stringify(parsed)}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Invalid version format is rejected (command injection prevention) +// ----------------------------------------------------------------------------- +async function testInvalidVersionRejected() { + const testName = "reputation_check: invalid version with shell metacharacters is rejected"; + try { + const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0; curl evil.com', '70']); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output: ${result.stdout}`); + return; + } + + if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid version format"))) { + pass(testName); + } else { + fail(testName, `Expected score 0 with invalid version warning, got: ${JSON.stringify(parsed)}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Valid slug and version pass input validation +// ----------------------------------------------------------------------------- +async function testValidInputsAccepted() { + const testName = "reputation_check: valid slug and semver pass input validation"; + try { + // clawhub is not installed, so the check will fail at the inspect step, + // but it should NOT fail at input validation + const result = await runScript(CHECKER_SCRIPT, ['my-test-skill', '1.0.0', '70']); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output: ${result.stdout}`); + return; + } + + // Should not contain input validation errors + const hasInputError = parsed.warnings.some( + w => w.includes("Invalid skill slug") || w.includes("Invalid version format") + ); + if (!hasInputError) { + pass(testName); + } else { + fail(testName, `Valid inputs were rejected: ${JSON.stringify(parsed.warnings)}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Slug with uppercase or special chars is rejected +// ----------------------------------------------------------------------------- +async function testUppercaseSlugRejected() { + const testName = "reputation_check: uppercase slug is rejected"; + try { + const result = await runScript(CHECKER_SCRIPT, ['Test-Skill', '1.0.0', '70']); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output: ${result.stdout}`); + return; + } + + if (parsed.score === 0 && parsed.safe === false) { + pass(testName); + } else { + fail(testName, `Expected uppercase slug to be rejected, got: ${JSON.stringify(parsed)}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Empty slug shows usage error +// ----------------------------------------------------------------------------- +async function testEmptySlugShowsUsage() { + const testName = "reputation_check: empty slug shows usage error"; + try { + const result = await runScript(CHECKER_SCRIPT, []); + + if (result.code === 1 && result.stderr.includes("Usage:")) { + pass(testName); + } else { + fail(testName, `Expected exit 1 with usage message, got code ${result.code}: ${result.stderr}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Version with pre-release tag is accepted +// ----------------------------------------------------------------------------- +async function testPreReleaseVersionAccepted() { + const testName = "reputation_check: pre-release version format is accepted"; + try { + const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0-beta.1', '70']); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output: ${result.stdout}`); + return; + } + + const hasVersionError = parsed.warnings.some(w => w.includes("Invalid version format")); + if (!hasVersionError) { + pass(testName); + } else { + fail(testName, `Pre-release version was rejected: ${JSON.stringify(parsed.warnings)}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: CLI entrypoint guard works when script path is relative +// ----------------------------------------------------------------------------- +async function testRelativePathCliEntrypointWorks() { + const testName = "reputation_check: CLI entrypoint works with relative script path"; + try { + const relativeCheckerScript = path.relative(process.cwd(), CHECKER_SCRIPT); + const result = await runScript(relativeCheckerScript, ['bad slug', '', '70']); + + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + fail(testName, `Could not parse output with relative script path: ${result.stdout}`); + return; + } + + if ( + result.code === 43 && + parsed.safe === false && + parsed.warnings.some((w) => w.includes("Invalid skill slug")) + ) { + pass(testName); + } else { + fail( + testName, + `Expected exit 43 with invalid slug warning via relative path, got code ${result.code}: ${JSON.stringify(parsed)}` + ); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Invalid threshold format is rejected in CLI mode +// ----------------------------------------------------------------------------- +async function testInvalidThresholdRejected() { + const testName = "reputation_check: invalid threshold is rejected"; + try { + const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0', 'abc']); + + if (result.code === 1 && result.stderr.includes("Invalid threshold")) { + pass(testName); + } else { + fail( + testName, + `Expected exit 1 with invalid threshold message, got code ${result.code}: ${result.stderr}` + ); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Enhanced installer rejects invalid skill name +// ----------------------------------------------------------------------------- +async function testEnhancedInstallerRejectsInvalidSkill() { + const testName = "enhanced_install: rejects skill name with invalid characters"; + try { + const result = await runScript(ENHANCED_INSTALL_SCRIPT, ['--skill', 'bad skill!']); + + if (result.code === 1 && result.stderr.includes("Invalid --skill value")) { + pass(testName); + } else { + fail(testName, `Expected exit 1 with invalid skill error, got code ${result.code}: ${result.stderr}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Enhanced installer requires --skill argument +// ----------------------------------------------------------------------------- +async function testEnhancedInstallerRequiresSkill() { + const testName = "enhanced_install: requires --skill argument"; + try { + const result = await runScript(ENHANCED_INSTALL_SCRIPT, []); + + if (result.code === 1 && result.stderr.includes("Missing required argument")) { + pass(testName); + } else { + fail(testName, `Expected exit 1 with missing argument error, got code ${result.code}: ${result.stderr}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Enhanced installer rejects invalid threshold +// ----------------------------------------------------------------------------- +async function testEnhancedInstallerRejectsInvalidThreshold() { + const testName = "enhanced_install: rejects invalid reputation threshold"; + try { + const result = await runScript(ENHANCED_INSTALL_SCRIPT, [ + '--skill', 'test-skill', '--reputation-threshold', '150' + ]); + + if (result.code === 1 && result.stderr.includes("Invalid --reputation-threshold")) { + pass(testName); + } else { + fail(testName, `Expected exit 1 with invalid threshold error, got code ${result.code}: ${result.stderr}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: formatReputationWarning +// ----------------------------------------------------------------------------- +async function testFormatReputationWarning() { + const testName = "reputation: formatReputationWarning formats correctly"; + try { + const { formatReputationWarning } = await import( + path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs") + ); + + // Safe reputation — should return empty + const safeResult = formatReputationWarning({ score: 80, warnings: [] }); + if (safeResult !== "") { + fail(testName, `Expected empty string for safe score, got: "${safeResult}"`); + return; + } + + // Unsafe reputation — should contain warning + const unsafeResult = formatReputationWarning({ score: 45, warnings: ["Low downloads", "New author"] }); + if ( + unsafeResult.includes("REPUTATION WARNING") && + unsafeResult.includes("45/100") && + unsafeResult.includes("Low downloads") && + unsafeResult.includes("New author") + ) { + pass(testName); + } else { + fail(testName, `Unexpected format: "${unsafeResult}"`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: formatReputationWarning handles null/undefined +// ----------------------------------------------------------------------------- +async function testFormatReputationWarningNull() { + const testName = "reputation: formatReputationWarning handles null input"; + try { + const { formatReputationWarning } = await import( + path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs") + ); + + const nullResult = formatReputationWarning(null); + const undefinedResult = formatReputationWarning(undefined); + + if (nullResult === "" && undefinedResult === "") { + pass(testName); + } else { + fail(testName, `Expected empty for null/undefined, got: "${nullResult}", "${undefinedResult}"`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Test: Enhanced installer validates --version even with --confirm-reputation +// ----------------------------------------------------------------------------- +async function testEnhancedInstallerRejectsInvalidVersion() { + const testName = "enhanced_install: rejects invalid version format even with --confirm-reputation"; + try { + const result = await runScript(ENHANCED_INSTALL_SCRIPT, [ + '--skill', 'test-skill', '--version', '1.0.0;rm -rf /', '--confirm-reputation' + ]); + + if (result.code === 1 && result.stderr.includes("Invalid --version value")) { + pass(testName); + } else { + fail( + testName, + `Expected exit 1 with invalid version message, got code ${result.code}: ${result.stderr}` + ); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Main test runner +// ----------------------------------------------------------------------------- +async function runTests() { + console.log("=== ClawSec ClawHub Checker Tests ===\n"); + + await testInvalidSlugRejected(); + await testInvalidVersionRejected(); + await testValidInputsAccepted(); + await testUppercaseSlugRejected(); + await testEmptySlugShowsUsage(); + await testPreReleaseVersionAccepted(); + await testRelativePathCliEntrypointWorks(); + await testInvalidThresholdRejected(); + await testEnhancedInstallerRejectsInvalidSkill(); + await testEnhancedInstallerRequiresSkill(); + await testEnhancedInstallerRejectsInvalidVersion(); + await testEnhancedInstallerRejectsInvalidThreshold(); + await testFormatReputationWarning(); + await testFormatReputationWarningNull(); + + console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`); + + if (failCount > 0) { + process.exit(1); + } +} + +runTests().catch((error) => { + console.error("Test runner failed:", error); + process.exit(1); +});