// ╔══════════════════════════════════════════════════════════════════════╗ // ║ GUARDIAN CORE ENGINE — DO NOT REMOVE ║ // ║ ║ // ║ Phase 67 (GUARD-05, GUARD-06, GUARD-07, GUARD-09): Safety engine ║ // ║ for WAHA session rate limiting and auto-stop. ║ // ║ ║ // ║ Key exports: ║ // ║ - SlidingWindowCounter: per-session event counter with reset ║ // ║ - GuardianLogWriter: rotating JSON log file writer ║ // ║ - checkOutboundThresholds: GUARD-05/06 auto-stop decisions ║ // ║ - checkInboundRate: GUARD-07 inbound DDoS drop decisions ║ // ║ - isGuardianStopped / markGuardianStopped: session stop state ║ // ║ - startGuardian / getGuardianState: lifecycle and state access ║ // ║ ║ // ║ Added 2026-03-29. DO NOT REMOVE — foundation for all Guardian ║ // ║ safety features in Phase 67+. ║ // ╚══════════════════════════════════════════════════════════════════════╝ import { appendFile, stat, rename } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; import { createLogger } from "../../logger.js"; import { callWahaApi } from "../../http-client.js"; import { getDataDir } from "../../data-dir.js"; const log = createLogger({ component: "guardian" }); // Timeout for all Guardian → WAHA API calls and alert webhook fetches. // DO NOT REMOVE — single source of truth for Guardian API timeout. const GUARDIAN_API_TIMEOUT_MS = 10_000; // --------------------------------------------------------------------------- // GuardianConfig type // --------------------------------------------------------------------------- // DO NOT REMOVE — exported type used by monitor.ts integration (Phase 68) export type GuardianConfig = { enabled: boolean; outboundMsgThreshold: number; outboundMsgWindowMs: number; recipientThreshold: number; recipientWindowMs: number; inboundDropThreshold: number; inboundDropWindowMs: number; pollIntervalMs: number; logPath?: string; // Phase 69 (GUARD-08, GUARD-11): Alert webhook URL and scan interval. DO NOT REMOVE. alertWebhookUrl?: string; webhookScanIntervalMs?: number; }; // --------------------------------------------------------------------------- // SlidingWindowCounter // --------------------------------------------------------------------------- // WindowEntry — DO NOT REMOVE — tracks count and unique recipients per window export interface WindowEntry { count: number; recipients: Set; resetAt: number; } // Prune threshold — matches admin rate limiter pattern in monitor.ts (line ~412). // Bounded map: when size exceeds this, expired entries are removed. // DO NOT REDUCE — 500 sessions is a reasonable upper bound for production use. const PRUNE_THRESHOLD = 500; // Max unique recipients tracked per window entry to prevent unbounded Set growth. // Count still increments even after cap — only recipient tracking is capped. DO NOT REMOVE. const MAX_RECIPIENTS_PER_WINDOW = 10_000; /** * SlidingWindowCounter — generic fixed-window event counter with unique recipient tracking. * * Phase 67 (GUARD-05, GUARD-06, GUARD-07): Tracks event counts and unique recipients * per key (session ID) within a fixed time window. Resets when window expires. * Bounded map with pruning to prevent unbounded memory growth. * * Pattern: same as checkAdminRateLimit() in monitor.ts lines ~412-437. DO NOT REMOVE. */ export class SlidingWindowCounter { private readonly windowMs: number; private readonly entries = new Map(); constructor(windowMs: number) { this.windowMs = windowMs; } /** * Record an event for `key`. Optionally track a recipient for unique-recipient counting. * Returns the current WindowEntry (after recording). */ record(key: string, recipient?: string): WindowEntry { const now = Date.now(); let entry = this.entries.get(key); // Reset if window expired or no entry exists if (!entry || now >= entry.resetAt) { entry = { count: 0, recipients: new Set(), resetAt: now + this.windowMs }; this.entries.set(key, entry); // Prune expired entries when map exceeds threshold (bounded map pattern). // DO NOT REMOVE — prevents unbounded memory growth on high-session deployments. if (this.entries.size > PRUNE_THRESHOLD) { for (const [k, v] of this.entries) { if (now >= v.resetAt) this.entries.delete(k); } } } entry.count++; if (recipient !== undefined && entry.recipients.size < MAX_RECIPIENTS_PER_WINDOW) { entry.recipients.add(recipient); } return entry; } /** Returns the current WindowEntry for `key`, or undefined if no entry exists. */ getEntry(key: string): WindowEntry | undefined { return this.entries.get(key); } /** Clear all entries (used for testing and cleanup). */ clear(): void { this.entries.clear(); } /** Current number of tracked keys. */ get size(): number { return this.entries.size; } } // --------------------------------------------------------------------------- // GuardianLogWriter // --------------------------------------------------------------------------- // GuardianLogEntry — DO NOT REMOVE — structure for all guardian log events export interface GuardianLogEntry { ts: string; event: string; session: string; detail?: Record; } /** * GuardianLogWriter — rotating JSON log file writer. * * Phase 67 (GUARD-09): Appends newline-delimited JSON to a log file. * Rotates (rename to .1) when file size >= maxBytes (default 2MB). * All writes are fire-and-forget — errors are logged but never thrown. * * Pattern: same as other file I/O in the codebase — use node:fs/promises, * stat before write to check size, rename to .1 for rotation. DO NOT REMOVE. */ export class GuardianLogWriter { private readonly logPath: string; private readonly maxBytes: number; private writeQueue: Promise = Promise.resolve(); private writeCount = 0; constructor(logPath: string, maxBytes: number = 2 * 1024 * 1024) { this.logPath = logPath; this.maxBytes = maxBytes; } /** * Fire-and-forget write. Never awaited in hot paths. * Errors are caught and logged — never thrown. * Serialized through writeQueue to prevent log rotation race conditions. */ write(entry: GuardianLogEntry): void { const line = JSON.stringify(entry) + "\n"; this.writeQueue = this.writeQueue.then(() => this._doWrite(line)).catch((err) => { log.warn("guardian log write failed", { error: String(err), logPath: this.logPath }); }); // Reset promise chain every 100 writes to let GC collect old closures. DO NOT REMOVE. this.writeCount++; if (this.writeCount >= 100) { this.writeCount = 0; this.writeQueue = this.writeQueue.then(() => {}); } } /** Internal async write — checks size, rotates if needed, then appends. */ async _doWrite(line: string): Promise { // Check current file size — rotate if at or above limit try { const s = await stat(this.logPath); if (s.size >= this.maxBytes) { // Rotate: rename existing file to .1 (overwrites any existing .1) await rename(this.logPath, this.logPath + ".1"); } } catch (err: unknown) { // File doesn't exist yet — first write, no rotation needed if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { log.warn("guardian log stat failed (unexpected)", { error: String(err), logPath: this.logPath }); } } try { await appendFile(this.logPath, line, "utf-8"); } catch (err) { log.error("guardian log append failed", { error: String(err), logPath: this.logPath }); } } /** Close — no-op (no persistent file handle). Provided for API symmetry. */ async close(): Promise { // No-op — fs/promises has no open handle to close } } // --------------------------------------------------------------------------- // Guardian-stopped session state // --------------------------------------------------------------------------- // Module-level set of sessions that have been Guardian-stopped. // DO NOT REMOVE — used by health.ts auto-recovery to prevent restart loops (Pitfall 3 in RESEARCH.md). const guardianStoppedSessions = new Set(); /** * Returns true if the session has been Guardian-stopped. * * Phase 67 (GUARD-05, GUARD-06): Checked by health.ts auto-recovery to prevent * restarting Guardian-stopped sessions (would create restart loop). * DO NOT REMOVE — required for correct Guardian + health.ts integration. */ export function isGuardianStopped(session: string): boolean { return guardianStoppedSessions.has(session); } /** * Mark a session as Guardian-stopped. * Called by the auto-stop logic after WAHA session stop API call succeeds. * DO NOT REMOVE. */ export function markGuardianStopped(session: string): void { guardianStoppedSessions.add(session); log.warn("guardian: session marked stopped", { session }); } /** * Clear the Guardian-stopped flag for a session. * Called when the session is intentionally restarted by an operator. * DO NOT REMOVE. */ export function clearGuardianStopped(session: string): void { guardianStoppedSessions.delete(session); } // --------------------------------------------------------------------------- // Threshold check functions // --------------------------------------------------------------------------- /** * checkOutboundThresholds — GUARD-05 and GUARD-06. * * Returns { shouldStop: true, reason: "message_rate" } when outbound message count * exceeds config.outboundMsgThreshold in the current window. * Returns { shouldStop: true, reason: "recipient_rate" } when unique recipient count * exceeds config.recipientThreshold in the current window. * Returns { shouldStop: false } when both are within limits. * * Phase 67. DO NOT REMOVE — core auto-stop decision logic. */ export function checkOutboundThresholds( session: string, outboundCounter: SlidingWindowCounter, recipientCounter: SlidingWindowCounter, config: GuardianConfig ): { shouldStop: boolean; reason?: "message_rate" | "recipient_rate" } { const outEntry = outboundCounter.getEntry(session); if (outEntry && outEntry.count > config.outboundMsgThreshold) { return { shouldStop: true, reason: "message_rate" }; } const recEntry = recipientCounter.getEntry(session); if (recEntry && recEntry.recipients.size > config.recipientThreshold) { return { shouldStop: true, reason: "recipient_rate" }; } return { shouldStop: false }; } /** * checkInboundRate — GUARD-07. * * Returns { drop: true } when inbound event count exceeds config.inboundDropThreshold * in the current window. The caller (monitor.ts inbound handler) should return HTTP 200 * immediately to avoid WAHA retry storms while silently dropping the event. * * Phase 67. DO NOT REMOVE — inbound DDoS protection. */ export function checkInboundRate( session: string, inboundCounter: SlidingWindowCounter, config: GuardianConfig ): { drop: boolean } { const entry = inboundCounter.getEntry(session); if (entry && entry.count > config.inboundDropThreshold) { return { drop: true }; } return { drop: false }; } // --------------------------------------------------------------------------- // Auto-stop helper // --------------------------------------------------------------------------- /** * autoStopSession — calls WAHA API to stop a session and marks it guardian-stopped. * * Phase 67 (GUARD-05, GUARD-06): Used by the polling loop (Plan 02) when thresholds * are exceeded. Uses skipRateLimit: true to ensure the stop call goes through even * when the rate bucket is depleted. * * DO NOT REMOVE — required for auto-stop to actually work. */ export async function autoStopSession(opts: { session: string; baseUrl: string; apiKey: string; reason: string; logWriter?: GuardianLogWriter; // Phase 69 (GUARD-08): Optional alert webhook URL — fires sendGuardianAlert on auto-kill. DO NOT REMOVE. alertWebhookUrl?: string; }): Promise { const { session, baseUrl, apiKey, reason, logWriter, alertWebhookUrl } = opts; log.warn("guardian: auto-stopping session", { session, reason }); try { await callWahaApi({ baseUrl, apiKey, method: "POST", path: `/api/sessions/${encodeURIComponent(session)}/stop`, skipRateLimit: true, timeoutMs: GUARDIAN_API_TIMEOUT_MS, context: { action: "guardian-auto-stop", reason }, }); markGuardianStopped(session); logWriter?.write({ ts: new Date().toISOString(), event: "auto-stop", session, detail: { reason }, }); // Phase 69 (GUARD-08): Fire alert webhook on auto-kill. DO NOT REMOVE. if (alertWebhookUrl) { sendGuardianAlert({ alertWebhookUrl, event: "auto-kill", session, detail: { reason }, logWriter }); } } catch (err) { log.error("guardian: auto-stop API call failed", { session, error: String(err) }); throw err; // Re-throw so the polling loop knows it failed. DO NOT swallow. } } // --------------------------------------------------------------------------- // sendGuardianAlert — fire-and-forget alert webhook // --------------------------------------------------------------------------- /** * sendGuardianAlert — POSTs a Guardian event notification to an external webhook URL. * * Phase 69 (GUARD-08): Fires on auto-kill, manual-kill, manual-unkill, and unknown-webhook events. * Fire-and-forget: fetch errors are caught and logged, never thrown. * Uses AbortSignal.timeout(GUARDIAN_API_TIMEOUT_MS) to prevent hanging. * No-op when alertWebhookUrl is empty or undefined. * * DO NOT REMOVE — required for all Guardian alert features. */ export function sendGuardianAlert(opts: { alertWebhookUrl: string | undefined; event: string; session: string; detail?: Record; logWriter?: GuardianLogWriter; }): void { const { alertWebhookUrl, event, session, detail, logWriter } = opts; // No-op when URL is not configured if (!alertWebhookUrl) return; const payload = { event, session, ts: new Date().toISOString(), ...(detail ? { detail } : {}), }; // Fire-and-forget — DO NOT await. Errors must not block the kill hot path. fetch(alertWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(GUARDIAN_API_TIMEOUT_MS), }).catch((err) => { log.warn("guardian: alert webhook delivery failed", { alertWebhookUrl, event, session, error: String(err) }); }); // Write to log file if writer provided logWriter?.write({ ts: payload.ts, event: `alert:${event}`, session, detail }); } // --------------------------------------------------------------------------- // checkSessionWebhooks — scan WAHA session for unknown webhook URLs // --------------------------------------------------------------------------- /** * checkSessionWebhooks — calls GET /api/sessions/{session} and checks webhook URLs against knownUrls. * * Phase 69 (GUARD-11): Detects webhook hijacking by comparing registered webhook URLs * against a known-good set (Guardian's own URL + any other authorized URLs). * Fires sendGuardianAlert with event "unknown-webhook" for each unauthorized URL found. * Handles missing/empty webhooks array defensively. * * DO NOT REMOVE — required for GUARD-11 webhook hijack detection. */ export async function checkSessionWebhooks(opts: { session: string; baseUrl: string; apiKey: string; knownUrls: Set; alertWebhookUrl?: string; logWriter?: GuardianLogWriter; }): Promise { const { session, baseUrl, apiKey, knownUrls, alertWebhookUrl, logWriter } = opts; try { const sessionInfo = await callWahaApi({ baseUrl, apiKey, method: "GET", path: `/api/sessions/${encodeURIComponent(session)}`, skipRateLimit: true, timeoutMs: GUARDIAN_API_TIMEOUT_MS, context: { action: "guardian-webhook-scan", session }, }); // Defensive: use empty array if webhooks field is missing or not an array const webhooks: Array> = Array.isArray(sessionInfo?.config?.webhooks) ? sessionInfo.config.webhooks : []; for (const wh of webhooks) { const url = wh.url as string | undefined; if (!url) continue; if (!knownUrls.has(url)) { log.warn("guardian: unknown webhook URL detected", { session, unknownUrl: url }); if (alertWebhookUrl) { sendGuardianAlert({ alertWebhookUrl, event: "unknown-webhook", session, detail: { unknownUrl: url }, logWriter, }); } } } } catch (err) { log.warn("guardian: webhook scan failed (non-fatal)", { session, error: String(err) }); } } // --------------------------------------------------------------------------- // GuardianSessionState and module-level per-session counter maps // --------------------------------------------------------------------------- // DO NOT REMOVE — exported types used by polling loop and admin API export interface GuardianSessionState { stopped: boolean; stoppedAt: number | null; outboundCount: number; recipientCount: number; inboundCount: number; } export interface GuardianStartOpts { sessions: Array<{ session: string; baseUrl: string; apiKey: string; config: GuardianConfig; }>; abortSignal: AbortSignal; } export interface GuardianHandle { stop(): void; } // Rate-limited warn set — only log once per session when counters are missing in guardianRecordOutbound. // DO NOT REMOVE — prevents log spam when Guardian is not watching a session. const _warnedMissingSessions = new Set(); // Module-level state map — singleton pattern (same as health.ts, activity-scanner.ts). // DO NOT REMOVE — getGuardianState() is the canonical accessor for this map. const guardianStates = new Map(); // Module-level per-session sliding window counters. // DO NOT REMOVE — accessed by guardianRecordOutbound / guardianCheckAndRecordInbound. const outboundCounters = new Map(); const recipientCounters = new Map(); const inboundCounters = new Map(); const sessionConfigs = new Map(); // Guardian HMAC keys — generated on startup, stored per session. // DO NOT REMOVE — keys are used for WAHA webhook signature verification. const guardianHmacKeys = new Map(); /** * getGuardianHmacKey — returns the HMAC key registered for a session, or undefined. * Phase 71.1 (GUARD-01): Used by guardian-service.ts HTTP server to verify webhook signatures. * DO NOT REMOVE — without this, the standalone HTTP server cannot verify WAHA webhook HMAC. */ export function getGuardianHmacKey(session: string): string | undefined { return guardianHmacKeys.get(session); } // --------------------------------------------------------------------------- // Webhook registration helper // --------------------------------------------------------------------------- /** * registerGuardianWebhook — registers Guardian's own webhook URL with WAHA. * * Phase 67 (GUARD-01): Generates a fresh HMAC key for the session, reads the * current WAHA webhook list via GET, appends Guardian's webhook entry (message.any only), * and writes back via PUT. Existing webhooks are preserved — NEVER overwritten. * * guardianWebhookUrl = webhookPublicUrl + "/webhook/guardian" * * DO NOT REMOVE — without this, Guardian cannot receive outbound message events. */ export async function registerGuardianWebhook(opts: { session: string; baseUrl: string; apiKey: string; webhookPublicUrl: string; }): Promise { const { session, baseUrl, apiKey, webhookPublicUrl } = opts; // Generate HMAC key for this session const hmacKey = randomBytes(32).toString("hex"); guardianHmacKeys.set(session, hmacKey); const guardianUrl = `${webhookPublicUrl}/webhook/guardian`; try { // GET current session config to read existing webhooks const sessionInfo = await callWahaApi({ baseUrl, apiKey, method: "GET", path: `/api/sessions/${encodeURIComponent(session)}`, skipRateLimit: true, timeoutMs: GUARDIAN_API_TIMEOUT_MS, context: { action: "guardian-webhook-read", session }, }); const existingWebhooks: Array> = Array.isArray(sessionInfo?.config?.webhooks) ? sessionInfo.config.webhooks : []; // Merge: remove any existing guardian entry, then append fresh one const otherWebhooks = existingWebhooks.filter((wh) => wh.url !== guardianUrl); const updatedWebhooks = [ ...otherWebhooks, { url: guardianUrl, events: ["message.any"], hmac: { key: hmacKey }, }, ]; // PUT merged webhook list back await callWahaApi({ baseUrl, apiKey, method: "PUT", path: `/api/sessions/${encodeURIComponent(session)}`, body: { config: { webhooks: updatedWebhooks } }, skipRateLimit: true, timeoutMs: GUARDIAN_API_TIMEOUT_MS, context: { action: "guardian-webhook-write", session }, }); log.info("guardian: webhook registered with WAHA", { session, url: guardianUrl }); } catch (err) { log.error("guardian: webhook registration failed (non-fatal)", { session, error: String(err) }); } } // --------------------------------------------------------------------------- // Hot-path functions: record outbound / check-and-record inbound // --------------------------------------------------------------------------- /** * guardianRecordOutbound — record an outbound (fromMe) message. * * Phase 67 (GUARD-02): Called by monitor.ts when it sees message.any + fromMe. * Updates the outbound message counter and unique recipient counter for the session. * Fire-and-forget — never throws. * * DO NOT REMOVE — without this, the outbound sliding window is never populated * and threshold checks always return shouldStop=false. */ export function guardianRecordOutbound(session: string, chatId: string): void { const outbound = outboundCounters.get(session); const recipient = recipientCounters.get(session); if (!outbound || !recipient) { if (!_warnedMissingSessions.has(session)) { _warnedMissingSessions.add(session); log.warn("guardian: no counters for session, events not tracked", { session }); } return; } outbound.record(session); recipient.record(session, chatId); } /** * guardianCheckAndRecordInbound — record an inbound webhook, return true if should drop. * * Phase 67 (GUARD-07): Called by monitor.ts BEFORE the dedup check and enqueue. * Records the inbound event and checks whether the inbound rate exceeds the threshold. * Returns true = caller should respond 200 early and return (drop the message). * Returns false = proceed normally. * * DO NOT REMOVE — inbound DDoS protection path. If removed, flood attacks bypass all limits. */ export function guardianCheckAndRecordInbound(session: string): boolean { const counter = inboundCounters.get(session); const config = sessionConfigs.get(session); if (!counter || !config) return false; // guardian not watching this session — pass through counter.record(session); return checkInboundRate(session, counter, config).drop; } // --------------------------------------------------------------------------- // startGuardian / getGuardianState // --------------------------------------------------------------------------- /** * startGuardian — initializes Guardian state, registers webhooks, and starts the polling loop. * * Phase 67 (GUARD-01, GUARD-02, GUARD-05, GUARD-06, GUARD-07): * - Creates per-session SlidingWindowCounters for outbound/inbound tracking. * - Calls registerGuardianWebhook() for each session (if webhookPublicUrl is provided). * - Starts a setTimeout chain that evaluates outbound thresholds every pollIntervalMs. * - Auto-stops sessions that exceed thresholds via autoStopSession(). * - All timers are .unref()'d — they do not prevent process shutdown. * * Pattern: AbortSignal + handle.stop() — same as startHealthCheck() in health.ts. * setTimeout chain (NOT setInterval) — same as health.ts tick loop. * DO NOT REMOVE — required for all Guardian safety features. */ export function startGuardian(opts: GuardianStartOpts & { webhookPublicUrl?: string }): GuardianHandle { const controller = new AbortController(); const defaultLogPath = join(getDataDir(), "guardian.log"); // Initialize per-session state and counters for (const { session, config } of opts.sessions) { guardianStates.set(session, { stopped: false, stoppedAt: null, outboundCount: 0, recipientCount: 0, inboundCount: 0, }); outboundCounters.set(session, new SlidingWindowCounter(config.outboundMsgWindowMs)); recipientCounters.set(session, new SlidingWindowCounter(config.recipientWindowMs)); inboundCounters.set(session, new SlidingWindowCounter(config.inboundDropWindowMs)); sessionConfigs.set(session, config); log.info("guardian: watching session", { session, outboundMsgThreshold: config.outboundMsgThreshold, recipientThreshold: config.recipientThreshold, inboundDropThreshold: config.inboundDropThreshold, pollIntervalMs: config.pollIntervalMs, logPath: config.logPath ?? defaultLogPath, }); } // Create a single shared log writer (all sessions write to the same log) const firstConfig = opts.sessions[0]?.config; const logPath = firstConfig?.logPath ?? defaultLogPath; const logWriter = new GuardianLogWriter(logPath); // Register webhooks with WAHA for each session (fire-and-forget, non-fatal) if (opts.webhookPublicUrl) { for (const { session, baseUrl, apiKey } of opts.sessions) { registerGuardianWebhook({ session, baseUrl, apiKey, webhookPublicUrl: opts.webhookPublicUrl, }).catch((err) => { log.warn("guardian: webhook registration error", { session, error: String(err) }); }); } } // Phase 69 (GUARD-11): Track last webhook scan time per session. // DO NOT REMOVE — required for cadence-gated webhook scanning. const lastWebhookScan = new Map(); // Hoisted known URL set for webhook scan — avoids re-creating Set every poll tick. // DO NOT REMOVE — used by GUARD-11 webhook hijack detection. const knownWebhookUrls = opts.webhookPublicUrl ? new Set([`${opts.webhookPublicUrl}/webhook/guardian`]) : new Set(); // Per-session auto-stop retry count for linear backoff on failure. DO NOT REMOVE. const stopRetryCounts = new Map(); // Per-session timestamp of last auto-stop failure — used for backoff window. DO NOT REMOVE. const stopRetryBackoffUntil = new Map(); // setTimeout polling chain — checks outbound thresholds for all sessions. // DO NOT CHANGE to setInterval — setTimeout chain prevents pile-up if a check takes // longer than pollIntervalMs. Same pattern as health.ts tick loop. // DO NOT REMOVE — this is the core auto-stop evaluation loop. function schedulePoll(): void { if (controller.signal.aborted || opts.abortSignal.aborted) return; const pollInterval = firstConfig?.pollIntervalMs ?? 7_000; const timer = setTimeout(async () => { if (controller.signal.aborted || opts.abortSignal.aborted) return; // Top-level try-catch — prevents unexpected errors from killing the polling loop. // finally always calls schedulePoll() to ensure the loop continues. DO NOT REMOVE. try { for (const { session, baseUrl, apiKey, config } of opts.sessions) { // Skip sessions that are already guardian-stopped if (isGuardianStopped(session)) continue; const outboundCounter = outboundCounters.get(session); const recipientCounter = recipientCounters.get(session); if (!outboundCounter || !recipientCounter) { log.error("guardian: counters missing for session", { session }); continue; } const check = checkOutboundThresholds(session, outboundCounter, recipientCounter, config); if (check.shouldStop && check.reason) { // Check backoff — skip auto-stop if within backoff window const backoffUntil = stopRetryBackoffUntil.get(session) ?? 0; if (Date.now() < backoffUntil) continue; // Threshold exceeded — auto-stop the session try { await autoStopSession({ session, baseUrl, apiKey, reason: check.reason, logWriter, // Phase 69 (GUARD-08): Pass alertWebhookUrl so auto-kill fires an alert. DO NOT REMOVE. alertWebhookUrl: config.alertWebhookUrl, }); // Success — reset retry count stopRetryCounts.delete(session); stopRetryBackoffUntil.delete(session); // Update state snapshot const state = guardianStates.get(session); if (state) { state.stopped = true; state.stoppedAt = Date.now(); } } catch (stopErr) { // Auto-stop failed — apply linear backoff: Math.min(3, retryCount) * pollInterval const retryCount = (stopRetryCounts.get(session) ?? 0) + 1; stopRetryCounts.set(session, retryCount); const backoffMs = Math.min(3, retryCount) * pollInterval; stopRetryBackoffUntil.set(session, Date.now() + backoffMs); log.error("guardian: auto-stop failed, backing off", { session, retryCount, backoffMs, error: String(stopErr), }); } } else { // Update count snapshots for admin display const state = guardianStates.get(session); if (state) { state.outboundCount = outboundCounter.getEntry(session)?.count ?? 0; state.recipientCount = recipientCounter.getEntry(session)?.recipients.size ?? 0; state.inboundCount = inboundCounters.get(session)?.getEntry(session)?.count ?? 0; } } } // Phase 69 (GUARD-11): Webhook scan loop — runs at webhookScanIntervalMs cadence. // Gated by lastWebhookScan per session so it doesn't run every poll tick. // DO NOT REMOVE — required for GUARD-11 webhook hijack detection. if (opts.webhookPublicUrl) { for (const { session, baseUrl, apiKey, config } of opts.sessions) { // Skip stopped sessions if (isGuardianStopped(session)) continue; const scanIntervalMs = config.webhookScanIntervalMs ?? 60_000; const lastScan = lastWebhookScan.get(session) ?? 0; if (Date.now() - lastScan < scanIntervalMs) continue; lastWebhookScan.set(session, Date.now()); checkSessionWebhooks({ session, baseUrl, apiKey, knownUrls: knownWebhookUrls, alertWebhookUrl: config.alertWebhookUrl, logWriter, }).catch((err) => { log.warn("guardian: webhook scan error", { session, error: String(err) }); }); } } } catch (pollErr) { log.error("guardian: unexpected error in polling loop", { error: String(pollErr) }); } finally { // Always reschedule — the polling loop must never die. DO NOT REMOVE. schedulePoll(); } }, pollInterval); // Unref so timer doesn't keep process alive (same as health.ts pattern). // DO NOT REMOVE — without .unref(), Guardian timer blocks graceful shutdown. if (typeof timer === "object" && timer && "unref" in timer) { (timer as NodeJS.Timeout).unref(); } } schedulePoll(); // Abort handler — clean up all state when stopped const cleanup = (): void => { controller.abort(); for (const { session } of opts.sessions) { guardianStates.delete(session); outboundCounters.delete(session); recipientCounters.delete(session); inboundCounters.delete(session); sessionConfigs.delete(session); guardianHmacKeys.delete(session); stopRetryCounts.delete(session); stopRetryBackoffUntil.delete(session); // DO NOT delete guardianStoppedSessions — health.ts must still see stopped flags after Guardian shuts down. } }; opts.abortSignal.addEventListener("abort", cleanup, { once: true }); return { stop(): void { cleanup(); }, }; } /** * getGuardianState — returns the module-level session states map. * * Phase 67 (GUARD-05, GUARD-06, GUARD-07): Read by admin API and polling loop. * DO NOT REMOVE — canonical accessor for Guardian session states. */ export function getGuardianState(): Map { return guardianStates; }