/**
 * Permission Minimiser Scanner
 *
 * Analyzes tool usage traces to generate permission tightening proposals.
 * This scanner helps implement the principle of least privilege by:
 * - Identifying unused tools that can be disabled
 * - Detecting broad permissions that can be scoped down
 * - Recommending OAuth scope reductions
 * - Suggesting file/path access restrictions
 *
 * @module scanners/agent/permission-minimiser
 */
import { createHash } from "crypto";
import { readFile, readdir, writeFile, mkdir } from "fs/promises";
import { join, dirname } from "path";
// ============================================================================
// Usage Analysis Patterns
// ============================================================================
/**
 * Patterns for extracting file paths from arguments
 */
const FILE_PATH_PATTERNS = [
    { key: "path", pattern: /.*/ },
    { key: "file", pattern: /.*/ },
    { key: "file_path", pattern: /.*/ },
    { key: "filepath", pattern: /.*/ },
    { key: "filename", pattern: /.*/ },
    { key: "directory", pattern: /.*/ },
    { key: "dir", pattern: /.*/ },
    { key: "folder", pattern: /.*/ },
    { key: "source", pattern: /.*\.(ts|js|json|md|txt|yaml|yml)$/i },
    { key: "target", pattern: /.*\.(ts|js|json|md|txt|yaml|yml)$/i },
];
/**
 * Patterns for extracting URLs from arguments
 */
const URL_PATTERNS = [
    { key: "url", pattern: /^https?:\/\// },
    { key: "endpoint", pattern: /^https?:\/\// },
    { key: "uri", pattern: /^https?:\/\// },
    { key: "webhook", pattern: /^https?:\/\// },
    { key: "api_url", pattern: /^https?:\/\// },
];
// ============================================================================
// Trace Loading
// ============================================================================
/**
 * Load tool invocation traces from directory
 */
async function loadTraces(tracesDir) {
    const invocations = [];
    try {
        const files = await readdir(tracesDir);
        const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
        for (const file of jsonlFiles) {
            const filePath = join(tracesDir, file);
            const content = await readFile(filePath, "utf-8");
            const lines = content.split("\n").filter((l) => l.trim());
            for (const line of lines) {
                try {
                    const entry = JSON.parse(line);
                    if (entry.tool) {
                        invocations.push(entry);
                    }
                }
                catch {
                    // Skip invalid lines
                }
            }
        }
    }
    catch {
        // Directory doesn't exist or is empty
    }
    return invocations;
}
// ============================================================================
// Usage Profile Building
// ============================================================================
/**
 * Extract file paths accessed from invocation arguments
 */
function extractFilePaths(args) {
    const paths = [];
    for (const { key } of FILE_PATH_PATTERNS) {
        const value = args[key];
        if (typeof value === "string" && value.length > 0) {
            paths.push(value);
        }
    }
    return paths;
}
/**
 * Extract URLs accessed from invocation arguments
 */
function extractUrls(args) {
    const urls = [];
    for (const { key, pattern } of URL_PATTERNS) {
        const value = args[key];
        if (typeof value === "string" && pattern.test(value)) {
            urls.push(value);
        }
    }
    return urls;
}
/**
 * Build usage records from invocations
 */
function buildUsageRecords(invocations, manifest) {
    const records = new Map();
    // Initialize records for all tools in manifest
    for (const tool of manifest.tools) {
        records.set(tool.name, {
            tool: tool.name,
            invocations: 0,
            argumentPatterns: [],
            resourcesAccessed: [],
            lastUsed: "",
            firstUsed: "",
        });
    }
    // Process invocations
    for (const inv of invocations) {
        let record = records.get(inv.tool);
        if (!record) {
            // Tool invoked but not in manifest (possibly removed)
            record = {
                tool: inv.tool,
                invocations: 0,
                argumentPatterns: [],
                resourcesAccessed: [],
                lastUsed: "",
                firstUsed: "",
            };
            records.set(inv.tool, record);
        }
        record.invocations++;
        // Track timestamps
        if (!record.firstUsed || inv.timestamp < record.firstUsed) {
            record.firstUsed = inv.timestamp;
        }
        if (!record.lastUsed || inv.timestamp > record.lastUsed) {
            record.lastUsed = inv.timestamp;
        }
        // Extract resources accessed
        const paths = extractFilePaths(inv.arguments);
        const urls = extractUrls(inv.arguments);
        record.resourcesAccessed.push(...paths, ...urls);
        // Build argument patterns
        const argKeys = Object.keys(inv.arguments).sort().join(",");
        if (!record.argumentPatterns.includes(argKeys)) {
            record.argumentPatterns.push(argKeys);
        }
    }
    // Deduplicate resources
    for (const record of records.values()) {
        record.resourcesAccessed = [...new Set(record.resourcesAccessed)];
    }
    return records;
}
// ============================================================================
// Permission Analysis
// ============================================================================
/**
 * Find common path prefix for file access patterns
 */
function findCommonPrefix(paths) {
    if (paths.length === 0)
        return "";
    if (paths.length === 1)
        return dirname(paths[0]);
    const parts = paths.map((p) => p.split("/"));
    const minLength = Math.min(...parts.map((p) => p.length));
    const common = [];
    for (let i = 0; i < minLength; i++) {
        const segment = parts[0][i];
        if (parts.every((p) => p[i] === segment)) {
            common.push(segment);
        }
        else {
            break;
        }
    }
    return common.join("/");
}
/**
 * Generate path restriction proposal
 */
function proposePathRestriction(tool, record) {
    const filePaths = record.resourcesAccessed.filter((r) => !r.startsWith("http"));
    if (filePaths.length === 0)
        return null;
    const commonPrefix = findCommonPrefix(filePaths);
    if (!commonPrefix || commonPrefix === "" || commonPrefix === ".") {
        return null;
    }
    // Check if we can suggest a more specific path
    const hasWildcard = !tool.inputSchema ||
        JSON.stringify(tool.inputSchema).includes("*");
    if (hasWildcard || filePaths.length >= 3) {
        return {
            type: "scope-down",
            tool: tool.name,
            current: "All paths (*)",
            proposed: `${commonPrefix}/**`,
            rationale: `Tool only accessed files under ${commonPrefix}/ in ${filePaths.length} invocations`,
            riskReduction: 25,
            confidence: Math.min(90, 50 + filePaths.length * 5),
        };
    }
    return null;
}
/**
 * Generate URL restriction proposal
 */
function proposeUrlRestriction(tool, record) {
    const urls = record.resourcesAccessed.filter((r) => r.startsWith("http"));
    if (urls.length === 0)
        return null;
    // Extract unique domains
    const domains = new Set();
    for (const url of urls) {
        try {
            const parsed = new URL(url);
            domains.add(parsed.hostname);
        }
        catch {
            // Invalid URL
        }
    }
    if (domains.size === 0)
        return null;
    const domainList = Array.from(domains);
    if (domainList.length <= 3 && tool.networkAccess) {
        return {
            type: "scope-down",
            tool: tool.name,
            current: "All URLs (*)",
            proposed: `Allowlist: ${domainList.join(", ")}`,
            rationale: `Tool only accessed ${domainList.length} domain(s) in ${urls.length} requests`,
            riskReduction: 30,
            confidence: Math.min(85, 50 + urls.length * 3),
        };
    }
    return null;
}
/**
 * Generate unused tool proposal
 */
function proposeDisableUnused(tool, record, totalInvocations, traceDays) {
    if (record.invocations > 0)
        return null;
    // Only suggest if we have meaningful trace data
    if (totalInvocations < 10 || traceDays < 7) {
        return null;
    }
    return {
        type: "disable-unused",
        tool: tool.name,
        current: "Enabled",
        proposed: "Disabled",
        rationale: `Tool not used in ${traceDays} days across ${totalInvocations} total invocations`,
        riskReduction: tool.destructiveHint ? 40 : tool.networkAccess ? 30 : 15,
        confidence: Math.min(80, 40 + traceDays),
    };
}
/**
 * Generate low-usage tool proposal
 */
function proposeLowUsageReview(tool, record, totalInvocations) {
    if (record.invocations === 0)
        return null;
    const usagePercent = (record.invocations / totalInvocations) * 100;
    // Flag tools with very low usage
    if (usagePercent < 1 && totalInvocations > 50) {
        return {
            type: "add-constraint",
            tool: tool.name,
            current: `${record.invocations} invocations (${usagePercent.toFixed(2)}%)`,
            proposed: "Add confirmation prompt or rate limit",
            rationale: `Tool rarely used - consider adding explicit approval step`,
            riskReduction: 10,
            confidence: 60,
        };
    }
    return null;
}
/**
 * Generate all permission proposals
 */
function generateProposals(manifest, records, invocations) {
    const proposals = [];
    // Calculate trace period
    let earliest = "";
    let latest = "";
    for (const inv of invocations) {
        if (!earliest || inv.timestamp < earliest)
            earliest = inv.timestamp;
        if (!latest || inv.timestamp > latest)
            latest = inv.timestamp;
    }
    const traceDays = earliest && latest
        ? Math.ceil((new Date(latest).getTime() - new Date(earliest).getTime()) / (1000 * 60 * 60 * 24))
        : 0;
    const totalInvocations = invocations.length;
    for (const tool of manifest.tools) {
        const record = records.get(tool.name);
        if (!record)
            continue;
        // Check for unused tools
        const unusedProposal = proposeDisableUnused(tool, record, totalInvocations, traceDays);
        if (unusedProposal)
            proposals.push(unusedProposal);
        // Check for path restrictions
        const pathProposal = proposePathRestriction(tool, record);
        if (pathProposal)
            proposals.push(pathProposal);
        // Check for URL restrictions
        const urlProposal = proposeUrlRestriction(tool, record);
        if (urlProposal)
            proposals.push(urlProposal);
        // Check for low usage
        const lowUsageProposal = proposeLowUsageReview(tool, record, totalInvocations);
        if (lowUsageProposal)
            proposals.push(lowUsageProposal);
    }
    // Sort by risk reduction (highest first)
    proposals.sort((a, b) => b.riskReduction - a.riskReduction);
    return proposals;
}
// ============================================================================
// Finding Generation
// ============================================================================
/**
 * Calculate severity from proposal
 */
function proposalSeverity(proposal) {
    if (proposal.riskReduction >= 35)
        return "high";
    if (proposal.riskReduction >= 20)
        return "medium";
    if (proposal.riskReduction >= 10)
        return "low";
    return "info";
}
/**
 * Convert proposals to findings
 */
function proposalsToFindings(proposals, records) {
    const findings = [];
    for (const proposal of proposals) {
        findings.push({
            scanner: "semgrep",
            ruleId: `permission-minimiser:${proposal.type}`,
            file: "mcp-manifest",
            line: 0,
            message: `Tool "${proposal.tool}": ${proposal.rationale}`,
            severity: proposalSeverity(proposal),
            confidence: 100,
            evidence: [
                `Current: ${proposal.current}`,
                `Proposed: ${proposal.proposed}`,
                `Risk reduction: ${proposal.riskReduction}%`,
                `Confidence: ${proposal.confidence}%`,
            ].join("\n"),
            metadata: {
                agentScanner: "permission-minimiser",
                proposalType: proposal.type,
                tool: proposal.tool,
                riskReduction: proposal.riskReduction,
                confidence: proposal.confidence,
            },
        });
    }
    // Add summary finding
    if (proposals.length > 0) {
        const totalRiskReduction = proposals.reduce((sum, p) => sum + p.riskReduction, 0);
        const avgConfidence = Math.round(proposals.reduce((sum, p) => sum + p.confidence, 0) / proposals.length);
        const byType = {
            "disable-unused": 0,
            "scope-down": 0,
            "remove-permission": 0,
            "add-constraint": 0,
        };
        for (const p of proposals) {
            byType[p.type] = (byType[p.type] || 0) + 1;
        }
        findings.push({
            scanner: "semgrep",
            ruleId: "permission-minimiser:summary",
            file: "mcp-manifest",
            line: 0,
            message: `Permission minimiser found ${proposals.length} optimization(s) with ${totalRiskReduction}% total risk reduction`,
            severity: proposals.some((p) => proposalSeverity(p) === "high") ? "high" : "medium",
            confidence: 100,
            evidence: [
                `Total proposals: ${proposals.length}`,
                `Total risk reduction: ${totalRiskReduction}%`,
                `Average confidence: ${avgConfidence}%`,
                `By type:`,
                `  - Disable unused: ${byType["disable-unused"]}`,
                `  - Scope down: ${byType["scope-down"]}`,
                `  - Add constraint: ${byType["add-constraint"]}`,
            ].join("\n"),
            metadata: {
                agentScanner: "permission-minimiser",
                totalProposals: proposals.length,
                totalRiskReduction,
                avgConfidence,
                byType,
            },
        });
    }
    return findings;
}
// ============================================================================
// Main Scanner Function
// ============================================================================
/**
 * Run permission minimiser scanner
 */
export async function runPermissionMinimiser(manifest, options) {
    const startTime = Date.now();
    const tracesDir = options?.tracesDir || ".vaspera/traces";
    // Load traces
    const invocations = await loadTraces(tracesDir);
    // Check if we have enough data
    if (invocations.length < 10) {
        return {
            scanner: "permission-minimiser",
            findings: [
                {
                    scanner: "semgrep",
                    ruleId: "permission-minimiser:insufficient-data",
                    file: "mcp-manifest",
                    line: 0,
                    message: `Insufficient trace data: only ${invocations.length} invocations found. Need at least 10 for meaningful analysis.`,
                    severity: "info",
                    confidence: 100,
                    evidence: `Traces directory: ${tracesDir}\nInvocations found: ${invocations.length}`,
                    metadata: {
                        agentScanner: "permission-minimiser",
                        invocationsFound: invocations.length,
                        tracesDir,
                    },
                },
            ],
            duration: Date.now() - startTime,
            success: true,
            mcpServerName: manifest.name,
            mcpServerVersion: manifest.version,
            version: "1.0.0",
            rulesUsed: ["usage-analysis"],
        };
    }
    // Build usage records
    const records = buildUsageRecords(invocations, manifest);
    // Generate proposals
    let proposals = generateProposals(manifest, records, invocations);
    // Filter by thresholds
    if (options?.minConfidence) {
        proposals = proposals.filter((p) => p.confidence >= options.minConfidence);
    }
    if (options?.minRiskReduction) {
        proposals = proposals.filter((p) => p.riskReduction >= options.minRiskReduction);
    }
    // Save proposals if output file specified
    if (options?.outputFile) {
        await mkdir(dirname(options.outputFile), { recursive: true });
        await writeFile(options.outputFile, JSON.stringify({
            timestamp: new Date().toISOString(),
            manifest: manifest.name,
            version: manifest.version,
            invocationsAnalyzed: invocations.length,
            proposals,
        }, null, 2));
    }
    // Convert to findings
    const findings = proposalsToFindings(proposals, records);
    // Calculate manifest hash
    const manifestHash = createHash("sha256")
        .update(JSON.stringify(manifest))
        .digest("hex");
    return {
        scanner: "permission-minimiser",
        findings,
        duration: Date.now() - startTime,
        success: true,
        mcpServerName: manifest.name,
        mcpServerVersion: manifest.version,
        manifestHash,
        version: "1.0.0",
        rulesUsed: ["usage-analysis"],
    };
}
/**
 * Check if permission minimiser is available
 */
export async function checkPermissionMinimiserAvailable() {
    return {
        scanner: "permission-minimiser",
        available: true,
        version: "1.0.0",
    };
}
/**
 * Get permission proposals from scanner result
 */
export function getPermissionProposals(result) {
    const proposals = [];
    for (const finding of result.findings) {
        if (finding.ruleId.startsWith("permission-minimiser:") &&
            finding.ruleId !== "permission-minimiser:summary" &&
            finding.ruleId !== "permission-minimiser:insufficient-data") {
            const meta = finding.metadata;
            if (meta.proposalType && meta.tool) {
                // Parse current/proposed from evidence
                const lines = (finding.evidence || "").split("\n");
                const current = lines.find((l) => l.startsWith("Current:"))?.replace("Current:", "").trim() || "";
                const proposed = lines.find((l) => l.startsWith("Proposed:"))?.replace("Proposed:", "").trim() || "";
                proposals.push({
                    type: meta.proposalType,
                    tool: meta.tool,
                    current,
                    proposed,
                    rationale: finding.message.replace(`Tool "${meta.tool}": `, ""),
                    riskReduction: meta.riskReduction || 0,
                    confidence: meta.confidence || 0,
                });
            }
        }
    }
    return proposals;
}
/**
 * Get minimiser summary from result
 */
export function getMinimiserSummary(result) {
    const summaryFinding = result.findings.find((f) => f.ruleId === "permission-minimiser:summary");
    if (!summaryFinding?.metadata) {
        return null;
    }
    const meta = summaryFinding.metadata;
    return {
        totalProposals: meta.totalProposals || 0,
        totalRiskReduction: meta.totalRiskReduction || 0,
        avgConfidence: meta.avgConfidence || 0,
        byType: meta.byType || {},
    };
}
//# sourceMappingURL=permission-minimiser.js.map