// 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);
}
