#!/usr/bin/env node
// posture-scanner.mjs — Deterministic security posture assessment
// Replaces the Opus-based posture-assessor-agent for quick posture checks.
// 13 categories aligned with OWASP LLM Top 10 + Claude Code Security Baseline v1.0.
//
// Standalone CLI: node scanners/posture-scanner.mjs [path] → JSON stdout
// Library: import { scan } from './posture-scanner.mjs'
//
// Scanner prefix: PST
// Zero external dependencies — Node.js builtins only.

import { readFile, readdir, stat, access } from 'node:fs/promises';
import { join, resolve, relative, extname } from 'node:path';
import { homedir } from 'node:os';
import { scanForInjection } from './lib/injection-patterns.mjs';
import { gradeFromPassRate, riskScore, riskBand, verdict, SEVERITY } from './lib/severity.mjs';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const VERSION = '7.3.1';

/** Minimum lines for a hook script to be considered non-stub */
const NON_STUB_THRESHOLD = 5;

/** Status values */
const STATUS = Object.freeze({ PASS: 'PASS', PARTIAL: 'PARTIAL', FAIL: 'FAIL', NA: 'N_A' });

/** Category definitions */
const CATEGORIES = [
  { id: 1, name: 'Deny-First Configuration', owasp: 'ASI02, ASI03' },
  { id: 2, name: 'Secrets Protection', owasp: 'ASI03, ASI05' },
  { id: 3, name: 'Path Guarding', owasp: 'ASI05, ASI10' },
  { id: 4, name: 'MCP Server Trust', owasp: 'ASI04, ASI07' },
  { id: 5, name: 'Destructive Command Blocking', owasp: 'ASI02, ASI05' },
  { id: 6, name: 'Sandbox Configuration', owasp: 'ASI02, ASI05' },
  { id: 7, name: 'Human Review Requirements', owasp: 'ASI09' },
  { id: 8, name: 'Skill and Plugin Sources', owasp: 'ASI04' },
  { id: 9, name: 'Session Isolation', owasp: 'ASI06, ASI08' },
  { id: 10, name: 'Cognitive State Security', owasp: 'LLM01, ASI02' },
  { id: 11, name: 'Prompt Injection Hardening', owasp: 'LLM01, ASI01' },
  { id: 12, name: 'Rule of Two', owasp: 'ASI02, ASI05' },
  { id: 13, name: 'Long-Horizon Monitoring', owasp: 'ASI06, ASI08' },
  { id: 14, name: 'EU AI Act Compliance', owasp: 'Governance' },
  { id: 15, name: 'NIST AI RMF Alignment', owasp: 'Governance' },
  { id: 16, name: 'ISO 42001 Readiness', owasp: 'Governance' },
];

// Critical categories: FAIL in these prevents Grade A
const CRITICAL_CATEGORIES = new Set([1, 2, 5]);

// ---------------------------------------------------------------------------
// File reading helpers
// ---------------------------------------------------------------------------

async function readJson(filePath) {
  try {
    const raw = await readFile(filePath, 'utf-8');
    return JSON.parse(raw);
  } catch { return null; }
}

async function readText(filePath) {
  try { return await readFile(filePath, 'utf-8'); }
  catch { return null; }
}

async function fileExists(filePath) {
  try { await access(filePath); return true; }
  catch { return false; }
}

async function listDir(dirPath) {
  try { return await readdir(dirPath); }
  catch { return []; }
}

/**
 * Count non-empty, non-comment lines in a file.
 * @param {string} filePath
 * @returns {Promise<number>}
 */
async function countCodeLines(filePath) {
  const content = await readText(filePath);
  if (!content) return 0;
  return content.split('\n').filter(l => {
    const t = l.trim();
    return t.length > 0 && !t.startsWith('//') && !t.startsWith('#') && !t.startsWith('<!--');
  }).length;
}

/**
 * Read YAML-ish frontmatter from a markdown file (simplified).
 * Returns the raw text between --- delimiters.
 */
function extractFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---/);
  return match ? match[1] : '';
}

/**
 * Find all .md files in a directory (non-recursive).
 */
async function listMdFiles(dirPath) {
  const entries = await listDir(dirPath);
  return entries.filter(e => e.endsWith('.md'));
}

// ---------------------------------------------------------------------------
// Category check functions
// Each returns { status, findings, evidence }
// ---------------------------------------------------------------------------

/**
 * Category 1: Deny-First Configuration (ASI02, ASI03)
 */
async function checkDenyFirst(projectRoot, globalSettings, projectSettings) {
  const evidence = [];
  const findings = [];
  let hasSettingsFile = false;
  let denyFirst = false;
  let hasBroadAllow = false;
  let claudeMdGuardrails = false;
  let commandsMissingTools = 0;
  let totalCommands = 0;

  // Check settings
  for (const [label, settings] of [['global', globalSettings], ['project', projectSettings]]) {
    if (!settings) continue;
    hasSettingsFile = true;

    // Check permissions
    const perms = settings.permissions || {};
    if (perms.defaultPermissionLevel === 'deny' || perms.defaultPermissionLevel === 'deny-all') {
      denyFirst = true;
      evidence.push(`${label}: defaultPermissionLevel = ${perms.defaultPermissionLevel}`);
    }

    // Check for broad allows
    const allow = perms.allow || [];
    if (allow.includes('*') || allow.some(a => typeof a === 'string' && a === '*')) {
      hasBroadAllow = true;
      evidence.push(`${label}: broad allow wildcard found`);
    }

    // Check scoped allows
    const scopedAllow = Array.isArray(allow) ? allow : [];
    if (scopedAllow.length > 0 && !hasBroadAllow) {
      evidence.push(`${label}: ${scopedAllow.length} scoped allow rules`);
    }
  }

  // Check CLAUDE.md for guardrails
  const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
  if (claudeMd) {
    const guardrailKeywords = /\b(?:deny|block|restrict|scope[- ]guard|override|security\s+boundar)/i;
    if (guardrailKeywords.test(claudeMd)) {
      claudeMdGuardrails = true;
      evidence.push('CLAUDE.md has scope/override guardrails');
    }
  }

  // Check commands/agents for allowed-tools
  for (const dir of ['commands', 'agents']) {
    const mdFiles = await listMdFiles(join(projectRoot, dir));
    for (const file of mdFiles) {
      totalCommands++;
      const content = await readText(join(projectRoot, dir, file));
      if (!content) continue;
      const fm = extractFrontmatter(content);
      if (!fm.includes('allowed-tools') && !fm.includes('tools:')) {
        commandsMissingTools++;
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.LOW,
          title: `No allowed-tools declared: ${dir}/${file}`,
          description: `${dir}/${file} has no allowed-tools in frontmatter. Explicit tool lists enforce least-privilege.`,
          file: `${dir}/${file}`,
          owasp: 'ASI02',
          recommendation: 'Add allowed-tools to frontmatter listing only required tools.',
        }));
      }
    }
  }

  // Determine status
  if (!hasSettingsFile) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No settings.json found',
      description: 'No global or project settings.json found. Cannot verify deny-first configuration.',
      owasp: 'ASI02',
      recommendation: 'Create .claude/settings.json with defaultPermissionLevel: deny.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hasBroadAllow) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'Broad allow wildcard in permissions',
      description: 'Settings contain allow: ["*"] or similar broad wildcard, defeating deny-first.',
      owasp: 'ASI02',
      recommendation: 'Replace broad wildcards with explicit, scoped allow rules.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (denyFirst && claudeMdGuardrails && commandsMissingTools === 0) {
    return { status: STATUS.PASS, findings, evidence };
  }

  if (denyFirst || (!hasBroadAllow && hasSettingsFile)) {
    if (!claudeMdGuardrails) {
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.LOW,
        title: 'CLAUDE.md lacks scope/override guardrails',
        description: 'CLAUDE.md does not contain deny-first language or scope guard instructions.',
        file: 'CLAUDE.md',
        owasp: 'ASI02',
        recommendation: 'Add security boundary instructions to CLAUDE.md.',
      }));
    }
    return { status: STATUS.PARTIAL, findings, evidence };
  }

  return { status: STATUS.FAIL, findings, evidence };
}

/**
 * Category 2: Secrets Protection (ASI03, ASI05)
 */
async function checkSecretsProtection(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hookNonStub = false;
  let gitignoreCoversSecrets = false;

  // Check hooks.json for pre-edit-secrets
  if (hooksJson) {
    const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
    const entries = Array.isArray(preToolUse) ? preToolUse : [];
    for (const entry of entries) {
      const matcher = entry.matcher || '';
      const hooks = entry.hooks || [];
      if (/edit|write/i.test(matcher)) {
        for (const h of hooks) {
          if (h.command && /pre-edit-secrets/i.test(h.command)) {
            hookActive = true;
            evidence.push(`Hook registered: ${h.command}`);
          }
        }
      }
    }
  }

  // Check if hook script is non-stub
  if (hookActive) {
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-edit-secrets.mjs');
    const lines = await countCodeLines(scriptPath);
    hookNonStub = lines > NON_STUB_THRESHOLD;
    evidence.push(`pre-edit-secrets.mjs: ${lines} code lines (${hookNonStub ? 'non-stub' : 'stub'})`);
  }

  // Check .gitignore
  const gitignore = await readText(join(projectRoot, '.gitignore'));
  if (gitignore) {
    const secretPatterns = ['.env', '*.key', '*.pem', 'credentials', 'secrets'];
    const covered = secretPatterns.filter(p => gitignore.includes(p));
    gitignoreCoversSecrets = covered.length >= 3;
    evidence.push(`.gitignore covers: ${covered.join(', ')}`);
    if (!gitignoreCoversSecrets) {
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.MEDIUM,
        title: 'Incomplete secrets coverage in .gitignore',
        description: `.gitignore covers only ${covered.length}/5 secret patterns: ${covered.join(', ')}`,
        file: '.gitignore',
        owasp: 'ASI03',
        recommendation: 'Add .env, *.key, *.pem, credentials.*, secrets.* to .gitignore.',
      }));
    }
  } else {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.MEDIUM,
      title: 'No .gitignore found',
      description: 'No .gitignore file found. Secrets may be committed to the repository.',
      owasp: 'ASI03',
      recommendation: 'Create .gitignore with standard secret exclusions.',
    }));
  }

  // Check for embedded secrets in md files (quick scan)
  const secretPatterns = /(?:sk-[a-zA-Z0-9]{20,}|Bearer\s+[a-zA-Z0-9._-]{20,}|password\s*=\s*["'][^"']+["']|(?:api[_-]?key|token)\s*=\s*["'][^"']+["'])/;
  for (const mdFile of ['CLAUDE.md', 'REMEMBER.md']) {
    const content = await readText(join(projectRoot, mdFile));
    if (content && secretPatterns.test(content)) {
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.CRITICAL,
        title: `Embedded secret in ${mdFile}`,
        description: `${mdFile} contains what appears to be a hardcoded secret.`,
        file: mdFile,
        owasp: 'ASI05',
        recommendation: 'Remove the secret immediately. Use environment variables or a secret manager.',
      }));
    }
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No secrets protection hook registered',
      description: 'No pre-edit-secrets hook found in hooks.json. Secrets can be written to files unchecked.',
      owasp: 'ASI03',
      recommendation: 'Register pre-edit-secrets.mjs under PreToolUse with Edit|Write matcher.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hookNonStub && gitignoreCoversSecrets) {
    return { status: STATUS.PASS, findings, evidence };
  }

  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 3: Path Guarding (ASI05, ASI10)
 */
async function checkPathGuarding(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hookNonStub = false;
  const requiredPaths = ['.env', '.ssh', '.aws', 'credentials', 'key', 'pem'];
  let coveredPaths = [];

  // Check hooks.json for pre-write-pathguard
  if (hooksJson) {
    const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
    const entries = Array.isArray(preToolUse) ? preToolUse : [];
    for (const entry of entries) {
      const matcher = entry.matcher || '';
      const hooks = entry.hooks || [];
      if (/write/i.test(matcher)) {
        for (const h of hooks) {
          if (h.command && /pathguard/i.test(h.command)) {
            hookActive = true;
            evidence.push(`Hook registered: ${h.command}`);
          }
        }
      }
    }
  }

  // Check script content for path coverage
  if (hookActive) {
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-write-pathguard.mjs');
    const content = await readText(scriptPath);
    if (content) {
      const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//')).length;
      hookNonStub = lines > NON_STUB_THRESHOLD;
      coveredPaths = requiredPaths.filter(p => content.includes(p));
      evidence.push(`pathguard covers: ${coveredPaths.join(', ')}`);
      evidence.push(`${lines} code lines (${hookNonStub ? 'non-stub' : 'stub'})`);

      const missing = requiredPaths.filter(p => !coveredPaths.includes(p));
      if (missing.length > 0) {
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.MEDIUM,
          title: 'Pathguard missing sensitive path patterns',
          description: `Pathguard hook does not cover: ${missing.join(', ')}`,
          file: 'hooks/scripts/pre-write-pathguard.mjs',
          owasp: 'ASI05',
          recommendation: `Add protection for: ${missing.join(', ')}`,
        }));
      }
    }
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No path guard hook registered',
      description: 'No pre-write-pathguard hook found. Sensitive paths (.env, .ssh, .aws) are unprotected.',
      owasp: 'ASI05',
      recommendation: 'Register pre-write-pathguard.mjs under PreToolUse with Write matcher.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hookNonStub && coveredPaths.length >= 4) {
    return { status: STATUS.PASS, findings, evidence };
  }

  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 4: MCP Server Trust (ASI04, ASI07)
 */
async function checkMcpTrust(projectRoot, globalSettings, projectSettings) {
  const evidence = [];
  const findings = [];
  let mcpServers = {};

  // Collect MCP configs from all sources
  const mcpJson = await readJson(join(projectRoot, '.mcp.json'));
  if (mcpJson?.mcpServers) Object.assign(mcpServers, mcpJson.mcpServers);

  for (const settings of [globalSettings, projectSettings]) {
    if (settings?.mcpServers) Object.assign(mcpServers, settings.mcpServers);
  }

  const desktopConfig = await readJson(join(projectRoot, 'claude_desktop_config.json'));
  if (desktopConfig?.mcpServers) Object.assign(mcpServers, desktopConfig.mcpServers);

  const serverNames = Object.keys(mcpServers);
  if (serverNames.length === 0) {
    evidence.push('No MCP servers configured');
    return { status: STATUS.NA, findings, evidence };
  }

  evidence.push(`${serverNames.length} MCP server(s): ${serverNames.join(', ')}`);

  let unpinned = 0;
  let noAuth = 0;

  for (const [name, config] of Object.entries(mcpServers)) {
    const cmd = config.command || '';
    const args = (config.args || []).join(' ');
    const full = `${cmd} ${args}`;

    // Check version pinning
    if (!/@\d+\.\d+/.test(full) && !/:\d+\.\d+/.test(full)) {
      unpinned++;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.LOW,
        title: `MCP server "${name}" version not pinned`,
        description: `MCP server "${name}" does not appear to have a pinned version.`,
        owasp: 'ASI04',
        recommendation: 'Pin the MCP server to a specific version.',
      }));
    }

    // Check for HTTP/SSE without auth
    if (/https?:\/\//.test(full) && !/auth|apikey|token|bearer/i.test(JSON.stringify(config))) {
      noAuth++;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.HIGH,
        title: `MCP server "${name}" has network access without auth`,
        description: `MCP server "${name}" connects to HTTP endpoint without visible auth configuration.`,
        owasp: 'ASI07',
        recommendation: 'Configure authentication for network-accessible MCP servers.',
      }));
    }
  }

  // Check for post-mcp-verify hook (via hooksJson passed from parent)
  // We check it at the caller level — here just note if we found servers

  if (noAuth > 0) return { status: STATUS.FAIL, findings, evidence };
  if (unpinned > 0) return { status: STATUS.PARTIAL, findings, evidence };
  return { status: STATUS.PASS, findings, evidence };
}

/**
 * Category 5: Destructive Command Blocking (ASI02, ASI05)
 */
async function checkDestructiveBlocking(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hookNonStub = false;
  // Use single keywords that must appear in any real blocklist script
  const requiredPatterns = ['rm', 'force', 'DROP', 'curl', 'wget', 'mkfs'];
  let coveredPatterns = [];

  if (hooksJson) {
    const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
    const entries = Array.isArray(preToolUse) ? preToolUse : [];
    for (const entry of entries) {
      const matcher = entry.matcher || '';
      const hooks = entry.hooks || [];
      if (/bash/i.test(matcher)) {
        for (const h of hooks) {
          if (h.command && /destructive/i.test(h.command)) {
            hookActive = true;
            evidence.push(`Hook registered: ${h.command}`);
          }
        }
      }
    }
  }

  if (hookActive) {
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-bash-destructive.mjs');
    const content = await readText(scriptPath);
    if (content) {
      const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//')).length;
      hookNonStub = lines > NON_STUB_THRESHOLD;
      coveredPatterns = requiredPatterns.filter(p => content.toLowerCase().includes(p.toLowerCase()));
      evidence.push(`Blocklist covers: ${coveredPatterns.join(', ')}`);

      const missing = requiredPatterns.filter(p => !coveredPatterns.includes(p));
      if (missing.length > 0 && missing.length <= 2) {
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.MEDIUM,
          title: 'Destructive command blocklist incomplete',
          description: `Blocklist missing: ${missing.join(', ')}`,
          file: 'hooks/scripts/pre-bash-destructive.mjs',
          owasp: 'ASI05',
          recommendation: `Add patterns for: ${missing.join(', ')}`,
        }));
      }
    }
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No destructive command hook registered',
      description: 'No pre-bash-destructive hook found. rm -rf, curl|sh, DROP TABLE etc. are not blocked.',
      owasp: 'ASI05',
      recommendation: 'Register pre-bash-destructive.mjs under PreToolUse with Bash matcher.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hookNonStub && coveredPatterns.length >= 5) {
    return { status: STATUS.PASS, findings, evidence };
  }

  if (hookNonStub) {
    return { status: STATUS.PARTIAL, findings, evidence };
  }

  findings.push(finding({
    scanner: 'PST',
    severity: SEVERITY.HIGH,
    title: 'Destructive command hook is a stub',
    description: 'pre-bash-destructive.mjs exists but has insufficient code to function.',
    file: 'hooks/scripts/pre-bash-destructive.mjs',
    owasp: 'ASI05',
    recommendation: 'Implement the destructive command blocklist.',
  }));
  return { status: STATUS.FAIL, findings, evidence };
}

/**
 * Category 6: Sandbox Configuration (ASI02, ASI05)
 */
async function checkSandboxConfig(projectRoot, globalSettings, projectSettings) {
  const evidence = [];
  const findings = [];
  let dangerousFlags = 0;

  for (const [label, settings] of [['global', globalSettings], ['project', projectSettings]]) {
    if (!settings) continue;

    // Check for dangerous flags
    if (settings.dangerouslyAllowArbitraryPaths === true) {
      dangerousFlags++;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.CRITICAL,
        title: `dangerouslyAllowArbitraryPaths enabled (${label})`,
        description: `${label} settings.json has dangerouslyAllowArbitraryPaths: true.`,
        owasp: 'ASI05',
        recommendation: 'Remove dangerouslyAllowArbitraryPaths or set to false.',
      }));
    }

    if (settings.skipDangerousModePermissionPrompt === true) {
      dangerousFlags++;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.HIGH,
        title: `skipDangerousModePermissionPrompt enabled (${label})`,
        description: `${label} settings.json skips dangerous mode permission prompts.`,
        owasp: 'ASI02',
        recommendation: 'Set skipDangerousModePermissionPrompt to false.',
      }));
    }

    // Check network access
    if (settings.sandbox?.network === 'unrestricted' || settings.network === 'unrestricted') {
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.HIGH,
        title: `Unrestricted network access (${label})`,
        description: `${label} settings.json has network: unrestricted.`,
        owasp: 'ASI05',
        recommendation: 'Restrict network access to required domains only.',
      }));
      dangerousFlags++;
    }
  }

  // Check for bypassPermissions in commands/agents
  let bypassCount = 0;
  for (const dir of ['commands', 'agents']) {
    const mdFiles = await listMdFiles(join(projectRoot, dir));
    for (const file of mdFiles) {
      const content = await readText(join(projectRoot, dir, file));
      if (content && /bypass[Pp]ermissions|--dangerously-skip-permissions/i.test(content)) {
        bypassCount++;
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.HIGH,
          title: `Permission bypass in ${dir}/${file}`,
          description: `${dir}/${file} references bypassPermissions or --dangerously-skip-permissions.`,
          file: `${dir}/${file}`,
          owasp: 'ASI02',
          recommendation: 'Remove permission bypass references. Use explicit allow rules instead.',
        }));
      }
    }
  }

  evidence.push(`dangerous flags: ${dangerousFlags}, bypass refs: ${bypassCount}`);

  if (dangerousFlags === 0 && bypassCount === 0) {
    return { status: STATUS.PASS, findings, evidence };
  }

  if (dangerousFlags >= 2 || bypassCount >= 2) {
    return { status: STATUS.FAIL, findings, evidence };
  }

  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 7: Human Review Requirements (ASI09)
 */
async function checkHumanReview(projectRoot) {
  const evidence = [];
  const findings = [];
  let commandsWithConfirmation = 0;
  let totalHighImpactCommands = 0;
  let claudeMdHitl = false;

  // Check CLAUDE.md for human-in-the-loop policy
  const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
  if (claudeMd) {
    if (/human[- ]in[- ]the[- ]loop|confirm|approval|ask.*user|user.*confirm/i.test(claudeMd)) {
      claudeMdHitl = true;
      evidence.push('CLAUDE.md has human-in-the-loop policy');
    }
  }

  // Check commands for confirmation gates
  const cmdFiles = await listMdFiles(join(projectRoot, 'commands'));
  for (const file of cmdFiles) {
    const content = await readText(join(projectRoot, 'commands', file));
    if (!content) continue;

    // Commands that involve write/delete/push are high-impact
    const isHighImpact = /\b(?:delete|remove|push|deploy|publish|force|drop|reset|clean)\b/i.test(content);
    if (isHighImpact) {
      totalHighImpactCommands++;
      if (/AskUserQuestion|confirm|approval|user.*confirm/i.test(content)) {
        commandsWithConfirmation++;
      }
    }
  }

  // Check agents for AskUserQuestion
  const agentFiles = await listMdFiles(join(projectRoot, 'agents'));
  let agentsWithAsk = 0;
  for (const file of agentFiles) {
    const content = await readText(join(projectRoot, 'agents', file));
    if (content && /AskUserQuestion/.test(content)) {
      agentsWithAsk++;
    }
  }

  evidence.push(`high-impact commands: ${totalHighImpactCommands}, with confirmation: ${commandsWithConfirmation}`);
  evidence.push(`agents with AskUserQuestion: ${agentsWithAsk}`);

  if (totalHighImpactCommands === 0) {
    // No high-impact commands found — PASS if CLAUDE.md has policy
    if (claudeMdHitl) return { status: STATUS.PASS, findings, evidence };
    return { status: STATUS.PARTIAL, findings, evidence };
  }

  if (commandsWithConfirmation >= totalHighImpactCommands && claudeMdHitl) {
    return { status: STATUS.PASS, findings, evidence };
  }

  if (commandsWithConfirmation > 0 || claudeMdHitl) {
    if (commandsWithConfirmation < totalHighImpactCommands) {
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.MEDIUM,
        title: 'Some high-impact commands lack confirmation gates',
        description: `${commandsWithConfirmation}/${totalHighImpactCommands} high-impact commands have confirmation steps.`,
        owasp: 'ASI09',
        recommendation: 'Add AskUserQuestion or explicit confirmation before destructive operations.',
      }));
    }
    return { status: STATUS.PARTIAL, findings, evidence };
  }

  findings.push(finding({
    scanner: 'PST',
    severity: SEVERITY.MEDIUM,
    title: 'No human review gates found',
    description: 'No confirmation steps found in commands and no HITL policy in CLAUDE.md.',
    owasp: 'ASI09',
    recommendation: 'Add confirmation gates for irreversible operations.',
  }));
  return { status: STATUS.FAIL, findings, evidence };
}

/**
 * Category 8: Skill and Plugin Sources (ASI04)
 */
async function checkPluginSources(projectRoot, globalSettings) {
  const evidence = [];
  const findings = [];

  // Check plugin.json
  const pluginJson = await readJson(join(projectRoot, '.claude-plugin', 'plugin.json'))
    || await readJson(join(projectRoot, 'plugin.json'));

  if (!pluginJson) {
    evidence.push('Not a plugin project (no plugin.json)');
    // Check if there are enabled plugins in global settings
    const enabledPlugins = globalSettings?.enabledPlugins || {};
    const pluginCount = Object.keys(enabledPlugins).length;
    if (pluginCount === 0) {
      return { status: STATUS.NA, findings, evidence };
    }
    evidence.push(`${pluginCount} enabled plugins in global settings`);
    return { status: STATUS.PASS, findings, evidence };
  }

  evidence.push(`plugin: ${pluginJson.name || 'unnamed'}`);

  // Check command tool permissions
  let overPermissioned = 0;
  const cmdFiles = await listMdFiles(join(projectRoot, 'commands'));
  for (const file of cmdFiles) {
    const content = await readText(join(projectRoot, 'commands', file));
    if (!content) continue;
    const fm = extractFrontmatter(content);

    // Flag commands with Bash + Write that don't seem to need it
    if (/allowed-tools:.*Bash/i.test(fm) && /allowed-tools:.*Write/i.test(fm)) {
      // Only flag if description suggests read-only
      if (/\b(?:scan|audit|assess|check|review|analyze|report|posture)\b/i.test(fm)) {
        overPermissioned++;
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.LOW,
          title: `Possibly over-permissioned command: ${file}`,
          description: `${file} appears to be a read/analysis command but has both Bash and Write tools.`,
          file: `commands/${file}`,
          owasp: 'ASI04',
          recommendation: 'Review if Bash and Write are both needed for this command.',
        }));
      }
    }
  }

  if (overPermissioned === 0) {
    return { status: STATUS.PASS, findings, evidence };
  }

  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 9: Session Isolation (ASI06, ASI08)
 */
async function checkSessionIsolation(projectRoot) {
  const evidence = [];
  const findings = [];
  let secretsInState = false;

  // Check state files for credentials
  const secretRe = /(?:sk-[a-zA-Z0-9]{20,}|Bearer\s+[a-zA-Z0-9._-]{20,}|password\s*=\s*["'][^"']+["']|(?:api[_-]?key|token)\s*=\s*["'][^"']+["'])/;

  const stateFiles = [
    'REMEMBER.md',
    ...await listMdFiles(join(projectRoot, 'memory')).then(f => f.map(n => `memory/${n}`)),
  ];

  for (const file of stateFiles) {
    const content = await readText(join(projectRoot, file));
    if (content && secretRe.test(content)) {
      secretsInState = true;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.CRITICAL,
        title: `Credentials found in state file: ${file}`,
        description: `${file} contains what appears to be credentials or API keys.`,
        file,
        owasp: 'ASI06',
        recommendation: 'Remove credentials from state files immediately.',
      }));
    }
  }

  // Check .gitignore for state files
  const gitignore = await readText(join(projectRoot, '.gitignore'));
  const stateGitignored = gitignore && /\*\.local\.md|REMEMBER\.md|memory\//i.test(gitignore);
  evidence.push(`state files gitignored: ${stateGitignored ? 'yes' : 'no'}`);

  if (!stateGitignored && stateFiles.length > 0) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.LOW,
      title: 'State files not covered by .gitignore',
      description: 'REMEMBER.md or memory/ files may not be gitignored.',
      file: '.gitignore',
      owasp: 'ASI08',
      recommendation: 'Add *.local.md, REMEMBER.md, and memory/ to .gitignore.',
    }));
  }

  if (secretsInState) return { status: STATUS.FAIL, findings, evidence };
  if (stateGitignored) return { status: STATUS.PASS, findings, evidence };
  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 10: Cognitive State Security (LLM01, ASI02)
 */
async function checkCognitiveStateSecurity(projectRoot) {
  const evidence = [];
  const findings = [];
  let injectionFound = false;
  let shellInMemory = false;
  let credPathInMemory = false;
  let permExpansionFound = false;
  let suspiciousUrlFound = false;

  // Files to scan
  const filesToScan = [];

  // CLAUDE.md
  if (await fileExists(join(projectRoot, 'CLAUDE.md'))) {
    filesToScan.push('CLAUDE.md');
  }
  if (await fileExists(join(projectRoot, 'REMEMBER.md'))) {
    filesToScan.push('REMEMBER.md');
  }

  // .claude/rules/*.md
  const rulesDir = join(projectRoot, '.claude', 'rules');
  const ruleFiles = await listMdFiles(rulesDir);
  for (const f of ruleFiles) filesToScan.push(`.claude/rules/${f}`);

  // memory/*.md
  const memoryFiles = await listMdFiles(join(projectRoot, 'memory'));
  for (const f of memoryFiles) filesToScan.push(`memory/${f}`);

  // *.local.md
  const rootFiles = await listDir(projectRoot);
  for (const f of rootFiles) {
    if (f.endsWith('.local.md')) filesToScan.push(f);
  }

  evidence.push(`cognitive state files: ${filesToScan.length}`);

  const shellCommandRe = /(?:^|[`'";\s|&])(?:curl|wget|bash|sh|eval|exec|chmod\s+[+0-7]|npm\s+install|pip3?\s+install)\b/i;
  const credPathRe = /(?:~\/|\/home\/|\/root\/)?\.(?:ssh|aws|gnupg|config\/gcloud)\/|(?:^|[\s/"'`])(?:id_rsa|id_ed25519|wallet\.dat|credentials\.json|\.env(?:\.\w+)?|\.netrc|kubeconfig)(?:[\s/"'`]|$)/i;
  const permExpansionRe = /(?:allowed-tools\s*[=:]\s*.*(?:Bash|Write|Edit|all)|bypassPermissions\s*[=:]\s*true|dangerouslySkipPermissions|--dangerously-skip-permissions|dangerouslyAllowArbitraryPaths\s*[=:]\s*true)/i;
  const suspiciousDomains = /(?:webhook\.site|requestbin\.com|pipedream\.net|ngrok\.io|ngrok\.app|pastebin\.com|transfer\.sh|file\.io|0x0\.st)/i;

  const isStrictFile = (f) => /^(?:memory\/|REMEMBER\.md|\.claude\/rules\/)/i.test(f);
  const isClaudeMd = (f) => /CLAUDE\.md$/i.test(f);

  for (const file of filesToScan) {
    const content = await readText(join(projectRoot, file));
    if (!content) continue;

    // 1. Injection patterns (reuse shared library)
    const scan = scanForInjection(content);
    if (scan.found) {
      injectionFound = true;
      for (const { label, severity } of scan.patterns) {
        findings.push(finding({
          scanner: 'PST',
          severity: severity === 'critical' ? SEVERITY.CRITICAL : severity === 'high' ? SEVERITY.HIGH : SEVERITY.MEDIUM,
          title: `Injection pattern in ${file}: ${label}`,
          description: `Cognitive state file "${file}" contains injection pattern: "${label}".`,
          file,
          owasp: 'LLM01',
          recommendation: 'Remove the injection pattern and review for unauthorized modifications.',
        }));
      }
    }

    // 2. Shell commands in strict files
    if (isStrictFile(file)) {
      const lines = content.split('\n');
      for (let i = 0; i < lines.length; i++) {
        if (shellCommandRe.test(lines[i])) {
          shellInMemory = true;
          findings.push(finding({
            scanner: 'PST',
            severity: SEVERITY.HIGH,
            title: `Shell command in memory/rules file: ${file}`,
            description: `${file} line ${i + 1} contains a shell command. Memory files should not have executable instructions.`,
            file,
            line: i + 1,
            evidence: lines[i].trim().slice(0, 80),
            owasp: 'LLM01',
            recommendation: 'Remove shell commands from memory files.',
          }));
        }
      }
    }

    // 3. Credential path references (skip code blocks in CLAUDE.md)
    const lines = content.split('\n');
    let inCodeBlock = false;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].trim().startsWith('```')) inCodeBlock = !inCodeBlock;
      if (inCodeBlock && isClaudeMd(file)) continue;

      if (credPathRe.test(lines[i]) && isStrictFile(file)) {
        credPathInMemory = true;
        findings.push(finding({
          scanner: 'PST',
          severity: SEVERITY.HIGH,
          title: `Credential path reference in ${file}`,
          description: `${file} line ${i + 1} references a credential path.`,
          file,
          line: i + 1,
          owasp: 'ASI02',
          recommendation: 'Remove credential path references from memory/rules files.',
        }));
      }
    }

    // 4. Permission expansion directives
    if (permExpansionRe.test(content) && (isStrictFile(file) || /\.local\.md$/.test(file))) {
      permExpansionFound = true;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.CRITICAL,
        title: `Permission expansion in ${file}`,
        description: `${file} contains permission expansion directives.`,
        file,
        owasp: 'ASI02',
        recommendation: 'Remove permission expansion from cognitive state files.',
      }));
    }

    // 5. Suspicious URLs
    if (suspiciousDomains.test(content)) {
      suspiciousUrlFound = true;
      findings.push(finding({
        scanner: 'PST',
        severity: SEVERITY.HIGH,
        title: `Suspicious URL in ${file}`,
        description: `${file} references a known exfiltration/webhook domain.`,
        file,
        owasp: 'LLM01',
        recommendation: 'Remove suspicious URLs from cognitive state files.',
      }));
    }
  }

  if (injectionFound || permExpansionFound) return { status: STATUS.FAIL, findings, evidence };
  if (shellInMemory || credPathInMemory || suspiciousUrlFound) return { status: STATUS.PARTIAL, findings, evidence };
  if (findings.length === 0) return { status: STATUS.PASS, findings, evidence };
  return { status: STATUS.PARTIAL, findings, evidence };
}


/**
 * Category 11: Prompt Injection Hardening (LLM01, ASI01)
 * v5.0: Checks MEDIUM advisory support, Unicode Tag detection, bash expansion normalization.
 */
async function checkPromptInjectionHardening(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hasMediumAdvisory = false;
  let hasUnicodeTagDetection = false;
  let hasBashNormalization = false;

  // Check hooks.json for pre-prompt-inject-scan in UserPromptSubmit
  if (hooksJson) {
    const userPromptSubmit = hooksJson.hooks?.UserPromptSubmit || hooksJson.UserPromptSubmit || [];
    const entries = Array.isArray(userPromptSubmit) ? userPromptSubmit : [];
    for (const entry of entries) {
      const hooks = entry.hooks || [];
      for (const h of hooks) {
        if (h.command && /prompt.?inject/i.test(h.command)) {
          hookActive = true;
          evidence.push(`Hook registered: ${h.command}`);
        }
      }
    }
  }

  if (hookActive) {
    // Check script for MEDIUM advisory support
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-prompt-inject-scan.mjs');
    const content = await readText(scriptPath);
    if (content) {
      if (/medium/i.test(content)) {
        hasMediumAdvisory = true;
        evidence.push('MEDIUM advisory support: yes');
      }
      if (/unicode.?tag|U\+E00|decodeUnicodeTags/i.test(content)) {
        hasUnicodeTagDetection = true;
        evidence.push('Unicode Tag detection: yes');
      }
    }
  }

  // Check bash-destructive for bash expansion normalization
  const bashScriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-bash-destructive.mjs');
  const bashContent = await readText(bashScriptPath);
  if (bashContent && /normalizeBash|bash.?normalize/i.test(bashContent)) {
    hasBashNormalization = true;
    evidence.push('Bash expansion normalization: yes');
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No prompt injection scanning hook',
      description: 'No pre-prompt-inject-scan hook found in UserPromptSubmit. User input is not scanned for injection patterns.',
      owasp: 'LLM01',
      recommendation: 'Register pre-prompt-inject-scan.mjs under UserPromptSubmit in hooks.json.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  const features = [hasMediumAdvisory, hasUnicodeTagDetection, hasBashNormalization].filter(Boolean).length;
  if (features === 3) return { status: STATUS.PASS, findings, evidence };

  if (!hasMediumAdvisory) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.MEDIUM,
      title: 'Prompt injection hook lacks MEDIUM advisory support',
      description: 'pre-prompt-inject-scan does not emit advisories for MEDIUM-severity obfuscation patterns (leetspeak, homoglyphs, zero-width).',
      file: 'hooks/scripts/pre-prompt-inject-scan.mjs',
      owasp: 'LLM01',
      recommendation: 'Upgrade to v5.0 hook with MEDIUM advisory support.',
    }));
  }
  if (!hasUnicodeTagDetection) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.MEDIUM,
      title: 'No Unicode Tag steganography detection',
      description: 'Prompt injection hook does not detect Unicode Tags (U+E0000-E007F) steganography.',
      file: 'hooks/scripts/pre-prompt-inject-scan.mjs',
      owasp: 'LLM01',
      recommendation: 'Add Unicode Tag decoding to the injection scanning pipeline.',
    }));
  }
  if (!hasBashNormalization) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.MEDIUM,
      title: 'Bash destructive hook lacks expansion normalization',
      description: 'pre-bash-destructive does not normalize bash parameter expansion evasion.',
      file: 'hooks/scripts/pre-bash-destructive.mjs',
      owasp: 'ASI01',
      recommendation: 'Import and apply normalizeBashExpansion() before command matching.',
    }));
  }

  if (features >= 1) return { status: STATUS.PARTIAL, findings, evidence };
  return { status: STATUS.FAIL, findings, evidence };
}

/**
 * Category 12: Rule of Two (ASI02, ASI05)
 * v5.0: Checks that the trifecta (untrusted input + sensitive data + state change)
 * is monitored with configurable enforcement mode.
 */
async function checkRuleOfTwo(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hasTrifectaMode = false;

  // Check hooks.json for post-session-guard in PostToolUse
  if (hooksJson) {
    const postToolUse = hooksJson.hooks?.PostToolUse || hooksJson.PostToolUse || [];
    const entries = Array.isArray(postToolUse) ? postToolUse : [];
    for (const entry of entries) {
      const hooks = entry.hooks || [];
      for (const h of hooks) {
        if (h.command && /session.?guard/i.test(h.command)) {
          hookActive = true;
          evidence.push(`Hook registered: ${h.command}`);
        }
      }
    }
  }

  if (hookActive) {
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'post-session-guard.mjs');
    const content = await readText(scriptPath);
    if (content && /TRIFECTA_MODE/i.test(content)) {
      hasTrifectaMode = true;
      evidence.push('TRIFECTA_MODE configurable: yes');
    }
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No Rule of Two enforcement hook',
      description: 'No post-session-guard hook found. The lethal trifecta (untrusted input + sensitive data + state change) is not monitored.',
      owasp: 'ASI02',
      recommendation: 'Register post-session-guard.mjs under PostToolUse in hooks.json.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hasTrifectaMode) return { status: STATUS.PASS, findings, evidence };

  findings.push(finding({
    scanner: 'PST',
    severity: SEVERITY.MEDIUM,
    title: 'Session guard lacks configurable trifecta mode',
    description: 'post-session-guard does not support LLM_SECURITY_TRIFECTA_MODE (block/warn/off).',
    file: 'hooks/scripts/post-session-guard.mjs',
    owasp: 'ASI02',
    recommendation: 'Upgrade to v5.0 session guard with configurable enforcement mode.',
  }));
  return { status: STATUS.PARTIAL, findings, evidence };
}

/**
 * Category 13: Long-Horizon Monitoring (ASI06, ASI08)
 * v5.0: Checks that the session guard monitors a 100-call window alongside
 * the standard 20-call window for slow-burn attack detection.
 */
async function checkLongHorizonMonitoring(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let hookActive = false;
  let hasLongHorizon = false;
  let hasBehavioralDrift = false;

  // Check hooks.json for post-session-guard in PostToolUse
  if (hooksJson) {
    const postToolUse = hooksJson.hooks?.PostToolUse || hooksJson.PostToolUse || [];
    const entries = Array.isArray(postToolUse) ? postToolUse : [];
    for (const entry of entries) {
      const hooks = entry.hooks || [];
      for (const h of hooks) {
        if (h.command && /session.?guard/i.test(h.command)) {
          hookActive = true;
        }
      }
    }
  }

  if (hookActive) {
    const scriptPath = join(projectRoot, 'hooks', 'scripts', 'post-session-guard.mjs');
    const content = await readText(scriptPath);
    if (content) {
      if (/LONG_HORIZON|long.?horizon|100.?call/i.test(content)) {
        hasLongHorizon = true;
        evidence.push('Long-horizon window (100-call): yes');
      }
      if (/behavioral.?drift|jensen.?shannon|divergence/i.test(content)) {
        hasBehavioralDrift = true;
        evidence.push('Behavioral drift detection: yes');
      }
    }
  }

  if (!hookActive) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.HIGH,
      title: 'No long-horizon session monitoring',
      description: 'No post-session-guard hook found. Long-horizon attacks (slow-burn trifecta over 50+ calls) are not detected.',
      owasp: 'ASI06',
      recommendation: 'Register post-session-guard.mjs under PostToolUse in hooks.json.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (hasLongHorizon && hasBehavioralDrift) return { status: STATUS.PASS, findings, evidence };

  if (!hasLongHorizon) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.MEDIUM,
      title: 'Session guard lacks long-horizon window',
      description: 'post-session-guard does not implement a 100-call monitoring window. Slow-burn attacks may evade the standard 20-call window.',
      file: 'hooks/scripts/post-session-guard.mjs',
      owasp: 'ASI06',
      recommendation: 'Upgrade to v5.0 session guard with 100-call long-horizon window.',
    }));
  }

  if (!hasBehavioralDrift) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.LOW,
      title: 'No behavioral drift detection',
      description: 'post-session-guard does not track tool-class distribution changes (Jensen-Shannon divergence) for behavioral drift.',
      file: 'hooks/scripts/post-session-guard.mjs',
      owasp: 'ASI08',
      recommendation: 'Add behavioral drift detection using Jensen-Shannon divergence on tool-class distribution.',
    }));
  }

  if (hasLongHorizon || hasBehavioralDrift) return { status: STATUS.PARTIAL, findings, evidence };
  return { status: STATUS.FAIL, findings, evidence };
}

// ---------------------------------------------------------------------------
// Category 14: EU AI Act Compliance (Governance)
// Checks for evidence that supports EU AI Act requirements:
//   Art. 9 — risk management system
//   Art. 14 — human oversight
//   Art. 15 — accuracy, robustness, cybersecurity
//   Art. 17 — quality management system
// ---------------------------------------------------------------------------

async function checkEUAIActCompliance(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let score = 0;
  const maxScore = 4;

  // Art. 9: Risk management — look for structured risk/security documentation
  const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
  const hasRiskDoc = claudeMd && claudeMd.length > 100 &&
    /security.*boundar|risk.*manage|threat.*model|security.*polic/i.test(claudeMd);
  if (hasRiskDoc) {
    score++;
    evidence.push('Art. 9: risk management documentation found in CLAUDE.md');
  }

  // Art. 14: Human oversight — hooks or CLAUDE.md mention human-in-the-loop
  const hasHitl = claudeMd && /human[- ](?:in[- ]the[- ]loop|oversight|review|confirm)|AskUserQuestion/i.test(claudeMd);
  const hasHumanHooks = hooksJson && JSON.stringify(hooksJson).includes('UserPromptSubmit');
  if (hasHitl || hasHumanHooks) {
    score++;
    evidence.push('Art. 14: human oversight mechanism present');
  }

  // Art. 15: Robustness/cybersecurity — security hooks registered
  const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
  if (hookCount >= 2) {
    score++;
    evidence.push(`Art. 15: ${hookCount} hook event types registered for robustness`);
  }

  // Art. 17: Quality management — test suite or scan reports exist
  const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
  const hasReports = await fileExists(join(projectRoot, 'reports'));
  if (hasTests || hasReports) {
    score++;
    evidence.push('Art. 17: quality management evidence (tests or reports)');
  }

  if (score === 0) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.INFO,
      title: 'No EU AI Act compliance evidence',
      description: 'No risk management, human oversight, robustness hooks, or quality management evidence found.',
      owasp: 'Governance',
      recommendation: 'Add security documentation to CLAUDE.md, register security hooks, and maintain test suites.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (score >= maxScore) return { status: STATUS.PASS, findings, evidence };
  return { status: STATUS.PARTIAL, findings, evidence };
}

// ---------------------------------------------------------------------------
// Category 15: NIST AI RMF Alignment (Governance)
// Maps to four NIST AI RMF functions:
//   Govern — governance controls (deny-first config, policies)
//   Map — risk mapping documentation (threat models, risk assessments)
//   Measure — measurement tooling (scanners, posture assessment)
//   Manage — risk management actions (hooks, remediation capabilities)
// ---------------------------------------------------------------------------

async function checkNISTAlignment(projectRoot, hooksJson, projectSettings) {
  const evidence = [];
  const findings = [];
  let functionsPresent = 0;
  const totalFunctions = 4;

  // Govern: deny-first configuration or policy documentation
  const settingsJson = projectSettings || await readJson(join(projectRoot, '.claude', 'settings.json'));
  const hasDenyFirst = settingsJson?.permissions?.defaultPermissionLevel === 'deny';
  const hasPolicyFile = await fileExists(join(projectRoot, '.llm-security', 'policy.json'));
  if (hasDenyFirst || hasPolicyFile) {
    functionsPresent++;
    evidence.push('Govern: deny-first config or policy file present');
  }

  // Map: risk documentation (threat-model reports, CLAUDE.md with threat/risk mentions)
  const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
  const hasRiskDoc = claudeMd && /threat|risk.*assess|security.*boundar/i.test(claudeMd);
  const hasReports = await fileExists(join(projectRoot, 'reports'));
  if (hasRiskDoc || hasReports) {
    functionsPresent++;
    evidence.push('Map: risk documentation or reports present');
  }

  // Measure: measurement tooling (scanners, tests)
  const hasScanners = await fileExists(join(projectRoot, 'scanners'));
  const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
  if (hasScanners || hasTests) {
    functionsPresent++;
    evidence.push('Measure: measurement tooling present (scanners or tests)');
  }

  // Manage: risk management actions (hooks registered)
  const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
  if (hookCount >= 2) {
    functionsPresent++;
    evidence.push(`Manage: ${hookCount} hook event types for active risk management`);
  }

  if (functionsPresent === 0) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.INFO,
      title: 'No NIST AI RMF alignment evidence',
      description: 'No evidence for any of the four NIST AI RMF functions: Govern, Map, Measure, Manage.',
      owasp: 'Governance',
      recommendation: 'Implement deny-first permissions (Govern), add risk documentation (Map), enable scanners (Measure), register hooks (Manage).',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (functionsPresent >= totalFunctions) return { status: STATUS.PASS, findings, evidence };
  return { status: STATUS.PARTIAL, findings, evidence };
}

// ---------------------------------------------------------------------------
// Category 16: ISO 42001 Readiness (Governance)
// ISO/IEC 42001:2023 AI Management System indicators:
//   Cl. 6 — planning and risk assessment
//   Cl. 8 — operational controls
//   Cl. 9 — performance evaluation and monitoring
//   Cl. 10 — continual improvement
// ---------------------------------------------------------------------------

async function checkISO42001Readiness(projectRoot, hooksJson) {
  const evidence = [];
  const findings = [];
  let indicators = 0;
  const totalIndicators = 4;

  // Cl. 6: Planning and risk — documented processes (CLAUDE.md with structure)
  const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
  if (claudeMd && claudeMd.length > 100) {
    indicators++;
    evidence.push('Cl. 6: documented AI management processes in CLAUDE.md');
  }

  // Cl. 8: Operational controls — hooks and settings providing runtime controls
  const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
  if (hookCount >= 2) {
    indicators++;
    evidence.push(`Cl. 8: ${hookCount} operational control hook event types`);
  }

  // Cl. 9: Performance evaluation — monitoring and measurement capabilities
  const hasReports = await fileExists(join(projectRoot, 'reports'));
  const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
  if (hasReports || hasTests) {
    indicators++;
    evidence.push('Cl. 9: performance evaluation evidence (reports or tests)');
  }

  // Cl. 10: Continual improvement — baseline diff capability, scan history
  const hasBaselines = await fileExists(join(projectRoot, 'reports', 'baselines'));
  const hasChangelog = await fileExists(join(projectRoot, 'CHANGELOG.md'));
  if (hasBaselines || hasChangelog) {
    indicators++;
    evidence.push('Cl. 10: continual improvement evidence (baselines or changelog)');
  }

  if (indicators === 0) {
    findings.push(finding({
      scanner: 'PST',
      severity: SEVERITY.INFO,
      title: 'No ISO 42001 readiness evidence',
      description: 'No evidence for ISO/IEC 42001 AI management system requirements.',
      owasp: 'Governance',
      recommendation: 'Document AI processes in CLAUDE.md, register operational hooks, maintain reports, track improvement via baselines.',
    }));
    return { status: STATUS.FAIL, findings, evidence };
  }

  if (indicators >= totalIndicators) return { status: STATUS.PASS, findings, evidence };
  return { status: STATUS.PARTIAL, findings, evidence };
}

// ---------------------------------------------------------------------------
// Main scan function
// ---------------------------------------------------------------------------

/**
 * Run deterministic posture assessment on a project.
 * @param {string} targetPath - Absolute path to project root
 * @returns {Promise<object>} - Full posture result with categories, grade, and findings
 */
export async function scan(targetPath) {
  const startMs = Date.now();
  resetCounter();

  const projectRoot = resolve(targetPath);

  // Load shared config files once
  const globalSettingsPath = join(homedir(), '.claude', 'settings.json');
  const projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
  const hooksJsonPath = join(projectRoot, 'hooks', 'hooks.json');

  const globalSettings = await readJson(globalSettingsPath);
  const projectSettings = await readJson(projectSettingsPath);
  const hooksJson = await readJson(hooksJsonPath);

  // Run all 16 category checks (13 security + 3 compliance)
  const results = [];
  results.push({ ...CATEGORIES[0], ...(await checkDenyFirst(projectRoot, globalSettings, projectSettings)) });
  results.push({ ...CATEGORIES[1], ...(await checkSecretsProtection(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[2], ...(await checkPathGuarding(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[3], ...(await checkMcpTrust(projectRoot, globalSettings, projectSettings)) });
  results.push({ ...CATEGORIES[4], ...(await checkDestructiveBlocking(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[5], ...(await checkSandboxConfig(projectRoot, globalSettings, projectSettings)) });
  results.push({ ...CATEGORIES[6], ...(await checkHumanReview(projectRoot)) });
  results.push({ ...CATEGORIES[7], ...(await checkPluginSources(projectRoot, globalSettings)) });
  results.push({ ...CATEGORIES[8], ...(await checkSessionIsolation(projectRoot)) });
  results.push({ ...CATEGORIES[9], ...(await checkCognitiveStateSecurity(projectRoot)) });
  results.push({ ...CATEGORIES[10], ...(await checkPromptInjectionHardening(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[11], ...(await checkRuleOfTwo(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[12], ...(await checkLongHorizonMonitoring(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[13], ...(await checkEUAIActCompliance(projectRoot, hooksJson)) });
  results.push({ ...CATEGORIES[14], ...(await checkNISTAlignment(projectRoot, hooksJson, projectSettings)) });
  results.push({ ...CATEGORIES[15], ...(await checkISO42001Readiness(projectRoot, hooksJson)) });

  // Compute grade
  const applicable = results.filter(r => r.status !== STATUS.NA);
  const passCount = results.filter(r => r.status === STATUS.PASS).length;
  const partialCount = results.filter(r => r.status === STATUS.PARTIAL).length;
  const failCount = results.filter(r => r.status === STATUS.FAIL).length;
  const naCount = results.filter(r => r.status === STATUS.NA).length;
  const score = passCount + (partialCount * 0.5);
  const passRate = applicable.length > 0 ? score / applicable.length : 0;

  // Count fails in critical categories (1, 2, 5)
  const failsInCritCats = results.filter(r => CRITICAL_CATEGORIES.has(r.id) && r.status === STATUS.FAIL).length;

  // Collect all findings and count by severity
  const allFindings = results.flatMap(r => r.findings);
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
  for (const f of allFindings) {
    if (counts[f.severity] !== undefined) counts[f.severity]++;
  }

  const grade = gradeFromPassRate(passRate, failsInCritCats, counts.critical);

  // Risk score (delegated to severity.mjs — single source of truth, v7.0.0+)
  const riskScoreValue = riskScore(counts);
  const riskBandValue = riskBand(riskScoreValue);
  const verdictValue = verdict(counts);

  const durationMs = Date.now() - startMs;

  return {
    scanner: 'posture-scanner',
    version: VERSION,
    status: 'ok',
    target: projectRoot,
    timestamp: new Date().toISOString(),
    duration_ms: durationMs,
    categories: results.map(r => ({
      id: r.id,
      name: r.name,
      owasp: r.owasp,
      status: r.status,
      findings_count: r.findings.length,
      evidence: r.evidence,
    })),
    findings: allFindings,
    counts,
    scoring: {
      pass: passCount,
      partial: partialCount,
      fail: failCount,
      na: naCount,
      applicable: applicable.length,
      score,
      pass_rate: Math.round(passRate * 100) / 100,
      grade,
    },
    risk: {
      score: riskScoreValue,
      band: riskBandValue,
      verdict: verdictValue,
    },
  };
}

// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------

const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));

import { fileURLToPath } from 'node:url';

if (isMain) {
  const target = process.argv[2] || process.cwd();
  const absTarget = resolve(target);

  try {
    const result = await scan(absTarget);
    process.stdout.write(JSON.stringify(result, null, 2) + '\n');
    process.exit(result.scoring.grade === 'F' ? 1 : 0);
  } catch (err) {
    process.stderr.write(`Error: ${err.message}\n`);
    process.exit(2);
  }
}
