/**
 * AnomalyAutoSuspend -- wires AnomalyDetector events to the agent state machine.
 *
 * When an anomaly exceeds the configured severity threshold, this module
 * automatically calls the same suspend action that a human operator would
 * invoke via the dashboard (POST /api/v1/agents/:agentId/pause).
 *
 * Default thresholds:
 *   - Behavioral baseline deviation > 3 sigma -> suspend
 *   - Sudden permission escalation attempt     -> suspend immediately
 *   - Communication with known-bad endpoints   -> suspend
 *   - Unusual tool call burst (10x baseline within 60s) -> suspend
 *
 * Integration:
 *   The module subscribes to an AnomalyEventBus and calls the AgentStateMachine
 *   suspend() method on threshold breach. The state machine accepts the same
 *   payload as the dashboard hook, so human override via resume() is unchanged.
 *
 * Audit chain:
 *   Every auto-suspension produces a signed audit event (written to the
 *   provided WebhookDelivery or stderr if no delivery is configured).
 *
 * Onyx #8 gap closure: Anomaly Auto-Suspend Wiring.
 *
 * @connexum/ai-governance
 */
import * as crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// AnomalyAutoSuspend
// ---------------------------------------------------------------------------
export class AnomalyAutoSuspend {
    stateMachine;
    siemDelivery;
    thresholds;
    anomalyTypes;
    /** Track recent tool call counts per agent for burst detection. */
    burstCounters = new Map();
    /** Audit log of all auto-suspension events (capped at 1000). */
    suspensionLog = [];
    constructor(config) {
        this.stateMachine = config.stateMachine;
        this.siemDelivery = config.siemDelivery;
        this.thresholds = {
            baselineDeviationSigma: config.thresholds?.baselineDeviationSigma ?? 3,
            suspendOnPermissionEscalation: config.thresholds?.suspendOnPermissionEscalation ?? true,
            badEndpointPatterns: config.thresholds?.badEndpointPatterns ?? [],
            toolCallBurstMultiplier: config.thresholds?.toolCallBurstMultiplier ?? 10,
            burstWindowMs: config.thresholds?.burstWindowMs ?? 60_000,
        };
        this.anomalyTypes = new Set(config.anomalyTypes ?? ['DEVIATION']);
        // Subscribe to the event bus using both wiring paths (interceptEmit + on())
        this.subscribeToEventBus(config.eventBus);
    }
    /**
     * Subscribe the auto-suspend logic to an already-constructed event bus.
     *
     * Two wiring paths are registered (defense-in-depth):
     *
     * Path 1 -- interceptEmit (existing):
     *   Patches `bus.emit` so that any local emit() call on this pod triggers
     *   handleAnomaly synchronously. Works for both EventBus and RedisEventBus.
     *   Does NOT fire for cross-pod events that arrive via Redis subscriber
     *   (handleIncoming -> dispatch) because those bypass emit().
     *
     * Path 2 -- explicit on('anomaly:detected') (NEW):
     *   Registers a handler via the bus's `on()` method when the bus supports it.
     *   This fires for BOTH local dispatches AND cross-pod Redis events, because
     *   both code paths ultimately call dispatch() which invokes registered handlers.
     *
     * Double-fire protection:
     *   A local emit on this pod triggers both paths (interceptEmit first, then
     *   dispatch calls the on() handler). handleAnomaly() is idempotent: if the
     *   agent is already suspended after the first path fires, the second path's
     *   call returns early at the status check. No duplicate suspension, audit
     *   entry, or webhook fire results.
     *
     * Cross-pod behaviour (Option a -- chosen):
     *   Pod B receives an anomaly for an agent that doesn't run on Pod B. Pod B's
     *   on() handler fires, handleAnomaly() looks up the agent in Pod B's state
     *   machine, finds status='running' (default), evaluates thresholds, and
     *   suspends in Pod B's local state machine. If the agent does not exist on
     *   Pod B (getStatus returns 'running' which is the default), the suspension
     *   is a no-op at the state machine level and is logged at debug level only.
     *   This keeps the architecture compatible with a future federated state machine
     *   without further refactor.
     */
    subscribeToEventBus(eventBus) {
        // -- Path 1: interceptEmit (defense-in-depth for local emits) ----
        const originalEmit = eventBus.emit.bind(eventBus);
        eventBus.emit = (type, data, target) => {
            originalEmit(type, data, target);
            if (type === 'anomaly:detected') {
                this.handleAnomaly(data);
            }
        };
        // -- Path 2: explicit on() handler (cross-pod Redis events) --------
        // Only available on buses that implement the SubscribableEventBus interface.
        const subscribable = eventBus;
        if (typeof subscribable.on === 'function') {
            subscribable.on('anomaly:detected', (event) => {
                // handleAnomaly is idempotent -- safe to call even if interceptEmit
                // already fired for the same event on this pod.
                this.handleAnomaly(event.data);
            });
        }
    }
    /**
     * Evaluate an anomaly and suspend the agent if any threshold is crossed.
     */
    handleAnomaly(anomaly) {
        const agentId = anomaly.agentName;
        // Skip if agent is already suspended
        const currentStatus = this.stateMachine.getStatus(agentId);
        if (currentStatus === 'paused' || currentStatus === 'stopped' || currentStatus === 'auto-suspended-pending-review') {
            return;
        }
        // Filter by configured anomaly types
        if (!this.anomalyTypes.has(anomaly.type)) {
            return;
        }
        let shouldSuspend = false;
        let suspendReason = '';
        // ---- Threshold 1: Behavioral baseline deviation > N sigma ----
        // The AnomalyDetector.endCycle() fires when value > baseline * multiplier.
        // Treat any DEVIATION anomaly as a > 3-sigma event (the detector already
        // gates on its configured multiplier, which defaults to 3).
        if (anomaly.type === 'DEVIATION') {
            const sigma = anomaly.baseline > 0
                ? (anomaly.value - anomaly.baseline) / Math.max(anomaly.baseline * 0.3, 1)
                : 0;
            if (sigma > this.thresholds.baselineDeviationSigma) {
                shouldSuspend = true;
                suspendReason = `Behavioral baseline deviation: ${anomaly.metric} = ${anomaly.value} ` +
                    `(baseline=${anomaly.baseline}, sigma_approx=${sigma.toFixed(1)}, ` +
                    `threshold=${this.thresholds.baselineDeviationSigma}sigma)`;
            }
        }
        // ---- Threshold 2: Permission escalation attempt ----
        // Detected when the metric is 'File writes' and they include
        // paths outside the agent's declared directory, or when toolName
        // contains escalation keywords.
        if (!shouldSuspend && this.thresholds.suspendOnPermissionEscalation) {
            const isEscalation = anomaly.metric.toLowerCase().includes('permission') ||
                anomaly.metric.toLowerCase().includes('escalat') ||
                anomaly.metric.toLowerCase().includes('privilege');
            if (isEscalation) {
                shouldSuspend = true;
                suspendReason = `Permission escalation attempt detected: ${anomaly.metric}`;
            }
        }
        // ---- Threshold 3: Communication with known-bad endpoints ----
        if (!shouldSuspend && this.thresholds.badEndpointPatterns.length > 0) {
            const metricLower = anomaly.metric.toLowerCase();
            const agentNameLower = agentId.toLowerCase();
            const combined = metricLower + ' ' + agentNameLower;
            const badMatch = this.thresholds.badEndpointPatterns.find(p => combined.includes(p.toLowerCase()));
            if (badMatch) {
                shouldSuspend = true;
                suspendReason = `Communication with known-bad endpoint pattern '${badMatch}': ${anomaly.metric}`;
            }
        }
        // ---- Threshold 4: Unusual tool call burst ----
        if (!shouldSuspend && anomaly.metric.toLowerCase().includes('tool call')) {
            const now = Date.now();
            const counter = this.burstCounters.get(agentId) ?? { count: 0, windowStart: now };
            // Reset window if expired
            if (now - counter.windowStart > this.thresholds.burstWindowMs) {
                counter.count = 0;
                counter.windowStart = now;
            }
            counter.count++;
            this.burstCounters.set(agentId, counter);
            // Burst if count exceeds baseline * multiplier within the window
            const burstThreshold = Math.max(10, anomaly.baseline * this.thresholds.toolCallBurstMultiplier);
            if (counter.count > burstThreshold) {
                shouldSuspend = true;
                suspendReason = `Tool call burst: ${counter.count} calls in ${this.thresholds.burstWindowMs}ms ` +
                    `(threshold=${burstThreshold}, baseline=${anomaly.baseline})`;
                // Reset counter after triggering to avoid repeated suspensions
                this.burstCounters.delete(agentId);
            }
        }
        if (!shouldSuspend)
            return;
        // Execute suspension
        this.suspend(agentId, suspendReason, anomaly);
    }
    /**
     * Execute the suspension: update state machine, emit audit event, notify SIEM.
     */
    suspend(agentId, reason, triggeredBy) {
        const suspendedAt = new Date().toISOString();
        const contentHash = crypto
            .createHash('sha256')
            .update(JSON.stringify({ agentId, reason, triggeredBy, suspendedAt }))
            .digest('hex');
        const event = {
            eventId: crypto.randomUUID(),
            agentId,
            triggeredBy,
            threshold: reason,
            suspendedAt,
            contentHash,
        };
        // Update state machine -- same path as human operator clicking "pause"
        const fullReason = `[AUTO-SUSPEND] ${reason}. Status: auto-suspended-pending-review. ` +
            `Human review required before resuming via dashboard.`;
        this.stateMachine.suspend(agentId, fullReason);
        // Log suspension event
        this.suspensionLog.push(event);
        if (this.suspensionLog.length > 1000) {
            this.suspensionLog.shift();
        }
        // Stderr audit (always -- even if SIEM delivery fails)
        process.stderr.write(`[AnomalyAutoSuspend] AGENT SUSPENDED: agentId=${agentId} ` +
            `reason="${reason}" eventId=${event.eventId} hash=${contentHash}\n`);
        // SIEM notification via webhook delivery
        if (this.siemDelivery) {
            this.siemDelivery.deliver({
                type: 'agent:auto-suspended',
                source: 'anomaly-auto-suspend',
                data: {
                    agentId,
                    reason,
                    triggeredByAnomaly: {
                        type: triggeredBy.type,
                        metric: triggeredBy.metric,
                        value: triggeredBy.value,
                        baseline: triggeredBy.baseline,
                        threshold: triggeredBy.threshold,
                    },
                    suspendedAt,
                    eventId: event.eventId,
                    contentHash,
                    humanReviewRequired: true,
                    resumeVia: `POST /api/v1/agents/${agentId}/resume`,
                },
                timestamp: suspendedAt,
            }).catch((err) => {
                process.stderr.write(`[AnomalyAutoSuspend] SIEM delivery failed for agentId=${agentId}: ${err.message}\n`);
            });
        }
    }
    /**
     * Get the suspension audit log.
     */
    getSuspensionLog() {
        return [...this.suspensionLog];
    }
    /**
     * Update thresholds at runtime (without restarting the subscription).
     */
    updateThresholds(thresholds) {
        Object.assign(this.thresholds, thresholds);
    }
    /**
     * Get current effective thresholds.
     */
    getThresholds() {
        return { ...this.thresholds };
    }
}
// ---------------------------------------------------------------------------
// InMemoryAgentStateMachine
// ---------------------------------------------------------------------------
/**
 * Concrete AgentStateMachine backed by an in-memory Map.
 * Matches the shape of agentControlState in packages/governance-server/src/index.ts.
 *
 * Production: inject the real agentControlState Map via the adapter below.
 * Testing: use this standalone implementation.
 */
export class InMemoryAgentStateMachine {
    state = new Map();
    suspend(agentId, reason) {
        this.state.set(agentId, {
            status: 'auto-suspended-pending-review',
            updatedAt: new Date().toISOString(),
            reason,
        });
    }
    resume(agentId) {
        this.state.set(agentId, {
            status: 'running',
            updatedAt: new Date().toISOString(),
        });
    }
    getStatus(agentId) {
        return this.state.get(agentId)?.status ?? 'running';
    }
    canExecute(agentId) {
        const status = this.getStatus(agentId);
        return status === 'running';
    }
    /** Expose the underlying map for governance server integration. */
    getStateMap() {
        return this.state;
    }
}
/**
 * Adapt the governance server's existing agentControlState Map to the
 * AgentStateMachine interface without modifying the Map's existing shape.
 *
 * Usage (in packages/governance-server/src/index.ts):
 *
 *   const autoSuspendMachine = new GovernanceServerStateMachineAdapter(agentControlState);
 *   const autoSuspend = new AnomalyAutoSuspend({
 *     eventBus: anomalyEventBus,
 *     stateMachine: autoSuspendMachine,
 *     siemDelivery: webhookDelivery,
 *   });
 */
export class GovernanceServerStateMachineAdapter {
    agentControlState;
    onSuspend;
    onResume;
    constructor(agentControlState, onSuspend, onResume) {
        this.agentControlState = agentControlState;
        this.onSuspend = onSuspend;
        this.onResume = onResume;
    }
    suspend(agentId, reason) {
        this.agentControlState.set(agentId, {
            status: 'paused',
            updatedAt: new Date().toISOString(),
            reason,
        });
        this.onSuspend?.(agentId, reason);
    }
    resume(agentId) {
        this.agentControlState.set(agentId, {
            status: 'running',
            updatedAt: new Date().toISOString(),
        });
        this.onResume?.(agentId);
    }
    getStatus(agentId) {
        return this.agentControlState.get(agentId)?.status ?? 'running';
    }
    canExecute(agentId) {
        const status = this.getStatus(agentId);
        return status === 'running';
    }
}
//# sourceMappingURL=anomaly-auto-suspend.js.map