/**
 * Plugin/Extension system for AI governance.
 *
 * Enables custom rules, checks, and reporting without forking
 * the core package. Plugins are discovered, validated, and loaded
 * at initialization time.
 *
 * SYSTEMIC-H2 / SYSTEMIC-H4 (2026-04-21): the four post-hook dispatchers
 * (`executePostAction`, `executeOnViolation`, `executeOnReport`,
 * `executeOnScan`) used to swallow plugin exceptions silently with
 * "Non-fatal" comments. That defeated SOC 2 CC7.3 system-operations
 * monitoring and could lose DORA Art. 17 incident notifications.
 *
 * The registry now emits, on every plugin exception:
 *
 *   1. A structured WARN log entry via the injected logger.
 *   2. A per-plugin + per-method sliding-window error counter
 *      (60-minute window). Callers read this with
 *      `getPluginErrorCount()` / `getRecentPluginErrors()`.
 *   3. An audit-chain `plugin-error` event via `onPluginError`.
 *   4. A CRITICAL alert via `onErrorThresholdBreach` the first time a
 *      plugin+method crosses the configured hourly threshold (default 5).
 *      Hysteresis prevents repeated firing until the hourly rate drops
 *      back below the resolve level.
 *
 * Continue-on-error is preserved: one plugin failing NEVER halts the
 * other plugins. The fix is about VISIBILITY of errors, not changing
 * the resilience semantics.
 *
 * @connexum/ai-governance
 */
import * as fs from 'fs';
import * as path from 'path';
import { assertPluginTrusted, computePluginSha256, PluginIntegrityError, } from '../security/plugin-integrity.js';
// ---------------------------------------------------------------------------
// PLUGIN-INTEGRITY-02: runtime per-dispatch integrity error
// ---------------------------------------------------------------------------
/**
 * Thrown (or surfaced as a result-entry) when a plugin fails runtime
 * per-dispatch SHA-256 re-verification. Indicates the plugin bytes changed
 * after load-time registration (TOCTOU window).
 */
export class PluginRuntimeIntegrityError extends Error {
    code = 'PLUGIN_RUNTIME_INTEGRITY_FAILED';
    pluginId;
    expected;
    actual;
    dispatchId;
    constructor(opts) {
        super(`Plugin runtime integrity check failed for '${opts.pluginId}': ` +
            `hash mismatch at dispatch (expected ${opts.expected}, got ${opts.actual}). ` +
            `TOCTOU: plugin bytes were mutated after load-time verification.`);
        this.name = 'PluginRuntimeIntegrityError';
        this.pluginId = opts.pluginId;
        this.expected = opts.expected;
        this.actual = opts.actual;
        this.dispatchId = opts.dispatchId;
    }
}
export class PluginValidationError extends Error {
    pluginName;
    reason;
    constructor(pluginName, reason) {
        super(`Plugin validation failed for '${pluginName}': ${reason}`);
        this.pluginName = pluginName;
        this.reason = reason;
        this.name = 'PluginValidationError';
    }
}
/**
 * Sliding-window milliseconds for the plugin error rate counter. One
 * hour matches the alert-thresholds.ts default evaluation window and
 * the docs commitment in fail-open-pattern-audit-2026-04-21.md.
 */
const PLUGIN_ERROR_WINDOW_MS = 60 * 60 * 1000;
export class PluginRegistry {
    plugins = new Map();
    config;
    /**
     * Per (pluginId, method) rolling arrays of error timestamps in ms.
     * Entries older than PLUGIN_ERROR_WINDOW_MS are pruned on every push.
     * Also carries the last few error messages so threshold alerts can
     * include recent-error context without re-scanning.
     */
    errorWindows = new Map();
    /**
     * Hysteresis state for the threshold alert. `true` means we are
     * currently "fired" for that (pluginId, method) and must NOT re-emit
     * another breach event until the rate drops back below the resolve
     * level. Modeled to match the AlertThresholdStore pattern.
     */
    firedState = new Map();
    constructor(config = {}) {
        this.config = {
            pluginDir: config.pluginDir || '',
            maxPlugins: config.maxPlugins ?? 50,
            logger: config.logger,
            onPluginError: config.onPluginError,
            onErrorThresholdBreach: config.onErrorThresholdBreach,
            errorRateThresholdPerHour: config.errorRateThresholdPerHour ?? 5,
            // PLUGIN-INTEGRITY-01
            trustedPlugins: config.trustedPlugins,
            pluginSources: config.pluginSources,
            disableIntegrity: config.disableIntegrity ?? false,
            onIntegrityEvent: config.onIntegrityEvent,
            // PLUGIN-INTEGRITY-02
            runtimeRecheckCadence: config.pluginIntegrity?.runtimeRecheckCadence ?? 1,
            onRuntimeIntegrityEvent: config.onRuntimeIntegrityEvent,
        };
    }
    /**
     * Register a plugin programmatically.
     *
     * PLUGIN-INTEGRITY-01: before admission, the plugin's source is verified
     * against the trusted manifest. The check is fail-closed by default:
     *   - If `disableIntegrity` is false (default) and the hash does not match
     *     or the plugin is not in the manifest, registration throws
     *     `PluginIntegrityError` and emits a `plugin:integrity-denied` CRITICAL
     *     audit event.
     *   - If `disableIntegrity` is true, the plugin is admitted without hash
     *     verification but a `plugin:integrity-disabled` WARN event is emitted
     *     so the bypass is recorded on the audit chain.
     *   - When integrity checking passes, a `plugin:integrity-verified` INFO
     *     event is emitted.
     *
     * Integrity checking is only active when `trustedPlugins` is explicitly
     * provided in the config. If `trustedPlugins` is omitted (legacy / first-boot
     * / test scenario), registration proceeds without hash verification.
     *
     * When `trustedPlugins` is present, callers must also supply source code via:
     *   a) `pluginSources` map in the registry config (source keyed by name), or
     *   b) The `sourceOverride` parameter on this call.
     *
     * If neither is available and integrity is active, the load is denied.
     */
    register(plugin, sourceOverride) {
        this.validatePlugin(plugin);
        const name = plugin.manifest.name;
        if (this.plugins.has(name)) {
            throw new Error(`Plugin already registered: ${name}`);
        }
        if (this.plugins.size >= this.config.maxPlugins) {
            throw new Error(`Plugin limit reached (${this.config.maxPlugins}). Cannot register '${name}'.`);
        }
        // PLUGIN-INTEGRITY-01: load-time integrity gate
        this.enforceIntegrityAtLoad(name, sourceOverride);
        if (plugin.initialize) {
            plugin.initialize();
        }
        // PLUGIN-INTEGRITY-02: store the trust anchor at registration time.
        const registeredSource = sourceOverride ?? this.config.pluginSources?.get(name);
        const trustedSha256 = registeredSource !== undefined
            ? computePluginSha256(registeredSource)
            : undefined;
        this.plugins.set(name, {
            plugin,
            priority: plugin.manifest.priority ?? 100,
            loadedAt: new Date().toISOString(),
            active: true,
            trustedSha256,
            registeredSource,
            dispatchCount: 0,
        });
    }
    /**
     * Unregister a plugin by name.
     */
    unregister(name) {
        const loaded = this.plugins.get(name);
        if (!loaded)
            return false;
        if (loaded.plugin.destroy) {
            loaded.plugin.destroy();
        }
        this.plugins.delete(name);
        return true;
    }
    /**
     * Get a registered plugin by name.
     */
    getPlugin(name) {
        return this.plugins.get(name)?.plugin;
    }
    /**
     * List all registered plugins.
     */
    listPlugins() {
        return this.getOrderedPlugins().map(lp => lp.plugin.manifest);
    }
    /**
     * Execute all preAction hooks in priority order.
     * Returns DENY if any plugin denies. Otherwise ALLOW.
     */
    executePreAction(context) {
        const dispatchId = (typeof context.data?.dispatchId === 'string' ? context.data.dispatchId : undefined) ??
            this.generateDispatchId();
        for (const loaded of this.getOrderedPlugins()) {
            if (!loaded.active)
                continue;
            if (!loaded.plugin.manifest.hooks.includes('preAction'))
                continue;
            if (!loaded.plugin.preAction)
                continue;
            const integrityOutcome = this.enforceIntegrityAtDispatch(loaded, dispatchId);
            if (integrityOutcome === 'mismatch') {
                return {
                    allow: false,
                    reason: `Plugin '${loaded.plugin.manifest.name}' failed runtime integrity check (TOCTOU detected)`,
                    metadata: { plugin: loaded.plugin.manifest.name, runtimeIntegrityError: true },
                };
            }
            try {
                const result = loaded.plugin.preAction(context);
                if (!result.allow) {
                    return {
                        allow: false,
                        reason: `Blocked by plugin '${loaded.plugin.manifest.name}': ${this.sanitizePluginMessage(result.reason || 'no reason')}`,
                        metadata: { plugin: loaded.plugin.manifest.name, ...result.metadata },
                    };
                }
            }
            catch (err) {
                // SYSTEMIC-H2 (extended): preAction already failed closed
                // (deny), but the exception itself was previously invisible.
                // Run it through the same visibility pipeline as the other
                // hooks so operators see the error count + audit event. Do
                // NOT leak err.message into the response (AIAG-009).
                this.handlePluginError(loaded.plugin.manifest.name, 'preAction', err);
                return {
                    allow: false,
                    reason: `Plugin '${loaded.plugin.manifest.name}' encountered an error`,
                    metadata: { plugin: loaded.plugin.manifest.name, error: true },
                };
            }
        }
        return { allow: true };
    }
    /**
     * Execute all postAction hooks in priority order.
     *
     * SYSTEMIC-H2: plugin exceptions no longer "silently swallowed". Each
     * throw emits a WARN log, increments the hourly counter, emits a
     * `plugin-error` audit event, and (if the hourly threshold is
     * crossed) fires a CRITICAL alert. Execution continues across the
     * remaining plugins to preserve resilience.
     */
    executePostAction(context) {
        const dispatchId = (typeof context.data?.dispatchId === 'string' ? context.data.dispatchId : undefined) ??
            this.generateDispatchId();
        for (const loaded of this.getOrderedPlugins()) {
            if (!loaded.active)
                continue;
            if (!loaded.plugin.manifest.hooks.includes('postAction'))
                continue;
            if (!loaded.plugin.postAction)
                continue;
            const integrityOutcome = this.enforceIntegrityAtDispatch(loaded, dispatchId);
            if (integrityOutcome === 'mismatch') {
                continue;
            }
            try {
                loaded.plugin.postAction(context);
            }
            catch (err) {
                this.handlePluginError(loaded.plugin.manifest.name, 'postAction', err);
            }
        }
    }
    /**
     * Execute all onViolation hooks.
     *
     * SYSTEMIC-H2: see executePostAction note. A violation-handler crash
     * here used to look identical to "no violations" to the operator,
     * breaking DORA Art. 17 incident-notification obligations. Now every
     * crash is logged, counted, audit-chained, and alertable.
     */
    executeOnViolation(context) {
        const dispatchId = (typeof context.data?.dispatchId === 'string' ? context.data.dispatchId : undefined) ??
            this.generateDispatchId();
        for (const loaded of this.getOrderedPlugins()) {
            if (!loaded.active)
                continue;
            if (!loaded.plugin.manifest.hooks.includes('onViolation'))
                continue;
            if (!loaded.plugin.onViolation)
                continue;
            const integrityOutcome = this.enforceIntegrityAtDispatch(loaded, dispatchId);
            if (integrityOutcome === 'mismatch') {
                continue;
            }
            try {
                loaded.plugin.onViolation(context);
            }
            catch (err) {
                this.handlePluginError(loaded.plugin.manifest.name, 'onViolation', err);
            }
        }
    }
    /**
     * Execute all onReport hooks and collect results.
     *
     * SYSTEMIC-H4: a plugin that throws here used to cause its report
     * section to silently disappear, leaving operators to present
     * structurally-incomplete reports to auditors. The result set now
     * ALWAYS carries a per-plugin status entry:
     *   - `{ status: 'ok', plugin, ...pluginOutput }` on success
     *   - `{ status: 'error', plugin, errorMessage }` on failure
     *
     * Callers (report generators) must aggregate the `error` entries
     * into `incompletePluginCount` + `incompletePlugins` and MUST
     * surface them prominently in rendered output (banner, not footnote).
     * See `summarizeReportPluginStatuses()` below.
     */
    executeOnReport(context) {
        const results = [];
        const dispatchId = (typeof context.data?.dispatchId === 'string' ? context.data.dispatchId : undefined) ??
            this.generateDispatchId();
        for (const loaded of this.getOrderedPlugins()) {
            if (!loaded.active)
                continue;
            if (!loaded.plugin.manifest.hooks.includes('onReport'))
                continue;
            if (!loaded.plugin.onReport)
                continue;
            const integrityOutcome = this.enforceIntegrityAtDispatch(loaded, dispatchId);
            if (integrityOutcome === 'mismatch') {
                results.push({
                    plugin: loaded.plugin.manifest.name,
                    status: 'runtime-integrity-error',
                    errorMessage: `Plugin '${loaded.plugin.manifest.name}' failed runtime integrity check -- dispatch refused (TOCTOU detected)`,
                });
                continue;
            }
            try {
                const result = loaded.plugin.onReport(context);
                // Always emit a status entry, even when the plugin returns
                // void/undefined, so the caller can see it ran.
                results.push({
                    plugin: loaded.plugin.manifest.name,
                    status: 'ok',
                    ...(result ?? {}),
                });
            }
            catch (err) {
                const message = this.sanitizePluginMessage(err?.message ?? String(err));
                this.handlePluginError(loaded.plugin.manifest.name, 'onReport', err);
                // H-4: surface the failed-plugin section with ERROR state so
                // the renderer can show a banner. Do NOT omit the entry.
                results.push({
                    plugin: loaded.plugin.manifest.name,
                    status: 'error',
                    errorMessage: message || 'plugin onReport threw',
                });
            }
        }
        return results;
    }
    /**
     * Execute all onScan hooks and collect results.
     *
     * SYSTEMIC-H2: scan crashes follow the same visibility contract as
     * the other hooks. The result array carries only successful plugin
     * outputs (scans feed into detection engines, not rendered reports),
     * but the failure is always logged / counted / audit-chained.
     */
    executeOnScan(context) {
        const results = [];
        const dispatchId = (typeof context.data?.dispatchId === 'string' ? context.data.dispatchId : undefined) ??
            this.generateDispatchId();
        for (const loaded of this.getOrderedPlugins()) {
            if (!loaded.active)
                continue;
            if (!loaded.plugin.manifest.hooks.includes('onScan'))
                continue;
            if (!loaded.plugin.onScan)
                continue;
            const integrityOutcome = this.enforceIntegrityAtDispatch(loaded, dispatchId);
            if (integrityOutcome === 'mismatch') {
                continue;
            }
            try {
                const result = loaded.plugin.onScan(context);
                if (result) {
                    results.push({ plugin: loaded.plugin.manifest.name, ...result });
                }
            }
            catch (err) {
                this.handlePluginError(loaded.plugin.manifest.name, 'onScan', err);
            }
        }
        return results;
    }
    // -------------------------------------------------------------------
    // SYSTEMIC-H2 plugin-error visibility surface
    // -------------------------------------------------------------------
    /**
     * Current number of errors for `(pluginId, method)` inside the
     * sliding 60-minute window. Prunes expired entries before returning.
     * Exposed so callers (dashboards, tests) can read the counter
     * without triggering a new event.
     */
    getPluginErrorCount(pluginId, method) {
        const key = `${pluginId}:${method}`;
        const bucket = this.errorWindows.get(key);
        if (!bucket)
            return 0;
        this.pruneExpired(bucket.timestamps, bucket.messages, Date.now());
        return bucket.timestamps.length;
    }
    /**
     * Returns up to `limit` most recent error messages (oldest-first
     * within the window) for `(pluginId, method)`. Useful when the alert
     * sink wants richer context than the numeric count.
     */
    getRecentPluginErrors(pluginId, method, limit = 3) {
        const key = `${pluginId}:${method}`;
        const bucket = this.errorWindows.get(key);
        if (!bucket)
            return [];
        this.pruneExpired(bucket.timestamps, bucket.messages, Date.now());
        return bucket.messages.slice(-limit);
    }
    /**
     * Reset all plugin-error counters + hysteresis state. Intended for
     * test setup between cases; NOT for production hot paths (would
     * erase operator signal).
     */
    resetPluginErrorCountersForTest() {
        this.errorWindows.clear();
        this.firedState.clear();
    }
    /**
     * Summarize report-hook results into an incomplete-plugin surface
     * suitable for top-level report metadata + banners. Renderers MUST
     * promote `incompletePluginCount > 0` to a banner, not a footnote.
     */
    static summarizeReportPluginStatuses(results) {
        const pluginStatuses = [];
        const incompletePlugins = [];
        for (const entry of results) {
            const pluginId = String(entry.plugin ?? 'unknown');
            const status = entry.status === 'error' ? 'error' : 'ok';
            const errorMessage = typeof entry.errorMessage === 'string' ? entry.errorMessage : undefined;
            pluginStatuses.push({ pluginId, status, ...(errorMessage ? { errorMessage } : {}) });
            if (status === 'error')
                incompletePlugins.push(pluginId);
        }
        const incompletePluginCount = incompletePlugins.length;
        const banner = incompletePluginCount > 0
            ? `This report is incomplete. ${incompletePluginCount} plugin(s) failed during generation. See pluginStatuses for details.`
            : null;
        return { pluginStatuses, incompletePluginCount, incompletePlugins, banner };
    }
    /**
     * Activate/deactivate a plugin.
     */
    setActive(name, active) {
        const loaded = this.plugins.get(name);
        if (!loaded)
            return false;
        loaded.active = active;
        return true;
    }
    /**
     * Check if a plugin is registered and active.
     */
    isActive(name) {
        const loaded = this.plugins.get(name);
        return loaded?.active ?? false;
    }
    /**
     * Get the count of registered plugins.
     */
    get pluginCount() {
        return this.plugins.size;
    }
    /**
     * Discover plugins from the plugin directory.
     * Returns manifest info without loading.
     */
    discoverPlugins() {
        if (!this.config.pluginDir || !fs.existsSync(this.config.pluginDir)) {
            return [];
        }
        const entries = fs.readdirSync(this.config.pluginDir);
        return entries.filter(e => {
            const fullPath = path.join(this.config.pluginDir, e);
            const stat = fs.statSync(fullPath);
            // Plugin is either a .js file or a directory with index.js
            return ((stat.isFile() && e.endsWith('.js')) ||
                (stat.isDirectory() && fs.existsSync(path.join(fullPath, 'index.js'))));
        });
    }
    simulateByteMutationForTest(pluginName, mutatedSource) {
        const loaded = this.plugins.get(pluginName);
        if (!loaded) {
            throw new Error(`simulateByteMutationForTest: plugin '${pluginName}' is not registered`);
        }
        loaded.registeredSource = mutatedSource;
    }
    // --- Private helpers ---
    // PLUGIN-INTEGRITY-02 ---------------------------------------------------
    generateDispatchId() {
        return `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
    }
    emitRuntimeIntegrityEvent(event) {
        if (!this.config.onRuntimeIntegrityEvent)
            return;
        try {
            this.config.onRuntimeIntegrityEvent(event);
        }
        catch (err) {
            try {
                process.stderr.write(`[plugin-system] onRuntimeIntegrityEvent sink threw for ${event.pluginId}: ` +
                    `${err?.message ?? String(err)}\n`);
            }
            catch { /* last resort */ }
        }
    }
    enforceIntegrityAtDispatch(loaded, dispatchId) {
        const now = new Date().toISOString();
        const name = loaded.plugin.manifest.name;
        loaded.dispatchCount += 1;
        if (this.config.trustedPlugins === undefined && !this.config.disableIntegrity) {
            return 'skip';
        }
        if (this.config.disableIntegrity) {
            this.emitRuntimeIntegrityEvent({
                type: 'plugin:integrity-runtime-bypassed',
                pluginId: name,
                dispatchId,
                timestamp: now,
            });
            return 'bypassed';
        }
        if (this.config.runtimeRecheckCadence === 'off') {
            return 'skip';
        }
        if (loaded.trustedSha256 === undefined || loaded.registeredSource === undefined) {
            return 'skip';
        }
        const cadence = this.config.runtimeRecheckCadence;
        const isDue = loaded.dispatchCount === 1 || loaded.dispatchCount % cadence === 0;
        if (!isDue) {
            return 'skip';
        }
        const actualHash = computePluginSha256(loaded.registeredSource);
        if (actualHash === loaded.trustedSha256) {
            this.emitRuntimeIntegrityEvent({
                type: 'plugin:integrity-runtime-verified',
                pluginId: name,
                sha256: actualHash,
                dispatchId,
                timestamp: now,
            });
            return 'ok';
        }
        this.emitRuntimeIntegrityEvent({
            type: 'plugin:integrity-runtime-mismatch',
            pluginId: name,
            expected: loaded.trustedSha256,
            actual: actualHash,
            dispatchId,
            timestamp: now,
        });
        const warnMsg = `[plugin-system] CRITICAL plugin:integrity-runtime-mismatch for '${name}' ` +
            `-- dispatch ${dispatchId} refused (TOCTOU attack detected)`;
        try {
            if (this.config.logger) {
                this.config.logger.warn(warnMsg, { pluginId: name, dispatchId });
            }
            else {
                console.warn(warnMsg);
            }
        }
        catch {
            try {
                process.stderr.write(warnMsg + '\n');
            }
            catch { /* last resort */ }
        }
        return 'mismatch';
    }
    // PLUGIN-INTEGRITY-01 ---------------------------------------------------
    /**
     * Enforce the load-time integrity contract for a plugin being registered.
     *
     * Integrity checking is ONLY active when `trustedPlugins` is explicitly
     * provided in the registry config. If `trustedPlugins` is undefined, the
     * registry is in "unconfigured" mode (e.g. pre-manifest first-boot or
     * test fixtures that pre-date PLUGIN-INTEGRITY-01) and registration
     * proceeds without hash verification.
     *
     * When `trustedPlugins` IS provided, enforcement is fail-closed:
     *   1. `disableIntegrity: true` -- emit WARN event, return (bypass).
     *   2. Source available + hash matches manifest -- emit INFO event, return.
     *   3. Source missing, hash mismatch, or unregistered -- emit CRITICAL
     *      event, throw `PluginIntegrityError`.
     *
     * Governance config note: callers operating under HIPAA, SOC 2, or PCI DSS
     * packs SHOULD supply `trustedPlugins` to activate the check. Leaving it
     * undefined is acceptable only in development and test environments.
     */
    enforceIntegrityAtLoad(pluginName, sourceOverride) {
        // If no trustedPlugins map was configured, integrity checking is not yet
        // active for this registry instance. Skip silently -- this is the
        // pre-PLUGIN-INTEGRITY-01 behaviour and the first-boot scenario.
        if (this.config.trustedPlugins === undefined && !this.config.disableIntegrity) {
            return;
        }
        const now = new Date().toISOString();
        if (this.config.disableIntegrity) {
            this.emitIntegrityEvent({
                type: 'plugin:integrity-disabled',
                pluginName,
                timestamp: now,
            });
            // Bypass: log via WARN so it is visible even without an audit sink.
            const warn = `[plugin-system] WARN plugin:integrity-disabled for '${pluginName}' -- disableIntegrity=true`;
            try {
                if (this.config.logger) {
                    this.config.logger.warn(warn, { pluginName });
                }
                else {
                    // eslint-disable-next-line no-console
                    console.warn(warn);
                }
            }
            catch {
                // Logger must not crash the caller.
            }
            return;
        }
        // At this point trustedPlugins is defined.
        const manifestMap = this.config.trustedPlugins;
        // Resolve source: caller override > pluginSources map > deny.
        const source = sourceOverride ?? this.config.pluginSources?.get(pluginName);
        if (source === undefined) {
            // No source means we cannot compute a hash. Fail closed.
            const deniedEvent = {
                type: 'plugin:integrity-denied',
                pluginName,
                reason: 'unregistered-plugin',
                timestamp: now,
            };
            this.emitIntegrityEvent(deniedEvent);
            throw new PluginIntegrityError({
                reason: 'unregistered-plugin',
                pluginName,
            });
        }
        const entry = manifestMap.get(pluginName);
        try {
            assertPluginTrusted({ name: pluginName, source }, manifestMap);
        }
        catch (err) {
            if (err instanceof PluginIntegrityError) {
                const deniedEvent = {
                    type: 'plugin:integrity-denied',
                    pluginName,
                    reason: err.reason,
                    expected: err.expected,
                    actual: err.actual,
                    timestamp: now,
                };
                this.emitIntegrityEvent(deniedEvent);
                throw err;
            }
            // Unexpected error from the crypto layer -- still fail closed.
            const deniedEvent = {
                type: 'plugin:integrity-denied',
                pluginName,
                reason: 'unregistered-plugin',
                timestamp: now,
            };
            this.emitIntegrityEvent(deniedEvent);
            throw new PluginIntegrityError({ reason: 'unregistered-plugin', pluginName });
        }
        // Hash matched.
        this.emitIntegrityEvent({
            type: 'plugin:integrity-verified',
            pluginName,
            sha256: entry?.sha256 ?? computePluginSha256(source),
            timestamp: now,
        });
    }
    /**
     * Deliver a plugin integrity audit event to the registered sink.
     * Exceptions from the sink are caught and logged to stderr -- a broken
     * audit sink must not crash the registration path.
     */
    emitIntegrityEvent(event) {
        if (!this.config.onIntegrityEvent)
            return;
        try {
            this.config.onIntegrityEvent(event);
        }
        catch (err) {
            try {
                process.stderr.write(`[plugin-system] onIntegrityEvent sink threw for ${event.pluginName}: ` +
                    `${err?.message ?? String(err)}\n`);
            }
            catch {
                // last resort.
            }
        }
    }
    // -----------------------------------------------------------------------
    /**
     * SYSTEMIC-H2 central visibility pipeline for plugin exceptions.
     *
     * Ordering is deliberate:
     *   1. Sanitize the error once so downstream consumers all see the
     *      same safe message (no absolute paths, no stack frames).
     *   2. Push timestamp into the sliding-window counter.
     *   3. Emit the WARN log (always, regardless of whether callbacks
     *      are wired). This guarantees at least one operator-visible
     *      signal even in a misconfigured deployment.
     *   4. Emit the structured audit event via `onPluginError`.
     *   5. Check the hourly threshold with hysteresis. Fire the
     *      CRITICAL alert once per (pluginId, method) crossing.
     *
     * No step is permitted to throw back into the calling dispatcher;
     * each is wrapped so one broken consumer cannot mask the visibility
     * of other consumers.
     */
    handlePluginError(pluginId, method, err) {
        const now = Date.now();
        const timestampIso = new Date(now).toISOString();
        const rawMessage = err?.message ?? String(err);
        const rawStack = err?.stack ?? '';
        const errorMessage = this.sanitizePluginMessage(rawMessage).substring(0, 500);
        const errorStack = this.sanitizePluginStack(rawStack);
        // 1. Counter update + prune expired entries.
        const key = `${pluginId}:${method}`;
        let bucket = this.errorWindows.get(key);
        if (!bucket) {
            bucket = { timestamps: [], messages: [] };
            this.errorWindows.set(key, bucket);
        }
        this.pruneExpired(bucket.timestamps, bucket.messages, now);
        bucket.timestamps.push(now);
        bucket.messages.push(errorMessage);
        // Cap stored messages at 100 to keep memory bounded even under
        // adversarial spam. Timestamps cap naturally via the 60-min prune.
        if (bucket.messages.length > 100) {
            bucket.messages.splice(0, bucket.messages.length - 100);
        }
        const windowErrorCount = bucket.timestamps.length;
        // 2. Structured WARN log. Falls back to console.warn if no logger
        // was injected so visibility is never silently dropped.
        const logPayload = {
            pluginId,
            method,
            errorMessage,
            windowErrorCount,
            errorRateThresholdPerHour: this.config.errorRateThresholdPerHour,
        };
        try {
            if (this.config.logger) {
                this.config.logger.warn('plugin-error: plugin hook threw', logPayload);
            }
            else {
                // eslint-disable-next-line no-console
                console.warn('[plugin-system] plugin-error', logPayload);
            }
        }
        catch {
            // Logger must never break the dispatcher. Best-effort stderr.
            try {
                process.stderr.write(`[plugin-system] logger threw while reporting plugin-error for ${pluginId}:${method}\n`);
            }
            catch {
                // last resort; nothing more we can do.
            }
        }
        // 3. Audit-chain event emission.
        if (this.config.onPluginError) {
            const event = {
                type: 'plugin-error',
                pluginId,
                method,
                errorMessage,
                errorStack,
                timestamp: timestampIso,
                windowErrorCount,
            };
            try {
                this.config.onPluginError(event);
            }
            catch (cbErr) {
                // A broken audit sink must not crash the dispatcher, but the
                // failure itself MUST be visible. Downgrade to stderr.
                try {
                    process.stderr.write(`[plugin-system] onPluginError sink threw while chaining ${pluginId}:${method}: ` +
                        `${cbErr?.message ?? String(cbErr)}\n`);
                }
                catch {
                    // nothing more we can do.
                }
            }
        }
        // 4. Threshold alert with hysteresis.
        const threshold = this.config.errorRateThresholdPerHour;
        const wasFired = this.firedState.get(key) ?? false;
        if (!wasFired && windowErrorCount >= threshold) {
            this.firedState.set(key, true);
            if (this.config.onErrorThresholdBreach) {
                const breach = {
                    pluginId,
                    method,
                    currentCount: windowErrorCount,
                    threshold,
                    recentErrors: bucket.messages.slice(-3),
                    firedAt: timestampIso,
                };
                try {
                    this.config.onErrorThresholdBreach(breach);
                }
                catch (cbErr) {
                    try {
                        process.stderr.write(`[plugin-system] onErrorThresholdBreach sink threw for ${pluginId}:${method}: ` +
                            `${cbErr?.message ?? String(cbErr)}\n`);
                    }
                    catch {
                        // last resort.
                    }
                }
            }
        }
        else if (wasFired) {
            // Hysteresis resolve: once the window count drops to half the
            // threshold or below, clear the fired state so a subsequent
            // spike can re-fire. Matches the 0.9-resolve pattern used by
            // AlertThresholdStore at a whole-number granularity.
            const resolveLevel = Math.max(1, Math.floor(threshold * 0.5));
            if (windowErrorCount <= resolveLevel) {
                this.firedState.set(key, false);
            }
        }
    }
    /**
     * Prune sliding-window entries older than PLUGIN_ERROR_WINDOW_MS.
     * Keeps timestamps + messages arrays index-aligned.
     */
    pruneExpired(timestamps, messages, now) {
        const cutoff = now - PLUGIN_ERROR_WINDOW_MS;
        let drop = 0;
        while (drop < timestamps.length && timestamps[drop] < cutoff)
            drop++;
        if (drop > 0) {
            timestamps.splice(0, drop);
            messages.splice(0, drop);
        }
    }
    /**
     * Sanitize a stack string: strip absolute paths, keep only the first
     * 3 frames, flatten whitespace. Matches the denial-reason sanitizer
     * so operators see a consistent shape across plugin failure modes.
     */
    sanitizePluginStack(stack) {
        if (!stack)
            return '';
        const lines = stack.split('\n').slice(0, 4); // err header + 3 frames
        return lines
            .map((l) => l.replace(/\/[\w./-]+/g, '[path]'))
            .join(' | ')
            .replace(/\s+/g, ' ')
            .trim()
            .substring(0, 500);
    }
    validatePlugin(plugin) {
        if (!plugin.manifest) {
            throw new PluginValidationError('unknown', 'Missing manifest');
        }
        const { name, version, description, hooks } = plugin.manifest;
        if (!name || typeof name !== 'string') {
            throw new PluginValidationError(name || 'unknown', 'Missing or invalid name');
        }
        if (!/^[a-z0-9-]+$/.test(name)) {
            throw new PluginValidationError(name, 'Name must be lowercase alphanumeric with hyphens only');
        }
        if (!version || typeof version !== 'string') {
            throw new PluginValidationError(name, 'Missing or invalid version');
        }
        if (!description || typeof description !== 'string') {
            throw new PluginValidationError(name, 'Missing or invalid description');
        }
        if (!Array.isArray(hooks) || hooks.length === 0) {
            throw new PluginValidationError(name, 'Must specify at least one hook');
        }
        const validHooks = [
            'preAction', 'postAction', 'onViolation', 'onAuditEntry', 'onReport', 'onScan',
        ];
        for (const hook of hooks) {
            if (!validHooks.includes(hook)) {
                throw new PluginValidationError(name, `Invalid hook: ${hook}`);
            }
        }
        // Verify that declared hooks have corresponding methods
        for (const hook of hooks) {
            if (typeof plugin[hook] !== 'function' && hook !== 'onAuditEntry') {
                // onAuditEntry is optional even if declared (for passive audit plugins)
            }
        }
    }
    getOrderedPlugins() {
        return Array.from(this.plugins.values()).sort((a, b) => a.priority - b.priority);
    }
    sanitizePluginMessage(message) {
        // AIAG-009: strip internal details from plugin-supplied denial reasons.
        //
        // Order matters: stack frames (`at FnName (/path/to/file.ts:42)`) must
        // be removed FIRST, otherwise the path-stripping pass eats the closing
        // paren and leaves dangling `at FnName ([path]` in the output.
        return message
            .replace(/at\s+\S+\s+\([^)]*\)/g, '') // stack-trace frames
            .replace(/at\s+[\/\w.-]+:\d+:\d+/g, '') // anonymous stack frames like "at /path:line:col"
            .replace(/\/[\w./-]+/g, '[path]') // any remaining absolute paths
            .replace(/\s+/g, ' ') // flatten whitespace (incl. newlines)
            .trim()
            .substring(0, 200); // cap length
    }
}
//# sourceMappingURL=plugin-system.js.map