"use strict";
/**
 * PackEvaluator -- synchronous pack-aware enforcement for the execute() hot path.
 *
 * Resolves active packs for a tenant, runs a flattened classifier index against
 * the action context, and returns a verdict (ALLOW / DENY / PENDING).
 *
 * Architecture decisions locked in KANBAN.md GAP-S3-01:
 *  - Per-tenant cache, 5-minute TTL, explicit flush on pack change events.
 *  - Synchronous evaluate() -- no await in the hot path.
 *  - Strictest-wins across packs: DENY > PENDING > ALLOW.
 *  - BLOCK action from dataPolicy.categoryActions -> DENY verdict.
 *  - WARN action -> PENDING verdict (human review required by default).
 *  - No classifier hit -> ALLOW.
 *
 * @connexum/ai-governance  GAP-S3-01
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.PackEvaluator = void 0;
// ---------------------------------------------------------------------------
// Verdict precedence
// ---------------------------------------------------------------------------
const VERDICT_RANK = {
    ALLOW: 0,
    PENDING: 1,
    DENY: 2,
};
function stricterVerdict(a, b) {
    return VERDICT_RANK[a] >= VERDICT_RANK[b] ? a : b;
}
// ---------------------------------------------------------------------------
// PackEvaluator
// ---------------------------------------------------------------------------
class PackEvaluator {
    config;
    packRegistry;
    cache = new Map();
    constructor(packRegistry, eventBus, config) {
        this.packRegistry = packRegistry;
        this.config = {
            ttlMs: config?.ttlMs ?? 5 * 60 * 1000,
            enableCache: config?.enableCache ?? true,
        };
        // Subscribe to pack lifecycle events to invalidate cache.
        // The orgId on the event payload is used as the tenant key.
        for (const evType of ['pack:amended', 'pack:activated', 'pack:deactivated']) {
            eventBus.on(evType, (event) => {
                const tenantId = event.orgId ?? event.data.tenantId;
                if (tenantId) {
                    this.flushTenant(tenantId);
                }
                else {
                    // No tenant context -- flush all to be safe.
                    this.flushAll();
                }
            });
        }
    }
    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------
    /**
     * Evaluate whether the action context is permitted under active pack policies.
     *
     * Synchronous by design: must not await anything.
     * Cache hit: uses cached pack set.
     * Cache miss: resolves from registry, populates cache, evaluates.
     *
     * @param allowedPackIds - Phase 2 SE Blocker #1 strict-membership filter.
     *   When provided, classifier index entries are restricted to packs whose IDs
     *   are in this set. Packs that are bound (active for the tenant) but NOT in
     *   this set are silently skipped — they do not contribute to the verdict.
     *   When absent (or undefined), all active packs evaluate (existing behaviour,
     *   preserves backward compat for legacy-token / non-entitlement paths).
     */
    evaluate(tenantId, agentId, actionContext, allowedPackIds) {
        const ts = Date.now();
        const index = this.getClassifierIndex(tenantId, ts);
        // Build a serialized representation of the action for classifier matching.
        // We match against toolName + serialized input args.
        const inputText = this.serializeActionForMatching(actionContext);
        let currentVerdict = 'ALLOW';
        const triggeringPackIds = [];
        const triggeringClassifierIds = [];
        const reasons = [];
        for (const [categoryId, entry] of index) {
            // Phase 2 SE Blocker #1: if an allowedPackIds filter is active, skip any
            // classifier whose contributing packs are all outside the allowed set.
            // A category with packs split across allowed and non-allowed still
            // evaluates (the allowed packs' contribution is real); we filter by
            // restricting `entry.packIds` to the allowed subset.
            let effectiveEntry = entry;
            if (allowedPackIds !== undefined) {
                const allowedContributors = entry.packIds.filter(pid => allowedPackIds.has(pid));
                if (allowedContributors.length === 0) {
                    // All contributing packs are outside the entitlement — skip this category.
                    continue;
                }
                if (allowedContributors.length !== entry.packIds.length) {
                    // Partially allowed: narrow packIds to the allowed contributors only.
                    effectiveEntry = { ...entry, packIds: allowedContributors };
                }
            }
            if (!this.categoryMatches(categoryId, inputText, actionContext, effectiveEntry.classifierDef))
                continue;
            const packVerdict = this.actionToVerdict(effectiveEntry.action);
            if (packVerdict === 'ALLOW')
                continue; // no restriction from this category
            currentVerdict = stricterVerdict(currentVerdict, packVerdict);
            triggeringClassifierIds.push(categoryId);
            for (const pid of effectiveEntry.packIds) {
                if (!triggeringPackIds.includes(pid))
                    triggeringPackIds.push(pid);
            }
            reasons.push(`Category '${categoryId}' (action=${effectiveEntry.action}, packs=[${effectiveEntry.packIds.join(',')}])`);
        }
        const reason = currentVerdict === 'ALLOW'
            ? 'No pack policy restrictions matched this action.'
            : `Pack policy enforced: ${reasons.join('; ')}`;
        return {
            verdict: currentVerdict,
            triggeringPackIds,
            triggeringClassifierIds,
            reason,
            timestamp: ts,
        };
    }
    /**
     * Flush the cache entry for a single tenant (break-glass, pack change).
     */
    flushTenant(tenantId) {
        this.cache.delete(tenantId);
    }
    /**
     * Flush all tenant cache entries.
     */
    flushAll() {
        this.cache.clear();
    }
    // ---------------------------------------------------------------------------
    // Internal helpers
    // ---------------------------------------------------------------------------
    /**
     * Return the flattened classifier index for a tenant, resolving from the
     * registry on cache miss or TTL expiry.
     */
    getClassifierIndex(tenantId, now) {
        if (this.config.enableCache) {
            const entry = this.cache.get(tenantId);
            if (entry && entry.expiresAt > now) {
                // F-NEW-PACK-HOT-RELOAD-1: version-check the per-tenant cache entry.
                // If the PackRegistry has mutated the tenant's pack selection since the
                // cache was built (activateForTenant / deactivateForTenant increments
                // the tenant version), rebuild immediately rather than waiting for TTL.
                const currentVersion = this.packRegistry.getTenantVersion(tenantId);
                if (entry.tenantVersion === currentVersion) {
                    return entry.classifierIndex;
                }
                // Version mismatch: fall through to rebuild.
            }
        }
        // Cache miss, TTL expired, or version mismatch: resolve and build.
        // F-NEW-PACK-HOT-RELOAD-1: use getActiveForTenant() so per-tenant pack
        // selections (written by the activate/deactivate endpoints) are enforced
        // per-tenant. Falls back to getActive() (global env defaults) for tenants
        // that have never explicitly managed their packs.
        const activePacks = this.packRegistry.getActiveForTenant(tenantId);
        const classifierIndex = this.buildClassifierIndex(activePacks);
        if (this.config.enableCache) {
            this.cache.set(tenantId, {
                activePacks,
                classifierIndex,
                expiresAt: now + this.config.ttlMs,
                tenantVersion: this.packRegistry.getTenantVersion(tenantId),
            });
        }
        return classifierIndex;
    }
    /**
     * Build a flattened classifier index from a pack list.
     * For categories present in multiple packs, the strictest action wins.
     *
     * The classifierDef is taken from the strictest-action pack (or the first
     * pack declaring the category when actions tie). It is used in
     * categoryMatches() to run the real regex/function classifier instead of
     * falling back to keyword substring matching.
     *
     * Returns: Map<categoryId, { action: string; packIds: string[]; classifierDef?: CategoryClassifierDef }>
     */
    buildClassifierIndex(packs) {
        const index = new Map();
        // Action severity order (for strictest-wins merge)
        const actionRank = {
            ALLOW: 0,
            LOG: 1,
            WARN: 2,
            REQUIRE_APPROVAL: 3,
            BLOCK: 4,
        };
        for (const pack of packs) {
            const categoryActions = pack.dataPolicy?.categoryActions ?? {};
            // Cast to the extended pack type to access optional categoryClassifiers
            const packWithClassifiers = pack;
            for (const [categoryId, action] of Object.entries(categoryActions)) {
                // Precompile the classifierDef regex at index-build time (not per evaluate() call)
                // to satisfy the GAP-S3-01 p99 <= 50ms latency budget.
                const classifierDef = packWithClassifiers.categoryClassifiers?.[categoryId];
                const existing = index.get(categoryId);
                if (!existing) {
                    index.set(categoryId, { action, packIds: [pack.id], classifierDef });
                }
                else {
                    const existingRank = actionRank[existing.action] ?? 0;
                    const newRank = actionRank[action] ?? 0;
                    if (newRank > existingRank) {
                        // Stricter action takes over; adopt this pack's classifierDef too
                        index.set(categoryId, { action, packIds: [pack.id], classifierDef });
                    }
                    else if (newRank === existingRank && !existing.packIds.includes(pack.id)) {
                        // Same action from additional pack -- append pack to attribution list.
                        // Prefer an existing real classifierDef over an undefined one.
                        existing.packIds.push(pack.id);
                        if (!existing.classifierDef && classifierDef) {
                            existing.classifierDef = classifierDef;
                        }
                    }
                    // If less strict, the existing entry wins -- no update.
                }
            }
        }
        return index;
    }
    /**
     * Map a dataPolicy categoryAction string to a verdict.
     * BLOCK -> DENY, WARN -> PENDING (human review), everything else -> ALLOW
     * (LOG and REQUIRE_APPROVAL are also mapped: REQUIRE_APPROVAL -> PENDING).
     */
    actionToVerdict(action) {
        switch (action) {
            case 'BLOCK':
                return 'DENY';
            case 'WARN':
            case 'REQUIRE_APPROVAL':
                return 'PENDING';
            case 'LOG':
            case 'ALLOW':
            default:
                return 'ALLOW';
        }
    }
    /**
     * Determine whether a category fires for the given action context.
     *
     * Evaluation order (Task 2 fix, 2026-04-25):
     *   1. If the pack declares a CategoryClassifierDef with a `classifier` function,
     *      call it -- this is the authoritative runtime decision.
     *   2. If the pack declares a `pattern` regex, test it against the serialized input.
     *      Regex is precompiled at buildClassifierIndex time (not here) so no per-call
     *      compilation overhead. Satisfies GAP-S3-01 p99 <= 50ms latency budget.
     *   3. If neither classifier nor pattern is defined (pack declared the category
     *      but hasn't instrumented it), fall through to the legacy broad substring
     *      matching with a debug log so the gap is discoverable.
     *
     * Previously this method ran only keyword substring matching, meaning packs'
     * carefully crafted regex classifiers were NEVER called in the enforcement
     * hot path. This fix ensures SSNs, credit card numbers, and other regulated
     * data are caught by the actual pattern the pack author specified.
     *
     * False positives produce PENDING (human review) -- safe.
     * False negatives produce ALLOW -- unsafe. Err toward PENDING.
     */
    categoryMatches(categoryId, serializedInput, actionContext, classifierDef) {
        // --- Path 1: real classifier function (highest fidelity) ---
        if (classifierDef?.classifier) {
            try {
                return classifierDef.classifier(serializedInput, {
                    agentId: actionContext.agent?.name,
                    orgId: actionContext.orgId ?? actionContext.agent?.orgId,
                });
            }
            catch {
                // Classifier threw -- fall through to regex/substring as safety net.
                // A broken classifier should not silently pass (ALLOW) -- drop to broad match.
            }
        }
        // --- Path 2: compiled regex pattern ---
        if (classifierDef?.pattern) {
            try {
                classifierDef.pattern.lastIndex = 0; // reset global flag state
                return classifierDef.pattern.test(serializedInput);
            }
            catch {
                // Regex threw (e.g. catastrophic backtrack guard) -- fall through.
            }
        }
        // --- Path 3: legacy broad keyword matching (fallback for uninstrumented categories) ---
        // This path fires when categoryClassifiers[categoryId] is absent or empty.
        // It is intentionally broad: false positives are safe (PENDING), false negatives
        // (missed matches, ALLOW) are unsafe.
        // A structured debug log is emitted so ops teams can identify un-instrumented
        // categories and add proper classifiers.
        if (!classifierDef || (!classifierDef.classifier && !classifierDef.pattern)) {
            // Only log once per category per session to avoid hot-path noise.
            // Use a simple in-memory set (acceptable: resets on process restart).
            if (!PackEvaluator._warnedUninstrumentedCategories.has(categoryId)) {
                PackEvaluator._warnedUninstrumentedCategories.add(categoryId);
                // Use stderr directly to avoid any I/O dependency in this synchronous path
                process.stderr.write(`[pack-evaluator][WARN] Category '${categoryId}' has no classifierDef ` +
                    `(no pattern or classifier function). Falling back to broad substring matching. ` +
                    `Add categoryClassifiers['${categoryId}'] to the pack to use real enforcement.\n`);
            }
        }
        const catLower = categoryId.toLowerCase();
        const inputLower = serializedInput.toLowerCase();
        const toolLower = actionContext.toolName.toLowerCase();
        // 1. Tool name contains the category keyword
        if (toolLower.includes(catLower))
            return true;
        // 2. Serialized input contains the category keyword
        if (inputLower.includes(catLower))
            return true;
        // 3. Category keyword appears in common sensitive signal words
        const SENSITIVE_KEYWORDS = [
            'phi', 'pii', 'ssn', 'dob', 'pan', 'cvv', 'iban', 'swift',
            'biometric', 'health', 'medical', 'patient', 'credit', 'password',
            'secret', 'token', 'key', 'private', 'confidential',
        ];
        if (SENSITIVE_KEYWORDS.some(kw => catLower.includes(kw) && inputLower.includes(kw))) {
            return true;
        }
        return false;
    }
    /** Set of category IDs already warned about missing classifierDef (de-dupe per process). */
    static _warnedUninstrumentedCategories = new Set();
    /**
     * Produce a single string from an ActionContext suitable for classifier matching.
     */
    serializeActionForMatching(actionContext) {
        try {
            return JSON.stringify({
                tool: actionContext.toolName,
                input: actionContext.input,
                agent: actionContext.agent?.name,
            });
        }
        catch {
            return actionContext.toolName;
        }
    }
}
exports.PackEvaluator = PackEvaluator;
//# sourceMappingURL=pack-evaluator.js.map