// permission-mapper.mjs — PRM scanner: permission mismatches, excessive agency, ghost hooks // Detects: purpose vs tools mismatch, dangerous tool combos, ghost hooks, // haiku on sensitive agents, overprivileged agents, undocumented permissions. // OWASP LLM06 — Excessive Agency // Zero dependencies (Node.js builtins only). import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseFrontmatter, classifyPluginFile } from './lib/yaml-frontmatter.mjs'; import { readFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { existsSync } from 'node:fs'; const SCANNER = 'PRM'; const OWASP = 'LLM06'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- // Description keywords that signal read-only intent. const READ_ONLY_INTENT_KEYWORDS = [ 'scan', 'analyze', 'analyse', 'audit', 'assess', 'check', 'review', 'evaluate', 'inspect', 'report', 'detect', 'monitor', 'observe', ]; // Tools that carry write / side-effect capability. const WRITE_TOOLS = new Set(['Write', 'Edit']); const BASH_TOOL = 'Bash'; // Description keywords that imply network / exfiltration risk when paired with Bash. const NETWORK_KEYWORDS = [ 'fetch', 'download', 'upload', 'send', 'webhook', 'api', 'http', 'post', 'request', 'endpoint', 'network', 'transfer', ]; // Indicators that an agent deals with sensitive operations. const SENSITIVE_KEYWORDS = [ 'security', 'secret', 'credential', 'auth', 'permission', 'deploy', 'token', 'key', 'password', 'certificate', 'vault', ]; // Maximum tolerated tool count before flagging as overprivileged. const MAX_TOOLS_INFO_THRESHOLD = 6; // Tool that allows a component to spawn sub-agents. const DELEGATION_TOOL = 'Task'; // Subdirectories of a plugin that contain components with frontmatter. const COMPONENT_DIRS = ['commands', 'agents', 'skills']; // --------------------------------------------------------------------------- // Plugin detection // --------------------------------------------------------------------------- /** * Decide whether targetPath looks like a Claude Code plugin. * Accepts plugins with either a plugin.json manifest or at least one .md file * with frontmatter in a commands/ directory. * * @param {string} targetPath * @returns {Promise} */ async function isPlugin(targetPath) { // Check for canonical manifest location (includes .fixture variant for test fixtures). if (existsSync(join(targetPath, '.claude-plugin', 'plugin.json'))) return true; if (existsSync(join(targetPath, 'plugin.json'))) return true; if (existsSync(join(targetPath, 'plugin.fixture.json'))) return true; // Check for at least one command .md with frontmatter. const commandsDir = join(targetPath, 'commands'); if (!existsSync(commandsDir)) return false; try { const { readdir } = await import('node:fs/promises'); const entries = await readdir(commandsDir); for (const entry of entries) { if (!entry.endsWith('.md')) continue; const content = await readTextFile(join(commandsDir, entry)); if (content && parseFrontmatter(content)) return true; } } catch { // Unreadable directory — not a plugin we can scan. } return false; } // --------------------------------------------------------------------------- // Permission matrix builder // --------------------------------------------------------------------------- /** * @typedef {Object} ComponentEntry * @property {string} component - Logical name (frontmatter name or filename) * @property {'command'|'agent'|'skill'|'unknown'} type * @property {string[]} tools - Normalised tool list * @property {string} description * @property {string} model - Model name or empty string * @property {string} file - Relative file path * @property {string} absFile - Absolute file path */ /** * Collect all component entries from the plugin's component directories. * * @param {string} targetPath * @returns {Promise} */ async function buildPermissionMatrix(targetPath) { const matrix = []; const { readdir } = await import('node:fs/promises'); for (const dir of COMPONENT_DIRS) { const absDir = join(targetPath, dir); if (!existsSync(absDir)) continue; let entries; try { entries = await readdir(absDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isFile()) continue; // Accept .md and .fixture.md (test fixtures) if (!entry.name.endsWith('.md')) continue; const absFile = join(absDir, entry.name); const relFile = `${dir}/${entry.name}`; const content = await readTextFile(absFile); if (!content) continue; const fm = parseFrontmatter(content); if (!fm) continue; // Skip files without frontmatter — not a component. const type = classifyPluginFile(relFile, fm); // Normalise tools: accept both `tools` (agents) and `allowed-tools` / `allowed_tools` (commands). const rawTools = fm.tools || fm.allowed_tools || fm['allowed-tools'] || []; const tools = Array.isArray(rawTools) ? rawTools.map(t => String(t).trim()).filter(Boolean) : String(rawTools).split(',').map(t => t.trim()).filter(Boolean); matrix.push({ component: fm.name || entry.name.replace(/\.md$/, ''), type, tools, description: typeof fm.description === 'string' ? fm.description.toLowerCase() : '', model: typeof fm.model === 'string' ? fm.model.toLowerCase() : '', file: relFile, absFile, }); } } return matrix; } // --------------------------------------------------------------------------- // Helper predicates // --------------------------------------------------------------------------- /** True if description suggests the component is read-only. */ function hasReadOnlyIntent(description) { return READ_ONLY_INTENT_KEYWORDS.some(kw => description.includes(kw)); } /** True if the tool list contains any write-capable tool. */ function hasWriteTools(tools) { return tools.some(t => WRITE_TOOLS.has(t)); } /** True if the tool list contains Bash. */ function hasBash(tools) { return tools.includes(BASH_TOOL); } /** True if description mentions network-related operations. */ function hasNetworkIntent(description) { return NETWORK_KEYWORDS.some(kw => description.includes(kw)); } /** True if description or tools suggest sensitive / security operations. */ function isSensitiveComponent(entry) { if (SENSITIVE_KEYWORDS.some(kw => entry.description.includes(kw))) return true; // Bash on any component can touch the host — treat as mildly sensitive. if (hasBash(entry.tools)) return true; return false; } // --------------------------------------------------------------------------- // Check: Purpose vs Tools Mismatch (HIGH) // --------------------------------------------------------------------------- /** * Flag components that declare read-only intent but include write-capable tools. * Bash alone is acceptable for scanners (they need to run analysis commands). * Write or Edit on a read-only-intent component → HIGH. */ function checkPurposeToolsMismatch(matrix) { const findings = []; for (const entry of matrix) { if (!hasReadOnlyIntent(entry.description)) continue; if (!hasWriteTools(entry.tools)) continue; const offendingTools = entry.tools.filter(t => WRITE_TOOLS.has(t)); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.HIGH, title: `Read-only intent with write tools: ${entry.component}`, description: `Component "${entry.component}" (${entry.type}) has a description implying read-only ` + `operation (contains: ${READ_ONLY_INTENT_KEYWORDS.filter(kw => entry.description.includes(kw)).join(', ')}) ` + `but is granted write-capable tools: ${offendingTools.join(', ')}. ` + `This violates the principle of least privilege — a scanner/auditor should not be able to ` + `modify files on disk. Grant only Read, Glob, Grep, and Bash (for running analysis commands).`, file: entry.file, evidence: `tools: [${entry.tools.join(', ')}]`, owasp: OWASP, recommendation: `Remove ${offendingTools.join(', ')} from ${entry.file}. ` + `If content modification is genuinely required, rename/redescribe the component to ` + `reflect its mutating intent (e.g. "fix", "patch", "remediate").`, })); } return findings; } // --------------------------------------------------------------------------- // Check: Dangerous Tool Combinations (HIGH / MEDIUM) // --------------------------------------------------------------------------- /** * Bash + Write + Edit together → HIGH (can download and overwrite arbitrary files). * Bash + network-related description → MEDIUM (potential exfiltration vector). */ function checkDangerousToolCombos(matrix) { const findings = []; for (const entry of matrix) { const hasBashTool = hasBash(entry.tools); const hasWriteOrEdit = hasWriteTools(entry.tools); const hasEdit = entry.tools.includes('Edit'); const hasWrite = entry.tools.includes('Write'); // HIGH: Bash + Write + Edit together without clear justification for code modification. if (hasBashTool && hasWrite && hasEdit) { // Allow if description clearly describes code modification (e.g. "fix", "patch", "generate"). const modificationWords = ['fix', 'patch', 'generate', 'create', 'write', 'modify', 'update', 'implement', 'refactor']; const justified = modificationWords.some(w => entry.description.includes(w)); if (!justified) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.HIGH, title: `Full-access tool triple on ${entry.component}: Bash + Write + Edit`, description: `Component "${entry.component}" (${entry.type}) holds Bash, Write, and Edit simultaneously ` + `without a description that justifies code modification. This combination allows ` + `arbitrary command execution combined with unrestricted file creation and editing — ` + `effectively full host access. An attacker who compromises this component's prompt ` + `can execute any shell command and persist output to disk.`, file: entry.file, evidence: `tools: [${entry.tools.join(', ')}]`, owasp: OWASP, recommendation: `Reduce the tool set to the minimum necessary. Separate read-and-run concerns ` + `(Bash + Read/Glob/Grep) from write concerns (Edit/Write) into distinct components, ` + `or add a description that clearly explains why all three are required.`, })); } } // MEDIUM: Bash + network-related description (potential exfiltration). if (hasBashTool && hasNetworkIntent(entry.description)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: `Bash + network intent on ${entry.component}`, description: `Component "${entry.component}" (${entry.type}) has Bash access and its description ` + `references network operations (${NETWORK_KEYWORDS.filter(kw => entry.description.includes(kw)).join(', ')}). ` + `Bash can invoke curl, wget, nc, or similar utilities. Combined with network intent, ` + `this is a plausible exfiltration vector if the component is prompt-injected.`, file: entry.file, evidence: `tools includes Bash; description: "${entry.description.slice(0, 120)}"`, owasp: OWASP, recommendation: `If network access is required, document the exact endpoints and protocols in the ` + `description. Consider using a dedicated MCP tool with constrained scope instead of ` + `open Bash. Add pre-bash-destructive hook coverage for exfil patterns.`, })); } } return findings; } // --------------------------------------------------------------------------- // Check: Ghost Hooks (MEDIUM) // --------------------------------------------------------------------------- /** * Read hooks/hooks.json and verify that every referenced script file actually exists. * Missing scripts → MEDIUM (declared enforcement that isn't enforced). * Scripts outside the plugin directory → MEDIUM (unusual path, possible tampering). */ async function checkGhostHooks(targetPath) { const findings = []; // Check standard path and .fixture variant (test fixtures). let hooksJsonPath = join(targetPath, 'hooks', 'hooks.json'); if (!existsSync(hooksJsonPath)) { hooksJsonPath = join(targetPath, 'hooks', 'hooks.fixture.json'); } if (!existsSync(hooksJsonPath)) return findings; let hooksConfig; try { const raw = await readFile(hooksJsonPath, 'utf-8'); hooksConfig = JSON.parse(raw); } catch (err) { // Malformed hooks.json — not a ghost hook issue, but worth noting. findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: 'hooks/hooks.json is not valid JSON', description: `hooks/hooks.json could not be parsed (${err.message}). ` + `Malformed hook configuration means hook enforcement is silently disabled — ` + `all hooks declared in this file will not run.`, file: 'hooks/hooks.json', owasp: OWASP, recommendation: 'Fix the JSON syntax error in hooks/hooks.json.', })); return findings; } // The hooks object is keyed by event name (PreToolUse, PostToolUse, etc.). // Each value is an array of hook descriptor objects, each of which has a `hooks` array // whose entries may have a `command` string. const hooksRoot = hooksConfig.hooks || hooksConfig; if (typeof hooksRoot !== 'object' || Array.isArray(hooksRoot)) return findings; for (const [eventName, descriptors] of Object.entries(hooksRoot)) { if (!Array.isArray(descriptors)) continue; for (const descriptor of descriptors) { const innerHooks = descriptor.hooks; if (!Array.isArray(innerHooks)) continue; for (const hookEntry of innerHooks) { if (hookEntry.type !== 'command' || typeof hookEntry.command !== 'string') continue; // Extract the script path from the command string. // Handles: "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs" // "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs" // "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs" const commandStr = hookEntry.command; // Replace the plugin root placeholder with the actual target path. const resolved = commandStr .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, targetPath) .replace(/\$CLAUDE_PLUGIN_ROOT/g, targetPath); // Split on whitespace and find the first argument that looks like a file path. const parts = resolved.trim().split(/\s+/); // Skip interpreter tokens (bash, node, sh). const interpreters = new Set(['bash', 'node', 'sh', 'zsh']); const scriptPath = parts.find( p => !interpreters.has(p) && (p.startsWith('/') || p.includes('/')) ); if (!scriptPath) continue; // Check existence. if (!existsSync(scriptPath)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: `Ghost hook: script not found for ${eventName} event`, description: `hooks/hooks.json declares a ${eventName} hook with command "${commandStr}" ` + `but the resolved script path "${scriptPath}" does not exist on disk. ` + `This is a ghost hook — the hook is registered but the enforcement script is missing. ` + `Any security control this hook was meant to provide is not active.`, file: 'hooks/hooks.json', evidence: `command: "${commandStr}"`, owasp: OWASP, recommendation: `Either create the missing script at "${scriptPath}", update the command path ` + `in hooks.json to point to the correct location, or remove the ghost hook entry.`, })); } else { // Script exists — check if it's outside the plugin directory (unusual). if (!scriptPath.startsWith(targetPath)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: `Hook script outside plugin directory: ${eventName}`, description: `hooks/hooks.json references a script at "${scriptPath}" which is outside ` + `the plugin directory "${targetPath}". Hooks that depend on external scripts ` + `create a supply-chain dependency — if that external path is modified or deleted, ` + `the hook silently fails. It may also indicate an attempt to load shared code ` + `that could be tampered with by another plugin.`, file: 'hooks/hooks.json', evidence: `command: "${commandStr}"`, owasp: OWASP, recommendation: `Move the script into the plugin's own hooks/scripts/ directory and update ` + `the path in hooks.json to use \${CLAUDE_PLUGIN_ROOT}.`, })); } } } } } return findings; } // --------------------------------------------------------------------------- // Check: Haiku on Sensitive Agents (MEDIUM) // --------------------------------------------------------------------------- /** * Flag agents that use the haiku model for sensitive operations. * Haiku is the smallest/cheapest model and may lack the reasoning capability * to correctly apply security policies. */ function checkHaikuOnSensitiveAgents(matrix) { const findings = []; for (const entry of matrix) { if (entry.type !== 'agent') continue; if (!entry.model.includes('haiku')) continue; if (!isSensitiveComponent(entry)) continue; const triggerKeywords = [ ...SENSITIVE_KEYWORDS.filter(kw => entry.description.includes(kw)), ...(hasBash(entry.tools) ? ['Bash'] : []), ]; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: `Haiku model on sensitive agent: ${entry.component}`, description: `Agent "${entry.component}" uses the haiku model but is configured for sensitive ` + `operations (indicators: ${triggerKeywords.join(', ')}). ` + `Haiku is optimised for speed and cost — it may not reliably enforce nuanced ` + `security policies, correctly interpret ambiguous instructions, or resist ` + `prompt injection targeting its smaller context window.`, file: entry.file, evidence: `model: haiku; tools: [${entry.tools.join(', ')}]`, owasp: OWASP, recommendation: `Upgrade to sonnet or opus for agents that handle security-sensitive operations, ` + `credentials, deployment, or unrestricted shell access.`, })); } return findings; } // --------------------------------------------------------------------------- // Check: Overprivileged Agents (MEDIUM / INFO) // --------------------------------------------------------------------------- /** * Flag agents with an unusually large tool set or with the Task + Bash combination * (which allows delegating unrestricted shell execution to sub-agents). */ function checkOverprivilegedAgents(matrix) { const findings = []; for (const entry of matrix) { // Skill and command files can legitimately have many tools; focus on agents. if (entry.type !== 'agent') continue; // INFO: More than MAX_TOOLS_INFO_THRESHOLD tools — worth noting. if (entry.tools.length > MAX_TOOLS_INFO_THRESHOLD) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.INFO, title: `Broad tool surface on agent: ${entry.component} (${entry.tools.length} tools)`, description: `Agent "${entry.component}" has ${entry.tools.length} tools: [${entry.tools.join(', ')}]. ` + `A large tool set increases the blast radius of a successful prompt injection — ` + `the agent can take more actions than may be intended. ` + `Review whether each tool is genuinely required for this agent's role.`, file: entry.file, evidence: `tools: [${entry.tools.join(', ')}]`, owasp: OWASP, recommendation: `Audit each tool against the agent's stated purpose. Remove any tool not required ` + `for the primary function. Consider splitting the agent into focused sub-agents ` + `if multiple distinct capabilities are needed.`, })); } // MEDIUM: Task + Bash → can delegate unrestricted shell execution. if (entry.tools.includes(DELEGATION_TOOL) && hasBash(entry.tools)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.MEDIUM, title: `Delegation + Bash on agent: ${entry.component}`, description: `Agent "${entry.component}" has both Task (sub-agent spawning) and Bash. ` + `This allows the agent to create sub-agents that inherit or escalate its Bash ` + `access, enabling indirect, multi-hop command execution that may bypass ` + `per-agent restrictions. If this agent is prompt-injected, an attacker can ` + `spin up an arbitrarily capable sub-agent chain.`, file: entry.file, evidence: `tools includes Task and Bash`, owasp: OWASP, recommendation: `If delegation is required, create a dedicated orchestrator agent that has Task ` + `but NOT Bash. Let leaf agents have Bash without Task. Enforce this separation ` + `at the component boundary.`, })); } } return findings; } // --------------------------------------------------------------------------- // Check: Missing Permissions Documentation (LOW) // --------------------------------------------------------------------------- /** * Flag components that have tools but no description explaining why those tools are needed. * An empty or very short description provides no justification for the granted capabilities. */ function checkMissingPermissionsDoc(matrix) { const findings = []; for (const entry of matrix) { if (entry.tools.length === 0) continue; // Description shorter than 30 chars offers essentially no justification. if (entry.description.length >= 30) continue; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.LOW, title: `No tool justification in description: ${entry.component}`, description: `Component "${entry.component}" (${entry.type}) is granted ${entry.tools.length} ` + `tool(s) — [${entry.tools.join(', ')}] — but its description ("${entry.description}") ` + `is too short to explain why those tools are needed. ` + `Without documented justification, reviewers cannot verify that the tool set is appropriate.`, file: entry.file, evidence: `description length: ${entry.description.length} chars`, owasp: OWASP, recommendation: `Add a meaningful description that explains the component's purpose and why each ` + `tool is required. This makes security review possible and deters scope creep.`, })); } return findings; } // --------------------------------------------------------------------------- // Main export // --------------------------------------------------------------------------- /** * Scan a target path for permission mismatches and excessive agency. * * @param {string} targetPath - Absolute path to the plugin or directory to scan. * @param {object} [discovery] - Pre-computed discovery result (optional, not used here). * @returns {Promise} Scanner result envelope. */ export async function scan(targetPath, discovery) { const start = Date.now(); // --- Plugin detection --- const pluginDetected = await isPlugin(targetPath); if (!pluginDetected) { return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start); } const findings = []; let filesScanned = 0; try { // --- Build permission matrix --- const matrix = await buildPermissionMatrix(targetPath); filesScanned = matrix.length; // --- Run all checks --- findings.push(...checkPurposeToolsMismatch(matrix)); findings.push(...checkDangerousToolCombos(matrix)); findings.push(...checkHaikuOnSensitiveAgents(matrix)); findings.push(...checkOverprivilegedAgents(matrix)); findings.push(...checkMissingPermissionsDoc(matrix)); // --- Ghost hook check (reads hooks.json separately) --- const ghostFindings = await checkGhostHooks(targetPath); findings.push(...ghostFindings); if (existsSync(join(targetPath, 'hooks', 'hooks.json')) || existsSync(join(targetPath, 'hooks', 'hooks.fixture.json'))) filesScanned += 1; } catch (err) { return scannerResult( SCANNER, 'error', findings, filesScanned, Date.now() - start, err.message, ); } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }