630 lines
25 KiB
JavaScript
630 lines
25 KiB
JavaScript
// 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<boolean>}
|
|
*/
|
|
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<ComponentEntry[]>}
|
|
*/
|
|
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<object>} 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);
|
|
}
|