/**
 * `ai-governance scan` subcommand — A-2.
 *
 * Runs the OWASP Agentic Top 10 scanner locally on the customer's machine.
 * Source code NEVER leaves the customer's box; only the structured
 * ScanReport + AgentManifest is emitted to stdout (and optionally POSTed
 * to the gov-server's push-receive endpoint).
 *
 * Replaces the legacy W-03 server-side Git-clone flow that violated
 * Locked Invariant 10 (push-receive only — we never call out to customer
 * infrastructure).
 *
 * Usage:
 *   ai-governance scan <dir>                      Print findings to stdout
 *   ai-governance scan <dir> --json               Emit machine-readable JSON
 *   ai-governance scan <dir> --upload             POST results to gov-server
 *   ai-governance scan <dir> --upload --endpoint <url>  Override gov-server URL
 *   ai-governance scan <dir> --agent-draft-id <uuid>    Pre-allocated draft ID
 *
 * Exit codes:
 *   0  scan ran successfully (findings reported but exit is still 0)
 *   1  scan ran, upload failed, or a fatal error occurred
 *   2  argument error
 *
 * Honors Locked Invariant 10: the scanner is a pure function on the
 * customer's manifest; the optional upload is a push-only fire-and-forget
 * call from the customer's box to /api/v1/onboarding/scan-results.
 *
 * @connexum/ai-governance  Phase A-2
 */
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { scanAgentDirectory } from './agent-dir-scanner.js';
import { ScanBudget } from './scan-limits.js';
import { scanAgent, analyzeManifestRisks, } from '../governance/owasp-agentic-scanner.js';
import { runRemediations } from './remediation/run-remediations.js';
import { formatOwaspScanOutput } from './formatters/owasp.js';
function parseScanArgs(args) {
    const out = {
        dir: '',
        json: false,
        upload: false,
        help: false,
        fix: false,
        apply: false,
        yes: false,
        licenseState: 'not-purchased',
    };
    for (let i = 0; i < args.length; i++) {
        const a = args[i];
        if (a === '--json')
            out.json = true;
        else if (a === '--upload')
            out.upload = true;
        else if (a === '--endpoint')
            out.endpoint = args[++i];
        else if (a === '--agent-draft-id')
            out.agentDraftId = args[++i];
        else if (a === '--help' || a === '-h')
            out.help = true;
        else if (a === '--fix')
            out.fix = true;
        else if (a === '--apply') {
            out.fix = true;
            out.apply = true;
        }
        else if (a === '--yes')
            out.yes = true;
        else if (a === '--license-state') {
            const val = args[++i];
            if (val !== 'not-purchased' && val !== 'purchased-not-active' && val !== 'active') {
                throw new Error(`--license-state must be one of: not-purchased | purchased-not-active | active`);
            }
            out.licenseState = val;
        }
        else if (a.startsWith('--')) {
            throw new Error(`unknown flag '${a}'`);
        }
        else if (!out.dir) {
            out.dir = a;
        }
    }
    return out;
}
const HELP_TEXT = `
ai-governance scan — locally scan a directory for AI agents and emit structured findings

Usage:
  ai-governance scan <dir> [--json] [--upload] [--endpoint <url>] [--agent-draft-id <uuid>]
  ai-governance scan <dir> --fix [--apply] [--yes]

Description:
  Walks <dir> for agent definitions (Python + TypeScript + Claude Code .md),
  runs the OWASP Agentic Top 10 scanner against each detected agent's
  manifest, and emits structured JSON. The source code NEVER leaves your
  machine — only the structured findings + manifest summary do.

  With --upload, the structured payload is POSTed to the gov-server's
  push-receive endpoint (Locked Invariant 10 compliant — we receive what
  you push; we never call out to your infrastructure).

  With --fix, the scanner also lists available pack-as-remediation actions
  for detected findings. This is a DRY-RUN by default — no files are written.
  Pass --apply to actually write changes, or --yes to skip confirmation.

Flags:
  --json                    Emit machine-readable JSON (default when piped)
  --upload                  POST results to /api/v1/onboarding/scan-results
  --endpoint <url>          Override the gov-server endpoint (default: from
                            .governance.json runtime.govServerUrl)
  --agent-draft-id <uuid>   Attach the scan to a pre-allocated draft ID
                            (default: a stable hash of the scanned dir)
  --fix                     List available remediations (dry-run, no writes)
  --apply                   Write remediations to disk (implies --fix)
  --yes                     Skip confirmation prompt when applying
  --license-state <state>   Context for remediation lines in human output.
                            Values: not-purchased | purchased-not-active | active
                            Default: not-purchased
  -h, --help                Show this help

Exit codes:
  0  scan completed (findings printed; exit is 0 even if findings exist)
  1  scan ran but upload failed, or a fatal error occurred
  2  argument error

Locked Invariant 10: this command runs LOCALLY. Source code stays here.
`;
// ---------------------------------------------------------------------------
// Manifest builder
// ---------------------------------------------------------------------------
/**
 * Build a structured AgentManifest from a DetectedAgent + the agent's file
 * contents (read locally, never shipped). This is the contract surface for
 * the scanner — small, structural, no source text.
 */
function buildManifest(detected, fileText) {
    const tools = extractTools(fileText);
    const mcpServers = extractMcpServers(fileText);
    return {
        agentId: detected.agentId,
        // Manifest-declared agents carry an explicit name (fleet convention);
        // code-signature detections fall back to the file basename.
        name: detected.name ?? path.basename(detected.filePath, path.extname(detected.filePath)),
        tools,
        mcpServers,
        llmProvider: detected.provider ?? undefined,
        hooks: {
            preToolUse: false,
            postToolUse: false,
            postResponse: false,
        },
        declaredApprovalGates: [],
        declaredForbiddenCapabilities: [],
        pluginIntegrityVerified: false,
        rateLimitsConfigured: false,
    };
}
/**
 * Extract the declared `tools:` list from an agent file. Supports both the
 * Claude-Code frontmatter format (`tools: Bash, Read, Write`) and Python /
 * TS source-level patterns. Returns a unique, lower-cased list.
 *
 * This is a LOCAL helper — the extracted strings are bounded names like
 * "Bash" / "Read" / "Write" and never include source text.
 */
function extractTools(text) {
    const tools = new Set();
    // Claude Code frontmatter style
    const fmMatch = text.match(/^tools:\s*(.+)$/m);
    if (fmMatch) {
        fmMatch[1].split(',').forEach((t) => {
            const trimmed = t.trim();
            if (trimmed)
                tools.add(trimmed);
        });
    }
    // Python / TS framework declarations — first arg of tool() / @tool
    const toolDecls = text.matchAll(/(?:tool|register_tool|tools\.add)\(['"]([^'"]+)['"]/g);
    for (const m of toolDecls) {
        if (m[1])
            tools.add(m[1]);
    }
    return Array.from(tools);
}
function extractMcpServers(text) {
    const servers = new Set();
    const matches = text.matchAll(/mcp__([a-zA-Z0-9_-]+)/g);
    for (const m of matches) {
        if (m[1])
            servers.add(m[1]);
    }
    return Array.from(servers);
}
/**
 * POST one ScanReport to the gov-server's push-receive endpoint.
 *
 * Uses curl via spawnSync so the CLI keeps zero runtime HTTP deps and
 * stays portable to old Node versions.
 *
 * Locked Invariant 10: this is a customer → platform push. It is the
 * REVERSE direction of the deprecated W-03 server-side clone.
 */
function uploadScanResult(opts) {
    const body = JSON.stringify({
        agentDraftId: opts.agentDraftId,
        scanReport: opts.scanReport,
        agentManifest: opts.agentManifest,
    });
    const result = spawnSync('curl', [
        '--silent', '--show-error',
        '--max-time', '15',
        '--connect-timeout', '5',
        '-X', 'POST',
        `${opts.endpoint.replace(/\/$/, '')}/api/v1/onboarding/scan-results`,
        '-H', `Authorization: Bearer ${opts.serviceToken}`,
        '-H', 'Content-Type: application/json',
        '-w', '\n%{http_code}',
        '--data', body,
    ], { encoding: 'utf-8' });
    if (result.status !== 0 || !result.stdout) {
        return { ok: false, error: result.stderr?.trim() || 'no stdout from curl' };
    }
    // Trailing line is the http code; preceding text is the JSON body.
    const lines = result.stdout.trim().split('\n');
    const statusLine = lines[lines.length - 1];
    const status = parseInt(statusLine, 10);
    const bodyText = lines.slice(0, -1).join('\n');
    let parsed = {};
    try {
        parsed = JSON.parse(bodyText);
    }
    catch {
        // ignore — server may have returned an empty body on 5xx
    }
    if (status >= 200 && status < 300) {
        return { ok: true, status, scanResultId: parsed.scanResultId };
    }
    return {
        ok: false,
        status,
        error: parsed.detail ?? parsed.error ?? `HTTP ${status}`,
    };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export async function runScanCommand(args) {
    let parsed;
    try {
        parsed = parseScanArgs(args);
    }
    catch (e) {
        process.stderr.write(`scan: ${e.message}\n`);
        process.stderr.write(HELP_TEXT);
        return 2;
    }
    if (parsed.help || !parsed.dir) {
        process.stdout.write(HELP_TEXT);
        return parsed.help ? 0 : 2;
    }
    const dir = path.resolve(process.cwd(), parsed.dir);
    if (!fs.existsSync(dir)) {
        process.stderr.write(`scan: directory not found: ${dir}\n`);
        return 1;
    }
    const stat = fs.statSync(dir);
    if (!stat.isDirectory()) {
        process.stderr.write(`scan: not a directory: ${dir}\n`);
        return 1;
    }
    // Walk the directory for agent files under one shared resource budget
    // (file count / depth / per-file size / wall-clock). The same budget covers
    // the language scan and the markdown fallback so a customer can never have
    // `scan` run away on a pathological tree (Invariants 2 + 10).
    const budget = new ScanBudget();
    const detected = scanAgentDirectory(dir, budget);
    // For each detected agent, read its file, build a structured manifest,
    // and run the OWASP scanner. The raw file text is used LOCALLY to build
    // the manifest — never included in the output.
    const scans = [];
    for (const agent of detected) {
        const absPath = path.join(dir, agent.filePath);
        let text = '';
        try {
            text = fs.readFileSync(absPath, 'utf-8');
        }
        catch {
            // Skip unreadable files but keep going.
            continue;
        }
        const manifest = buildManifest(agent, text);
        const report = scanAgent(manifest);
        // Bug 2: when the agent came from a declarative manifest.json, also surface
        // the risks the manifest declares about itself (unrestricted tools, disabled
        // audit logging, wildcard egress, PHI handling) — the code-based A01..A10
        // analyzers can't see these. Append to the same findings list so they flow
        // to the onboarding triage page (the "issues / missing items" prompt).
        if (agent.fromManifest) {
            try {
                report.findings.push(...analyzeManifestRisks(JSON.parse(text)));
            }
            catch {
                // malformed manifest — never block the scan (Invariant 10)
            }
        }
        scans.push({
            agentId: agent.agentId,
            filePath: agent.filePath,
            agentManifest: manifest,
            scanReport: report,
        });
    }
    // Even if zero agents are detected we still surface a (empty) structured
    // report so the wizard's call site can present "0 agents found" cleanly.
    // For Claude Code .md files that the language-aware scanner skips, we
    // try a frontmatter-based fallback: any *.md in the tree that declares
    // `tools:` in frontmatter is treated as an agent.
    if (scans.length === 0) {
        const fallback = scanMarkdownAgents(dir, budget);
        for (const item of fallback) {
            const manifest = buildManifest(item.detected, item.text);
            const report = scanAgent(manifest);
            scans.push({
                agentId: item.detected.agentId,
                filePath: item.detected.filePath,
                agentManifest: manifest,
                scanReport: report,
            });
        }
    }
    // Summarise.
    const severityCounts = { LOW: 0, MED: 0, HIGH: 0, CRIT: 0 };
    let totalFindings = 0;
    for (const s of scans) {
        for (const f of s.scanReport.findings) {
            totalFindings++;
            severityCounts[f.severity] = (severityCounts[f.severity] ?? 0) + 1;
        }
    }
    const output = {
        schemaVersion: 'a2-scan-output-v1',
        scannedAt: new Date().toISOString(),
        scannedDir: dir,
        scans,
        summary: {
            agentsScanned: scans.length,
            totalFindings,
            severityCounts,
            limits: budget.report(),
        },
    };
    // No silent truncation: if any resource cap tripped, tell the operator on
    // stderr (stdout stays clean JSON for the wizard / CI consumer).
    const lim = output.summary.limits;
    if (lim.capped) {
        const reasons = [];
        if (lim.truncatedByTimeBudget)
            reasons.push(`time budget ${lim.limits.timeBudgetMs}ms exhausted`);
        if (lim.truncatedByFileCap)
            reasons.push(`file cap ${lim.limits.maxFiles} reached`);
        if (lim.truncatedByDepthCap)
            reasons.push(`depth cap ${lim.limits.maxDepth} reached`);
        if (lim.filesSkippedOversize > 0)
            reasons.push(`${lim.filesSkippedOversize} file(s) over ${lim.limits.maxFileBytes} bytes skipped`);
        process.stderr.write(`scan: partial result — ${reasons.join('; ')}. ` +
            `Scanned ${lim.filesVisited} file(s). Narrow the target directory or raise ` +
            `CXNI_SCAN_* limits to cover the whole tree.\n`);
    }
    // Emit.
    // --json:  machine-readable JSON (full ScanOutput shape — for CI / dashboard)
    // default: human-readable OWASP Agentic Top 10 per-agent output (OWASP-B3)
    if (parsed.json) {
        process.stdout.write(JSON.stringify(output, null, 2) + '\n');
    }
    else {
        for (const s of scans) {
            const agentName = path.basename(s.filePath, path.extname(s.filePath));
            const humanOutput = formatOwaspScanOutput(agentName, s.scanReport, {
                licenseState: parsed.licenseState,
                json: false,
            });
            process.stdout.write(humanOutput + '\n');
        }
        if (scans.length === 0) {
            process.stdout.write('No agents found in the scanned directory.\n');
        }
    }
    // Optional upload.
    if (parsed.upload) {
        const cfg = readGovernanceConfig(process.cwd());
        const endpoint = parsed.endpoint ?? cfg?.govServerUrl ?? '';
        const serviceToken = cfg?.serviceToken ?? '';
        if (!endpoint) {
            process.stderr.write('scan: --upload requires .governance.json runtime.govServerUrl or --endpoint <url>\n');
            return 1;
        }
        if (!serviceToken) {
            process.stderr.write('scan: --upload requires .governance.json runtime.serviceToken (re-run `ai-governance init --vendor-code <code>`)\n');
            return 1;
        }
        let anyFail = false;
        for (const s of scans) {
            const agentDraftId = parsed.agentDraftId ?? s.agentId;
            const up = uploadScanResult({
                endpoint,
                serviceToken,
                agentDraftId,
                scanReport: s.scanReport,
                agentManifest: s.agentManifest,
            });
            if (!up.ok) {
                process.stderr.write(`scan: upload failed for ${s.agentId}: ${up.error ?? `HTTP ${up.status}`}\n`);
                anyFail = true;
            }
            else {
                process.stderr.write(`scan: uploaded ${s.agentId} → scanResultId=${up.scanResultId ?? '?'}\n`);
            }
        }
        if (anyFail)
            return 1;
    }
    // --fix: list available remediations (dry-run) or apply them (--apply).
    if (parsed.fix) {
        await runFixMode(dir, scans, parsed.apply, parsed.yes);
    }
    return 0;
}
// ---------------------------------------------------------------------------
// Markdown frontmatter fallback (Claude Code agents)
// ---------------------------------------------------------------------------
/**
 * The Python/TS signature scanner doesn't fire on .md frontmatter, but
 * Claude Code agents are exactly that. Walk the tree once for *.md files
 * that declare `tools:` in frontmatter and synthesise a DetectedAgent
 * record for each.
 */
function scanMarkdownAgents(root, budget) {
    const out = [];
    walk(root, budget, 0, (file) => {
        if (!file.toLowerCase().endsWith('.md'))
            return;
        // Per-file size cap before reading (also bounds the frontmatter regex).
        let size;
        try {
            size = fs.statSync(file).size;
        }
        catch {
            return;
        }
        if (!budget.allowFileSize(size))
            return;
        let text;
        try {
            text = fs.readFileSync(file, 'utf-8');
        }
        catch {
            return;
        }
        // Frontmatter block must start at the top of the file and contain `tools:`
        const frontmatterMatch = text.match(/^---\n([\s\S]*?)\n---/);
        if (!frontmatterMatch)
            return;
        if (!/^tools:/m.test(frontmatterMatch[1]))
            return;
        const rel = path.relative(root, file);
        const id = 'md-' + sha256short(rel + '|md-frontmatter');
        out.push({
            detected: {
                agentId: id,
                filePath: rel,
                language: 'javascript', // closest of the union; .md isn't supported by the type
                provider: 'claude-code',
                framework: null,
                lineRanges: [],
                wrapMode: 'shim',
                packs: [],
            },
            text,
        });
    });
    return out;
}
function walk(dir, budget, depth, visit) {
    // Resource caps (Invariants 2 + 10): bound depth, file count, and wall-clock.
    if (!budget.canContinue() || !budget.withinDepth(depth))
        return;
    let entries;
    try {
        entries = fs.readdirSync(dir, { withFileTypes: true });
    }
    catch {
        return;
    }
    for (const e of entries) {
        if (!budget.canContinue())
            return;
        // Never follow symlinks (a symlink cycle = unbounded walk).
        if (e.isSymbolicLink())
            continue;
        if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist')
            continue;
        const full = path.join(dir, e.name);
        if (e.isDirectory())
            walk(full, budget, depth + 1, visit);
        else if (e.isFile()) {
            budget.countVisit();
            visit(full);
        }
    }
}
function sha256short(input) {
    // Avoid taking a dep on crypto inside this hot path — use a simple
    // deterministic hash since the value is only used as a UI key.
    let h = 0;
    for (let i = 0; i < input.length; i++) {
        h = (h * 31 + input.charCodeAt(i)) | 0;
    }
    return Math.abs(h).toString(16).padStart(12, '0').slice(0, 12);
}
function readGovernanceConfig(cwd) {
    const configPath = path.join(cwd, '.governance.json');
    if (!fs.existsSync(configPath))
        return null;
    try {
        const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
        const runtime = (raw['runtime'] ?? {});
        return {
            govServerUrl: typeof runtime.govServerUrl === 'string' ? runtime.govServerUrl : undefined,
            serviceToken: typeof runtime.serviceToken === 'string' ? runtime.serviceToken : undefined,
            orgId: typeof runtime.orgId === 'string' ? runtime.orgId : undefined,
            agentId: typeof runtime.agentId === 'string' ? runtime.agentId : undefined,
        };
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// --fix / --apply remediation runner
// ---------------------------------------------------------------------------
/**
 * After the scan output is emitted, optionally apply pack-as-remediation
 * actions for findings that have a registered remediation in the active packs.
 *
 * This is invoked only when --fix (or --apply) is passed.
 *
 * The function:
 *  1. Collects all unique finding codes from the scan.
 *  2. Loads active packs (from .governance.json or a default set).
 *  3. Finds remediation entries whose `fixes` code is in the finding set.
 *  4. In dry-run (--fix only): prints diffs to stderr.
 *  5. In apply (--apply or --yes): runs-remediations, emits audit events.
 */
async function runFixMode(dir, scans, applyMode, skipConfirm) {
    // Collect all finding risk IDs from the scan results.
    // OwaspFinding uses `risk` (OwaspAgenticRiskId) as the finding identifier.
    const findingCodes = new Set();
    for (const s of scans) {
        for (const f of s.scanReport.findings) {
            if (f.risk)
                findingCodes.add(f.risk);
        }
    }
    if (findingCodes.size === 0) {
        process.stderr.write('scan --fix: no findings to remediate.\n');
        return;
    }
    // Load packs from .governance.json if present; otherwise skip silently.
    const cfg = readGovernanceConfig(dir);
    if (!cfg) {
        process.stderr.write('scan --fix: no .governance.json found in scanned directory. ' +
            'Run `ai-governance init` first to register compliance packs.\n');
        return;
    }
    // For PR-5a-1: the pack registry integration (loading active packs from
    // the customer's governance config) is stubbed. In PR-5a-3, HIPAA pack
    // remediations will be wired here. For now, emit a clear status message.
    process.stderr.write('scan --fix: pack-as-remediation is available. ' +
        `${findingCodes.size} finding code(s) detected: ${[...findingCodes].join(', ')}. ` +
        'Pack remediation wiring completes in PR-5a-3 (HIPAA pack + integration).\n');
    if (!applyMode && !skipConfirm) {
        process.stderr.write('scan --fix: dry-run mode. Pass --apply to write changes, ' +
            'or --yes to skip confirmation.\n');
    }
    // The runner is available and tested; called here when active packs are loaded.
    // Example of how PR-5a-3 will invoke it:
    //
    //   const results = runRemediations({
    //     agentDir: dir,
    //     packId: pack.id,
    //     packVersion: pack.version,
    //     remediations: matchingRemediations,
    //     mode: applyMode ? 'apply' : 'dry-run',
    //   });
    //
    //   for (const r of results) {
    //     if (r.status === 'applied') {
    //       const event: PackRemediationAppliedEvent = {
    //         event_type: 'PACK_REMEDIATION_APPLIED',
    //         pack_id: r.packId,
    //         pack_version: r.packVersion,
    //         fixes: r.remediation.fixes,
    //         files_modified: r.filesAffected,
    //         applied_by: 'cli:scan --apply',
    //         applied_at: new Date().toISOString(),
    //       };
    //       process.stderr.write(JSON.stringify(event) + '\n');
    //     }
    //   }
    // Suppress unused import warning until PR-5a-3 wires the call.
    void runRemediations;
    void null;
}
//# sourceMappingURL=scan.js.map