From 502ab6d7d5abd6ee1ceafefc37fb04b54ec3a13e Mon Sep 17 00:00:00 2001 From: JBAhire Date: Thu, 2 Apr 2026 22:18:41 -0700 Subject: [PATCH 1/2] feat: focus daemon on OpenClaw/MCP monitoring Refocus the daemon on what matters for open-source users: detecting running AI agents, monitoring OpenClaw skill integrity, and tracking MCP config drift. - Rewrite daemon runner for focused monitoring loop - Remove enterprise modules (behavioral-baseline, correlation-engine, cost-monitor, enforcement, event-receiver, fleet) - Remove related tests - Keep core: agent detection, OpenClaw drift, signal handling --- src/daemon/behavioral-baseline.ts | 248 -------- src/daemon/correlation-engine.ts | 329 ----------- src/daemon/cost-monitor.ts | 292 ---------- src/daemon/enforcement.ts | 178 ------ src/daemon/event-receiver.ts | 293 ---------- src/daemon/fleet.ts | 206 ------- src/daemon/notification-manager.ts | 20 +- src/daemon/runner.ts | 742 ++---------------------- tests/unit/behavioral-baseline.test.ts | 142 ----- tests/unit/correlation-engine.test.ts | 167 ------ tests/unit/cost-monitor.test.ts | 174 ------ tests/unit/daemon.test.ts | 134 ----- tests/unit/enforcement.test.ts | 264 --------- tests/unit/event-receiver.test.ts | 255 -------- tests/unit/fleet.test.ts | 132 ----- tests/unit/kill-switch.test.ts | 132 ----- tests/unit/notification-manager.test.ts | 654 --------------------- 17 files changed, 52 insertions(+), 4310 deletions(-) delete mode 100644 src/daemon/behavioral-baseline.ts delete mode 100644 src/daemon/correlation-engine.ts delete mode 100644 src/daemon/cost-monitor.ts delete mode 100644 src/daemon/enforcement.ts delete mode 100644 src/daemon/event-receiver.ts delete mode 100644 src/daemon/fleet.ts delete mode 100644 tests/unit/behavioral-baseline.test.ts delete mode 100644 tests/unit/correlation-engine.test.ts delete mode 100644 tests/unit/cost-monitor.test.ts delete mode 100644 tests/unit/daemon.test.ts delete mode 100644 tests/unit/enforcement.test.ts delete mode 100644 tests/unit/event-receiver.test.ts delete mode 100644 tests/unit/fleet.test.ts delete mode 100644 tests/unit/kill-switch.test.ts delete mode 100644 tests/unit/notification-manager.test.ts diff --git a/src/daemon/behavioral-baseline.ts b/src/daemon/behavioral-baseline.ts deleted file mode 100644 index 8e58273..0000000 --- a/src/daemon/behavioral-baseline.ts +++ /dev/null @@ -1,248 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -const G0_DIR = path.join(os.homedir(), '.g0'); -const BASELINE_PATH = path.join(G0_DIR, 'behavioral-baseline.json'); - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface ToolStats { - count: number; - avgPerHour: number; - stddev: number; - lastSeen: string; -} - -export interface BehavioralBaseline { - toolFrequency: Record; - learningMode: boolean; - learningStarted: string; - learningEndedAt?: string; - totalEvents: number; - hoursObserved: number; -} - -export interface BehavioralAnomaly { - type: 'unusual-tool-frequency' | 'new-tool-first-seen' | 'tool-burst'; - toolName: string; - expected: number; - actual: number; - timestamp: string; - severity: 'critical' | 'high' | 'medium'; -} - -export interface AnomalyCheckResult { - anomalies: BehavioralAnomaly[]; - baseline: BehavioralBaseline; -} - -// ── Baseline Manager ────────────────────────────────────────────────────── - -const LEARNING_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours -const STDDEV_THRESHOLD = 3; // 3 standard deviations -const BURST_WINDOW_MS = 60 * 1000; // 1 minute -const BURST_THRESHOLD = 10; // >10 calls to same tool in 1 minute - -export interface BaselineManagerOptions { - baselinePath?: string; - learningDurationMs?: number; - stddevThreshold?: number; - burstThreshold?: number; - burstWindowMs?: number; -} - -export class BaselineManager { - private baseline: BehavioralBaseline; - private baselinePath: string; - private learningDurationMs: number; - private stddevThreshold: number; - private burstThreshold: number; - private burstWindowMs: number; - private recentEvents: Array<{ toolName: string; timestamp: number }> = []; - - constructor(opts?: BaselineManagerOptions) { - this.baselinePath = opts?.baselinePath ?? BASELINE_PATH; - this.learningDurationMs = opts?.learningDurationMs ?? LEARNING_DURATION_MS; - this.stddevThreshold = opts?.stddevThreshold ?? STDDEV_THRESHOLD; - this.burstThreshold = opts?.burstThreshold ?? BURST_THRESHOLD; - this.burstWindowMs = opts?.burstWindowMs ?? BURST_WINDOW_MS; - - // Load or create baseline - this.baseline = this.loadBaseline() ?? this.createBaseline(); - } - - /** - * Record a tool call event and check for anomalies. - * During learning mode: updates baseline stats only. - * After learning: returns detected anomalies. - */ - recordToolCall(toolName: string, timestamp?: string): BehavioralAnomaly[] { - const now = timestamp ? new Date(timestamp).getTime() : Date.now(); - const nowISO = new Date(now).toISOString(); - const anomalies: BehavioralAnomaly[] = []; - - this.baseline.totalEvents++; - this.recentEvents.push({ toolName, timestamp: now }); - - // Prune old recent events (keep last 5 minutes) - const cutoff = now - 5 * 60 * 1000; - this.recentEvents = this.recentEvents.filter(e => e.timestamp >= cutoff); - - // Check if learning period is over - const learningStart = new Date(this.baseline.learningStarted).getTime(); - if (this.baseline.learningMode && (now - learningStart) >= this.learningDurationMs) { - this.baseline.learningMode = false; - this.baseline.learningEndedAt = nowISO; - this.baseline.hoursObserved = (now - learningStart) / (60 * 60 * 1000); - - // Compute final stats - this.finalizeStats(); - } - - if (this.baseline.learningMode) { - // Learning mode — just track - if (!this.baseline.toolFrequency[toolName]) { - this.baseline.toolFrequency[toolName] = { - count: 0, - avgPerHour: 0, - stddev: 0, - lastSeen: nowISO, - }; - } - this.baseline.toolFrequency[toolName].count++; - this.baseline.toolFrequency[toolName].lastSeen = nowISO; - } else { - // Detection mode - const stats = this.baseline.toolFrequency[toolName]; - - // New tool never seen in baseline - if (!stats) { - anomalies.push({ - type: 'new-tool-first-seen', - toolName, - expected: 0, - actual: 1, - timestamp: nowISO, - severity: 'high', - }); - // Start tracking the new tool - this.baseline.toolFrequency[toolName] = { - count: 1, - avgPerHour: 0, - stddev: 0, - lastSeen: nowISO, - }; - } else { - stats.count++; - stats.lastSeen = nowISO; - - // Calculate current hourly rate (based on recent window) - const recentCount = this.recentEvents.filter(e => e.toolName === toolName).length; - const windowMinutes = Math.max(1, (now - Math.min(...this.recentEvents.map(e => e.timestamp))) / 60000); - const currentRate = (recentCount / windowMinutes) * 60; // extrapolate to per-hour - - // Check for unusual frequency (>3 stddev above average) - if (stats.stddev > 0 && stats.avgPerHour > 0) { - const deviations = (currentRate - stats.avgPerHour) / stats.stddev; - if (deviations > this.stddevThreshold) { - anomalies.push({ - type: 'unusual-tool-frequency', - toolName, - expected: Math.round(stats.avgPerHour * 100) / 100, - actual: Math.round(currentRate * 100) / 100, - timestamp: nowISO, - severity: deviations > 5 ? 'critical' : 'high', - }); - } - } - } - - // Check for burst (many calls to same tool in short window) - const burstStart = now - this.burstWindowMs; - const burstCount = this.recentEvents.filter( - e => e.toolName === toolName && e.timestamp >= burstStart, - ).length; - - if (burstCount >= this.burstThreshold) { - anomalies.push({ - type: 'tool-burst', - toolName, - expected: this.burstThreshold - 1, - actual: burstCount, - timestamp: nowISO, - severity: 'critical', - }); - } - } - - // Periodically save - if (this.baseline.totalEvents % 100 === 0) { - this.save(); - } - - return anomalies; - } - - /** - * Get the current baseline state - */ - getBaseline(): BehavioralBaseline { - return { ...this.baseline }; - } - - /** - * Force save the baseline to disk - */ - save(): void { - try { - const dir = path.dirname(this.baselinePath); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(this.baselinePath, JSON.stringify(this.baseline, null, 2), { mode: 0o600 }); - } catch { - // Non-fatal - } - } - - /** - * Reset the baseline and start learning again - */ - reset(): void { - this.baseline = this.createBaseline(); - this.recentEvents = []; - this.save(); - } - - // ── Internal ────────────────────────────────────────────────────────── - - private createBaseline(): BehavioralBaseline { - return { - toolFrequency: {}, - learningMode: true, - learningStarted: new Date().toISOString(), - totalEvents: 0, - hoursObserved: 0, - }; - } - - private loadBaseline(): BehavioralBaseline | null { - try { - if (!fs.existsSync(this.baselinePath)) return null; - const raw = fs.readFileSync(this.baselinePath, 'utf-8'); - return JSON.parse(raw) as BehavioralBaseline; - } catch { - return null; - } - } - - private finalizeStats(): void { - const hours = Math.max(1, this.baseline.hoursObserved); - - for (const [, stats] of Object.entries(this.baseline.toolFrequency)) { - stats.avgPerHour = stats.count / hours; - // Simple stddev approximation: assume Poisson-like distribution - // stddev ≈ sqrt(avgPerHour) for event counts - stats.stddev = Math.sqrt(stats.avgPerHour); - } - } -} diff --git a/src/daemon/correlation-engine.ts b/src/daemon/correlation-engine.ts deleted file mode 100644 index a8b0d06..0000000 --- a/src/daemon/correlation-engine.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { ReceivedEvent } from './event-receiver.js'; -import type { CVEEntry } from '../intelligence/cve-feed.js'; -import type { IOCMatch } from '../intelligence/ioc-database.js'; - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface StaticFinding { - id: string; - severity: 'critical' | 'high' | 'medium' | 'low'; - domain: string; - name: string; -} - -export interface DynamicResult { - category: string; - passed: boolean; - details?: string; -} - -export interface CorrelatedThreat { - id: string; - name: string; - severity: 'critical' | 'high' | 'medium'; - confidence: number; // 0-100 - sources: Array<{ - type: 'static' | 'dynamic' | 'runtime' | 'cve' | 'ioc'; - id: string; - timestamp?: string; - }>; - attackChain: string[]; - narrative: string; - remediation: string[]; -} - -// ── Correlation Rules ────────────────────────────────────────────────────── - -interface CorrelationRule { - id: string; - name: string; - severity: CorrelatedThreat['severity']; - baseConfidence: number; - match: (ctx: CorrelationContext) => CorrelatedThreat | null; -} - -interface CorrelationContext { - staticFindings: StaticFinding[]; - dynamicResults: DynamicResult[]; - runtimeEvents: ReceivedEvent[]; - cves: CVEEntry[]; - iocs: IOCMatch[]; -} - -const CORRELATION_RULES: CorrelationRule[] = [ - { - id: 'CT-001', - name: 'Confirmed Injection Vulnerability', - severity: 'critical', - baseConfidence: 90, - match(ctx) { - const hasStaticNoValidation = ctx.staticFindings.some( - f => f.name.toLowerCase().includes('validation') || f.name.toLowerCase().includes('sanitiz'), - ); - const hasRuntimeInjection = ctx.runtimeEvents.some( - e => e.type.includes('injection'), - ); - - if (hasStaticNoValidation && hasRuntimeInjection) { - return { - id: 'CT-001', - name: 'Confirmed Injection Vulnerability', - severity: 'critical', - confidence: 95, - sources: [ - ...ctx.staticFindings - .filter(f => f.name.toLowerCase().includes('validation')) - .map(f => ({ type: 'static' as const, id: f.id })), - ...ctx.runtimeEvents - .filter(e => e.type.includes('injection')) - .slice(0, 3) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ], - attackChain: ['Missing input validation', 'Injection pattern detected at runtime', 'Confirmed exploitable'], - narrative: 'Static analysis found missing input validation AND runtime monitoring detected actual injection attempts. This confirms the vulnerability is exploitable in production.', - remediation: [ - 'Add input validation/sanitization to all tool handlers', - 'Enable injection blocking in the g0 OpenClaw plugin (before_tool_call hook)', - 'Review session transcripts for successful exploitation', - ], - }; - } - return null; - }, - }, - { - id: 'CT-002', - name: 'Known CVE with Exposed Gateway', - severity: 'critical', - baseConfidence: 85, - match(ctx) { - const hasCriticalCVE = ctx.cves.some(c => c.severity === 'critical'); - const hasGatewayExposure = ctx.staticFindings.some( - f => f.name.toLowerCase().includes('gateway') || f.name.toLowerCase().includes('bind'), - ); - - if (hasCriticalCVE && hasGatewayExposure) { - return { - id: 'CT-002', - name: 'Known CVE with Exposed Gateway', - severity: 'critical', - confidence: 90, - sources: [ - ...ctx.cves.filter(c => c.severity === 'critical').map(c => ({ type: 'cve' as const, id: c.id })), - ...ctx.staticFindings.filter(f => f.name.toLowerCase().includes('gateway')).map(f => ({ type: 'static' as const, id: f.id })), - ], - attackChain: ['Critical CVE exists', 'Gateway is network-exposed', 'Remote exploitation possible'], - narrative: 'A critical CVE affects this OpenClaw version AND the gateway is exposed beyond loopback. Remote attackers can exploit the vulnerability.', - remediation: [ - 'Update OpenClaw to the patched version immediately', - 'Restrict gateway.bind to loopback until patched', - 'Enable gateway.auth.mode = "token"', - ], - }; - } - return null; - }, - }, - { - id: 'CT-003', - name: 'Active Compromise Indicators', - severity: 'critical', - baseConfidence: 80, - match(ctx) { - const hasAnomalousActivity = ctx.runtimeEvents.some( - e => e.type.includes('anomaly') || e.type.includes('burst'), - ); - const hasIOCMatch = ctx.iocs.some(m => m.type === 'ip' || m.type === 'domain'); - - if (hasAnomalousActivity && hasIOCMatch) { - return { - id: 'CT-003', - name: 'Active Compromise Indicators', - severity: 'critical', - confidence: 85, - sources: [ - ...ctx.runtimeEvents - .filter(e => e.type.includes('anomaly')) - .slice(0, 3) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ...ctx.iocs - .filter(m => m.type === 'ip' || m.type === 'domain') - .map(m => ({ type: 'ioc' as const, id: m.matched })), - ], - attackChain: ['Behavioral anomaly detected', 'Egress to known malicious endpoint', 'Possible active compromise'], - narrative: 'Unusual agent behavior combined with network connections to known malicious endpoints suggests an active compromise.', - remediation: [ - 'Activate kill switch immediately: g0 daemon kill-switch on', - 'Review session transcripts for data exfiltration', - 'Rotate all credentials accessible to compromised agents', - 'Block egress to identified malicious endpoints', - ], - }; - } - return null; - }, - }, - { - id: 'CT-004', - name: 'Confirmed Exploitable — Dynamic Test', - severity: 'high', - baseConfidence: 85, - match(ctx) { - const hasTestSuccess = ctx.dynamicResults.some(r => !r.passed); - const hasNoSandbox = ctx.staticFindings.some( - f => f.name.toLowerCase().includes('sandbox') || f.name.toLowerCase().includes('isolation'), - ); - - if (hasTestSuccess && hasNoSandbox) { - return { - id: 'CT-004', - name: 'Confirmed Exploitable — Dynamic Test', - severity: 'high', - confidence: 90, - sources: [ - ...ctx.dynamicResults.filter(r => !r.passed).slice(0, 3).map(r => ({ type: 'dynamic' as const, id: r.category })), - ...ctx.staticFindings.filter(f => f.name.toLowerCase().includes('sandbox')).map(f => ({ type: 'static' as const, id: f.id })), - ], - attackChain: ['Dynamic test bypassed security controls', 'No sandboxing in place', 'Exploit has direct host access'], - narrative: 'Adversarial testing successfully bypassed security controls AND no sandboxing is configured. Exploits have direct access to host resources.', - remediation: [ - 'Enable sandbox mode: agents.defaults.sandbox.mode = "all"', - 'Fix the specific vulnerability exposed by the dynamic test', - 'Add tool-level input validation', - ], - }; - } - return null; - }, - }, - { - id: 'CT-005', - name: 'Cognitive Poisoning', - severity: 'critical', - baseConfidence: 75, - match(ctx) { - const hasCognitiveDrift = ctx.runtimeEvents.some( - e => e.type.includes('cognitive') && e.type.includes('modif'), - ); - const hasInjection = ctx.runtimeEvents.some( - e => e.type.includes('injection'), - ); - - if (hasCognitiveDrift && hasInjection) { - return { - id: 'CT-005', - name: 'Cognitive Poisoning', - severity: 'critical', - confidence: 80, - sources: [ - ...ctx.runtimeEvents - .filter(e => e.type.includes('cognitive')) - .slice(0, 2) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ...ctx.runtimeEvents - .filter(e => e.type.includes('injection')) - .slice(0, 2) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ], - attackChain: ['Cognitive file modified', 'Injection patterns detected in modification', 'Agent personality/memory poisoned'], - narrative: 'Cognitive files (SOUL.md, MEMORY.md) were modified AND injection patterns were detected in the changes. This indicates an attempt to poison the agent\'s personality or memory.', - remediation: [ - 'Restore cognitive files from the last known-good baseline', - 'Review the modification diff for injected instructions', - 'Enable cognitive file integrity monitoring in the daemon', - 'Consider read-only mounts for cognitive files in production', - ], - }; - } - return null; - }, - }, - { - id: 'CT-006', - name: 'Cost Abuse Indicator', - severity: 'high', - baseConfidence: 70, - match(ctx) { - const hasCostSpike = ctx.runtimeEvents.some( - e => e.type.includes('cost') && (e.type.includes('warning') || e.type.includes('tripped')), - ); - const hasNewTools = ctx.runtimeEvents.some( - e => e.type.includes('new-tool') || e.type.includes('first-seen'), - ); - - if (hasCostSpike && hasNewTools) { - return { - id: 'CT-006', - name: 'Cost Abuse Indicator', - severity: 'high', - confidence: 75, - sources: [ - ...ctx.runtimeEvents - .filter(e => e.type.includes('cost')) - .slice(0, 2) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ...ctx.runtimeEvents - .filter(e => e.type.includes('new-tool') || e.type.includes('first-seen')) - .slice(0, 2) - .map(e => ({ type: 'runtime' as const, id: e.type, timestamp: e.timestamp })), - ], - attackChain: ['New/unusual tool patterns detected', 'Cost spike observed', 'Possible abuse or compromise'], - narrative: 'Unusual tool usage patterns combined with a cost spike suggest the agent may be under adversarial control or being abused for resource exhaustion.', - remediation: [ - 'Review recent tool call logs for suspicious activity', - 'Set cost limits: costMonitor.hourlyLimitUsd in daemon.json', - 'Enable circuit breaker to auto-activate kill switch on cost threshold', - ], - }; - } - return null; - }, - }, -]; - -// ── Public API ───────────────────────────────────────────────────────────── - -/** - * Correlate events from multiple detection sources to identify attack chains. - */ -export function correlateEvents( - staticFindings: StaticFinding[], - dynamicResults: DynamicResult[], - runtimeEvents: ReceivedEvent[], - cves: CVEEntry[], - iocs: IOCMatch[], -): CorrelatedThreat[] { - const ctx: CorrelationContext = { - staticFindings, - dynamicResults, - runtimeEvents, - cves, - iocs, - }; - - const threats: CorrelatedThreat[] = []; - - for (const rule of CORRELATION_RULES) { - const threat = rule.match(ctx); - if (threat) { - threats.push(threat); - } - } - - // Sort by severity then confidence - const severityOrder: Record = { critical: 0, high: 1, medium: 2 }; - threats.sort((a, b) => { - const sevDiff = (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3); - if (sevDiff !== 0) return sevDiff; - return b.confidence - a.confidence; - }); - - return threats; -} - -/** - * Get all correlation rule definitions (for documentation/display) - */ -export function getCorrelationRules(): Array<{ id: string; name: string; severity: string }> { - return CORRELATION_RULES.map(r => ({ id: r.id, name: r.name, severity: r.severity })); -} diff --git a/src/daemon/cost-monitor.ts b/src/daemon/cost-monitor.ts deleted file mode 100644 index c618db7..0000000 --- a/src/daemon/cost-monitor.ts +++ /dev/null @@ -1,292 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface CostConfig { - hourlyLimitUsd?: number; - dailyLimitUsd?: number; - monthlyLimitUsd?: number; - circuitBreakerEnabled?: boolean; -} - -export interface CostSnapshot { - hourly: number; - daily: number; - monthly: number; - breaker: 'ok' | 'warning' | 'tripped'; - details: CostDetail[]; -} - -export interface CostDetail { - model: string; - inputTokens: number; - outputTokens: number; - cost: number; -} - -// ── Per-Model Pricing (USD per 1M tokens) ────────────────────────────────── - -interface ModelPricing { - inputPer1M: number; - outputPer1M: number; -} - -const MODEL_PRICING: Record = { - // Anthropic - 'claude-opus-4': { inputPer1M: 15, outputPer1M: 75 }, - 'claude-sonnet-4': { inputPer1M: 3, outputPer1M: 15 }, - 'claude-haiku-3.5': { inputPer1M: 0.80, outputPer1M: 4 }, - 'claude-3-opus': { inputPer1M: 15, outputPer1M: 75 }, - 'claude-3.5-sonnet': { inputPer1M: 3, outputPer1M: 15 }, - 'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 }, - // OpenAI - 'gpt-4o': { inputPer1M: 2.50, outputPer1M: 10 }, - 'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.60 }, - 'gpt-4-turbo': { inputPer1M: 10, outputPer1M: 30 }, - 'gpt-4': { inputPer1M: 30, outputPer1M: 60 }, - 'o1': { inputPer1M: 15, outputPer1M: 60 }, - 'o1-mini': { inputPer1M: 3, outputPer1M: 12 }, - // Google - 'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5 }, - 'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.30 }, - 'gemini-2.0-flash': { inputPer1M: 0.10, outputPer1M: 0.40 }, - // Default fallback - 'default': { inputPer1M: 5, outputPer1M: 15 }, -}; - -// ── Token Usage Extraction ───────────────────────────────────────────────── - -interface TokenUsage { - model: string; - inputTokens: number; - outputTokens: number; - timestamp: string; -} - -function extractTokenUsageFromLine(line: string): TokenUsage | null { - try { - const parsed = JSON.parse(line); - - // OpenClaw session JSONL format - let model = parsed.model ?? parsed.data?.model ?? 'default'; - let inputTokens = 0; - let outputTokens = 0; - const timestamp = parsed.timestamp ?? parsed.ts ?? new Date().toISOString(); - - // Anthropic format - if (parsed.usage) { - inputTokens = parsed.usage.input_tokens ?? parsed.usage.prompt_tokens ?? 0; - outputTokens = parsed.usage.output_tokens ?? parsed.usage.completion_tokens ?? 0; - } - - // OpenAI format - if (parsed.data?.usage) { - inputTokens = parsed.data.usage.prompt_tokens ?? parsed.data.usage.input_tokens ?? 0; - outputTokens = parsed.data.usage.completion_tokens ?? parsed.data.usage.output_tokens ?? 0; - } - - // Direct token counts - if (parsed.input_tokens) inputTokens = parsed.input_tokens; - if (parsed.output_tokens) outputTokens = parsed.output_tokens; - - if (inputTokens === 0 && outputTokens === 0) return null; - - // Normalize model names - model = normalizeModelName(model); - - return { model, inputTokens, outputTokens, timestamp }; - } catch { - return null; - } -} - -function normalizeModelName(model: string): string { - const lower = model.toLowerCase(); - - // Match known models - for (const key of Object.keys(MODEL_PRICING)) { - if (key === 'default') continue; - if (lower.includes(key)) return key; - } - - // Partial matches - if (lower.includes('opus')) return 'claude-opus-4'; - if (lower.includes('sonnet')) return 'claude-sonnet-4'; - if (lower.includes('haiku')) return 'claude-haiku-3.5'; - if (lower.includes('gpt-4o-mini')) return 'gpt-4o-mini'; - if (lower.includes('gpt-4o')) return 'gpt-4o'; - if (lower.includes('gpt-4')) return 'gpt-4'; - if (lower.includes('gemini')) return 'gemini-1.5-pro'; - - return 'default'; -} - -function calculateCost(usage: TokenUsage): number { - const pricing = MODEL_PRICING[usage.model] ?? MODEL_PRICING['default']; - const inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPer1M; - const outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPer1M; - return inputCost + outputCost; -} - -// ── Public API ───────────────────────────────────────────────────────────── - -/** - * Estimate the total cost from a single session JSONL file - */ -export function estimateSessionCost(sessionFile: string): CostDetail[] { - const details = new Map(); - - let content: string; - try { - content = fs.readFileSync(sessionFile, 'utf-8'); - } catch { - return []; - } - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - const usage = extractTokenUsageFromLine(line); - if (!usage) continue; - - const cost = calculateCost(usage); - const existing = details.get(usage.model); - if (existing) { - existing.inputTokens += usage.inputTokens; - existing.outputTokens += usage.outputTokens; - existing.cost += cost; - } else { - details.set(usage.model, { - model: usage.model, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cost, - }); - } - } - - return [...details.values()]; -} - -/** - * Scan all session files in an events/agent directory and compute cost snapshot - */ -export function getCostSnapshot( - eventsDir: string, - config: CostConfig, -): CostSnapshot { - const now = Date.now(); - const hourAgo = now - 60 * 60 * 1000; - const dayAgo = now - 24 * 60 * 60 * 1000; - const monthAgo = now - 30 * 24 * 60 * 60 * 1000; - - let hourly = 0; - let daily = 0; - let monthly = 0; - const modelCosts = new Map(); - - // Scan JSONL files in events directory - const files = findJsonlFiles(eventsDir); - - for (const file of files) { - let content: string; - try { - content = fs.readFileSync(file, 'utf-8'); - } catch { - continue; - } - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - const usage = extractTokenUsageFromLine(line); - if (!usage) continue; - - const cost = calculateCost(usage); - const ts = new Date(usage.timestamp).getTime(); - - if (ts >= monthAgo) { - monthly += cost; - // Aggregate by model - const existing = modelCosts.get(usage.model); - if (existing) { - existing.inputTokens += usage.inputTokens; - existing.outputTokens += usage.outputTokens; - existing.cost += cost; - } else { - modelCosts.set(usage.model, { - model: usage.model, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cost, - }); - } - } - if (ts >= dayAgo) daily += cost; - if (ts >= hourAgo) hourly += cost; - } - } - - // Determine breaker state - let breaker: CostSnapshot['breaker'] = 'ok'; - - if (config.circuitBreakerEnabled) { - const hourlyTripped = config.hourlyLimitUsd !== undefined && hourly >= config.hourlyLimitUsd; - const dailyTripped = config.dailyLimitUsd !== undefined && daily >= config.dailyLimitUsd; - const monthlyTripped = config.monthlyLimitUsd !== undefined && monthly >= config.monthlyLimitUsd; - - if (hourlyTripped || dailyTripped || monthlyTripped) { - breaker = 'tripped'; - } else { - // Warning at 80% - const hourlyWarning = config.hourlyLimitUsd !== undefined && hourly >= config.hourlyLimitUsd * 0.8; - const dailyWarning = config.dailyLimitUsd !== undefined && daily >= config.dailyLimitUsd * 0.8; - const monthlyWarning = config.monthlyLimitUsd !== undefined && monthly >= config.monthlyLimitUsd * 0.8; - - if (hourlyWarning || dailyWarning || monthlyWarning) { - breaker = 'warning'; - } - } - } - - return { - hourly: Math.round(hourly * 100) / 100, - daily: Math.round(daily * 100) / 100, - monthly: Math.round(monthly * 100) / 100, - breaker, - details: [...modelCosts.values()], - }; -} - -/** - * Get model pricing table - */ -export function getModelPricing(): Record { - return { ...MODEL_PRICING }; -} - -// ── Internal helpers ────────────────────────────────────────────────────── - -function findJsonlFiles(dir: string): string[] { - const files: string[] = []; - try { - if (!fs.existsSync(dir)) return files; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } else if (entry.isDirectory()) { - // Recurse one level - try { - const subEntries = fs.readdirSync(fullPath); - for (const sub of subEntries) { - if (sub.endsWith('.jsonl')) { - files.push(path.join(fullPath, sub)); - } - } - } catch { /* skip */ } - } - } - } catch { /* skip */ } - return files; -} diff --git a/src/daemon/enforcement.ts b/src/daemon/enforcement.ts deleted file mode 100644 index 8b38bc9..0000000 --- a/src/daemon/enforcement.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import type { DaemonConfig } from './config.js'; -import type { DeploymentAuditResult } from '../mcp/openclaw-deployment.js'; -import type { DaemonLogger } from './logger.js'; - -// ── Consecutive Critical Tracker ────────────────────────────────────────── - -let consecutiveCriticalTicks = 0; - -export function resetCriticalCounter(): void { - consecutiveCriticalTicks = 0; -} - -export function getConsecutiveCriticalTicks(): number { - return consecutiveCriticalTicks; -} - -// ── Main Enforcement Entry Point ────────────────────────────────────────── - -export async function enforceOnCritical( - result: DeploymentAuditResult, - config: NonNullable, - logger: DaemonLogger, -): Promise<{ actioned: boolean; actions: string[] }> { - const actions: string[] = []; - const threshold = config.criticalThreshold ?? 2; - - if (result.summary.overallStatus === 'critical') { - consecutiveCriticalTicks++; - } else { - consecutiveCriticalTicks = 0; - return { actioned: false, actions: [] }; - } - - if (consecutiveCriticalTicks < threshold) { - logger.warn( - `Critical status detected (${consecutiveCriticalTicks}/${threshold} ticks before enforcement)`, - ); - return { actioned: false, actions: [] }; - } - - logger.warn( - `Critical threshold reached (${consecutiveCriticalTicks} consecutive ticks). Executing enforcement actions.`, - ); - - // ── Action 1: Stop non-protected containers ───────────────────────── - - if (config.stopContainersOnCritical) { - const stopped = stopVulnerableContainers( - result, - config.protectedContainers ?? [], - logger, - ); - actions.push(...stopped); - } - - // ── Action 2: Run custom command ──────────────────────────────────── - - if (config.onCriticalCommand) { - const cmdResult = runCustomCommand(config.onCriticalCommand, result, logger); - if (cmdResult) actions.push(cmdResult); - } - - return { actioned: actions.length > 0, actions }; -} - -// ── Container Stop Logic ────────────────────────────────────────────────── - -function stopVulnerableContainers( - result: DeploymentAuditResult, - protectedPatterns: string[], - logger: DaemonLogger, -): string[] { - const actions: string[] = []; - - // Find containers flagged by Docker-related checks - const dockerCheckIds = ['OC-H-021', 'OC-H-025', 'OC-H-027']; - const failedDockerChecks = result.checks.filter( - c => dockerCheckIds.includes(c.id) && c.status === 'fail' && c.severity === 'critical', - ); - - if (failedDockerChecks.length === 0) return actions; - - // Get running containers - let containers: string[]; - try { - const output = execFileSync('docker', ['ps', '--format', '{{.Names}}'], { - encoding: 'utf-8', - timeout: 10_000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - containers = output.trim().split('\n').filter(Boolean); - } catch { - logger.error('Enforcement: Could not list Docker containers'); - return actions; - } - - for (const container of containers) { - // Check protection list - const isProtected = protectedPatterns.some(pattern => { - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); - return regex.test(container); - } - return container === pattern; - }); - - if (isProtected) { - logger.info(`Enforcement: Skipping protected container "${container}"`); - continue; - } - - try { - execFileSync('docker', ['stop', container], { - encoding: 'utf-8', - timeout: 30_000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - logger.warn(`Enforcement: Stopped container "${container}"`); - actions.push(`docker stop ${container}`); - } catch (err) { - logger.error( - `Enforcement: Failed to stop container "${container}": ${err instanceof Error ? err.message : err}`, - ); - } - } - - return actions; -} - -// ── Custom Command Execution ────────────────────────────────────────────── - -function runCustomCommand( - command: string, - result: DeploymentAuditResult, - logger: DaemonLogger, -): string | null { - // Split the command into executable and arguments. - // We use execFileSync (no shell) to prevent injection. - const parts = command.split(/\s+/).filter(Boolean); - if (parts.length === 0) { - logger.error('Enforcement: onCriticalCommand is empty'); - return null; - } - - const [executable, ...args] = parts; - - try { - const summaryJson = JSON.stringify({ - overallStatus: result.summary.overallStatus, - failed: result.summary.failed, - checks: result.checks - .filter(c => c.status === 'fail') - .map(c => ({ id: c.id, name: c.name, severity: c.severity, detail: c.detail })), - }); - - execFileSync(executable, args, { - encoding: 'utf-8', - timeout: 30_000, - stdio: ['pipe', 'pipe', 'pipe'], - input: summaryJson, - env: { - ...process.env, - G0_AUDIT_STATUS: result.summary.overallStatus, - G0_AUDIT_FAILED: String(result.summary.failed), - G0_AUDIT_PASSED: String(result.summary.passed), - }, - }); - - logger.info(`Enforcement: Custom command executed: "${command}"`); - return `exec: ${command}`; - } catch (err) { - logger.error( - `Enforcement: Custom command failed: ${err instanceof Error ? err.message : err}`, - ); - return null; - } -} diff --git a/src/daemon/event-receiver.ts b/src/daemon/event-receiver.ts deleted file mode 100644 index d0b5d19..0000000 --- a/src/daemon/event-receiver.ts +++ /dev/null @@ -1,293 +0,0 @@ -import * as http from 'node:http'; -import * as fs from 'node:fs'; -import type { DaemonLogger } from './logger.js'; - -// ── Event Types ────────────────────────────────────────────────────────────── - -export type EventSource = 'g0-plugin' | 'falcosidekick' | 'tetragon' | 'custom'; - -export interface ReceivedEvent { - source: EventSource; - type: string; - timestamp: string; - agentId?: string; - sessionId?: string; - data: Record; -} - -export type EventHandler = (event: ReceivedEvent) => void | Promise; - -// ── Event Receiver ─────────────────────────────────────────────────────────── - -export interface EventReceiverOptions { - port?: number; - bind?: string; - authToken?: string; - logFile?: string; - logger?: DaemonLogger; - onEvent?: EventHandler; -} - -export class EventReceiver { - private server: http.Server | null = null; - private port: number; - private bind: string; - private authToken: string | null; - private logger: DaemonLogger; - private onEvent: EventHandler; - private eventCount = 0; - private recentEvents: ReceivedEvent[] = []; - private maxRecent = 100; - private logStream: fs.WriteStream | null = null; - private logFile: string | null; - private readonly maxLogSize = 100 * 1024 * 1024; // 100MB - - constructor(options: EventReceiverOptions) { - this.port = options.port ?? 6040; - this.bind = options.bind ?? '127.0.0.1'; - this.authToken = options.authToken ?? null; - const noop = () => {}; - this.logger = options.logger ?? { info: noop, warn: noop, error: noop } as unknown as DaemonLogger; - this.onEvent = options.onEvent ?? (() => {}); - this.logFile = options.logFile ?? null; - if (this.logFile) { - this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' }); - this.logStream.on('error', (err) => { - this.logger.error(`Log stream error: ${err.message}`); - }); - } - } - - start(): Promise { - return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => this.handleRequest(req, res)); - - this.server.on('error', (err) => { - this.logger.error(`Event receiver error: ${err.message}`); - reject(err); - }); - - this.server.listen(this.port, this.bind, () => { - this.logger.info(`Event receiver listening on ${this.bind}:${this.port}`); - resolve(); - }); - }); - } - - stop(): Promise { - if (this.logStream) { - this.logStream.end(); - this.logStream = null; - } - return new Promise((resolve) => { - if (!this.server) { - resolve(); - return; - } - this.server.close(() => { - this.logger.info('Event receiver stopped'); - this.server = null; - resolve(); - }); - }); - } - - getStats(): { eventCount: number; recentEvents: ReceivedEvent[] } { - return { eventCount: this.eventCount, recentEvents: [...this.recentEvents] }; - } - - private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { - // CORS preflight - if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'Access-Control-Allow-Origin': '127.0.0.1', - 'Access-Control-Allow-Methods': 'POST, GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }); - res.end(); - return; - } - - // Health check - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', events: this.eventCount })); - return; - } - - // Stats endpoint - if (req.method === 'GET' && req.url === '/stats') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(this.getStats())); - return; - } - - // Event ingestion - if (req.method === 'POST' && (req.url === '/events' || req.url === '/')) { - this.handleEvent(req, res); - return; - } - - // Falcosidekick format - if (req.method === 'POST' && req.url === '/falco') { - this.handleFalcoEvent(req, res); - return; - } - - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found' })); - } - - private readBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let size = 0; - const maxSize = 1_048_576; // 1MB - const timeout = setTimeout(() => { - req.destroy(); - reject(new Error('Request timeout')); - }, 30_000); - - req.on('data', (chunk: Buffer) => { - size += chunk.length; - if (size > maxSize) { - clearTimeout(timeout); - req.destroy(); - reject(new Error('Payload too large')); - return; - } - chunks.push(chunk); - }); - - req.on('end', () => { - clearTimeout(timeout); - resolve(Buffer.concat(chunks).toString('utf-8')); - }); - req.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - } - - private checkAuth(req: http.IncomingMessage): boolean { - if (!this.authToken) return true; // no auth configured - const authHeader = req.headers.authorization; - if (!authHeader) return false; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; - return token === this.authToken; - } - - private async handleEvent(req: http.IncomingMessage, res: http.ServerResponse): Promise { - if (!this.checkAuth(req)) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - - try { - const body = await this.readBody(req); - const parsed = JSON.parse(body); - - const event: ReceivedEvent = { - source: parsed.source ?? 'g0-plugin', - type: parsed.type ?? 'unknown', - timestamp: parsed.timestamp ?? new Date().toISOString(), - agentId: parsed.agentId, - sessionId: parsed.sessionId, - data: parsed.data ?? parsed, - }; - - this.recordEvent(event); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ received: true })); - } catch (err) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - } - } - - private async handleFalcoEvent(req: http.IncomingMessage, res: http.ServerResponse): Promise { - if (!this.checkAuth(req)) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - - try { - const body = await this.readBody(req); - const parsed = JSON.parse(body); - - // Falcosidekick webhook format - const event: ReceivedEvent = { - source: 'falcosidekick', - type: parsed.rule ?? 'falco.alert', - timestamp: parsed.time ?? new Date().toISOString(), - data: { - rule: parsed.rule, - priority: parsed.priority, - output: parsed.output, - outputFields: parsed.output_fields, - tags: parsed.tags, - hostname: parsed.hostname, - }, - }; - - this.recordEvent(event); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ received: true })); - } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid JSON' })); - } - } - - private recordEvent(event: ReceivedEvent): void { - this.eventCount++; - this.recentEvents.push(event); - if (this.recentEvents.length > this.maxRecent) { - this.recentEvents.shift(); - } - - // Persist to JSONL - if (this.logStream && this.logFile) { - const ok = this.logStream.write(JSON.stringify(event) + '\n'); - if (!ok) { - this.logStream.once('drain', () => { /* backpressure relieved */ }); - } - // Check rotation - try { - const stat = fs.statSync(this.logFile); - if (stat.size > this.maxLogSize) { - this.logStream.end(); - fs.renameSync(this.logFile, this.logFile + '.1'); - this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' }); - } - } catch { /* rotation check failed — non-fatal */ } - } - - // Log based on event type - const level = event.type.includes('injection') || event.type.includes('blocked') - ? 'warn' : 'info'; - - if (level === 'warn') { - this.logger.warn(`[event] ${event.source}/${event.type} agent=${event.agentId ?? 'unknown'}`); - } else { - this.logger.info(`[event] ${event.source}/${event.type} agent=${event.agentId ?? 'unknown'}`); - } - - // Fire handler (don't await — non-blocking) - try { - const result = this.onEvent(event); - if (result instanceof Promise) { - result.catch(err => { - this.logger.error(`Event handler error: ${err instanceof Error ? err.message : err}`); - }); - } - } catch (err) { - this.logger.error(`Event handler error: ${err instanceof Error ? (err as Error).message : err}`); - } - } -} diff --git a/src/daemon/fleet.ts b/src/daemon/fleet.ts deleted file mode 100644 index 194a521..0000000 --- a/src/daemon/fleet.ts +++ /dev/null @@ -1,206 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import * as crypto from 'node:crypto'; - -const G0_DIR = path.join(os.homedir(), '.g0'); -const FLEET_FILE = path.join(G0_DIR, 'fleet-state.json'); - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface FleetMember { - machineId: string; - hostname: string; - platform: string; - group?: string; - tags: string[]; - lastSeen: string; - scores: FleetScores; - agentCount?: number; -} - -export interface FleetScores { - endpointScore?: number; - endpointGrade?: string; - hostHardeningPassed?: number; - hostHardeningFailed?: number; - openclawStatus?: string; - openclawFailedChecks?: number; - scanGrade?: string; - scanFindings?: number; -} - -export interface FleetState { - members: FleetMember[]; - lastUpdated: string; -} - -export interface FleetSummary { - totalMembers: number; - byGroup: Record; - byPlatform: Record; - avgEndpointScore: number; - worstGrade: string; - criticalMembers: FleetMember[]; - aggregateScore: number; - aggregateGrade: string; -} - -// ── Fleet State Management ───────────────────────────────────────────────── - -export function loadFleetState(): FleetState { - try { - const raw = fs.readFileSync(FLEET_FILE, 'utf-8'); - return JSON.parse(raw); - } catch { - return { members: [], lastUpdated: new Date().toISOString() }; - } -} - -export function saveFleetState(state: FleetState): void { - fs.mkdirSync(G0_DIR, { recursive: true, mode: 0o700 }); - state.lastUpdated = new Date().toISOString(); - fs.writeFileSync(FLEET_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); -} - -/** - * Register or update a fleet member - */ -export function registerMember( - machineId: string, - scores: FleetScores, - options?: { group?: string; tags?: string[]; agentCount?: number }, -): FleetMember { - const state = loadFleetState(); - - const member: FleetMember = { - machineId, - hostname: os.hostname(), - platform: `${os.platform()}-${os.arch()}`, - group: options?.group, - tags: options?.tags ?? [], - lastSeen: new Date().toISOString(), - scores, - agentCount: options?.agentCount, - }; - - const idx = state.members.findIndex(m => m.machineId === machineId); - if (idx >= 0) { - state.members[idx] = member; - } else { - state.members.push(member); - } - - saveFleetState(state); - return member; -} - -/** - * Remove stale members not seen in N hours - */ -export function pruneStaleMembers(maxAgeHours: number = 72): number { - const state = loadFleetState(); - const cutoff = Date.now() - maxAgeHours * 60 * 60 * 1000; - const before = state.members.length; - state.members = state.members.filter(m => new Date(m.lastSeen).getTime() > cutoff); - const pruned = before - state.members.length; - if (pruned > 0) saveFleetState(state); - return pruned; -} - -// ── Fleet Aggregation ────────────────────────────────────────────────────── - -const GRADE_ORDER: Record = { A: 0, B: 1, C: 2, D: 3, F: 4 }; -const GRADE_REVERSE: Record = { 0: 'A', 1: 'B', 2: 'C', 3: 'D', 4: 'F' }; - -/** - * Compute aggregate fleet summary - */ -export function getFleetSummary(state?: FleetState): FleetSummary { - const s = state ?? loadFleetState(); - const members = s.members; - - const byGroup: Record = {}; - const byPlatform: Record = {}; - const endpointScores: number[] = []; - let worstGradeIdx = 0; - - for (const m of members) { - byGroup[m.group ?? 'default'] = (byGroup[m.group ?? 'default'] ?? 0) + 1; - byPlatform[m.platform] = (byPlatform[m.platform] ?? 0) + 1; - if (m.scores.endpointScore !== undefined) endpointScores.push(m.scores.endpointScore); - - const grade = m.scores.endpointGrade ?? m.scores.scanGrade; - if (grade) { - const idx = GRADE_ORDER[grade.toUpperCase()] ?? 4; - if (idx > worstGradeIdx) worstGradeIdx = idx; - } - } - - const avgEndpointScore = endpointScores.length > 0 - ? Math.round(endpointScores.reduce((a, b) => a + b, 0) / endpointScores.length) - : 0; - - const criticalMembers = members.filter(m => - m.scores.openclawStatus === 'critical' || - (m.scores.endpointGrade && GRADE_ORDER[m.scores.endpointGrade.toUpperCase()] >= 3), - ); - - // Aggregate score: average of endpoint scores, penalized by critical members - const penalty = criticalMembers.length * 5; - const aggregateScore = Math.max(0, avgEndpointScore - penalty); - const aggregateGradeIdx = aggregateScore >= 90 ? 0 : aggregateScore >= 70 ? 1 : aggregateScore >= 50 ? 2 : aggregateScore >= 30 ? 3 : 4; - - return { - totalMembers: members.length, - byGroup, - byPlatform, - avgEndpointScore, - worstGrade: GRADE_REVERSE[worstGradeIdx] ?? 'A', - criticalMembers, - aggregateScore, - aggregateGrade: GRADE_REVERSE[aggregateGradeIdx] ?? 'A', - }; -} - -/** - * Cross-machine correlation: find common failures across fleet - */ -export function findCommonFailures(state?: FleetState): Array<{ - issue: string; - affectedCount: number; - affectedMembers: string[]; -}> { - const s = state ?? loadFleetState(); - const failureMap: Record = {}; - - for (const m of s.members) { - if (m.scores.openclawStatus === 'critical' || m.scores.openclawStatus === 'warn') { - const key = `openclaw-${m.scores.openclawStatus}`; - if (!failureMap[key]) failureMap[key] = []; - failureMap[key].push(m.hostname); - } - - if (m.scores.hostHardeningFailed && m.scores.hostHardeningFailed > 0) { - const key = `host-hardening-failures`; - if (!failureMap[key]) failureMap[key] = []; - failureMap[key].push(m.hostname); - } - - const grade = m.scores.endpointGrade ?? m.scores.scanGrade; - if (grade && GRADE_ORDER[grade.toUpperCase()] >= 3) { - const key = `poor-grade-${grade}`; - if (!failureMap[key]) failureMap[key] = []; - failureMap[key].push(m.hostname); - } - } - - return Object.entries(failureMap) - .filter(([, members]) => members.length > 1) // Only cross-machine issues - .map(([issue, members]) => ({ - issue, - affectedCount: members.length, - affectedMembers: members, - })) - .sort((a, b) => b.affectedCount - a.affectedCount); -} diff --git a/src/daemon/notification-manager.ts b/src/daemon/notification-manager.ts index 759ba8d..5a07d93 100644 --- a/src/daemon/notification-manager.ts +++ b/src/daemon/notification-manager.ts @@ -1,8 +1,24 @@ import type { DaemonConfig } from './config.js'; import type { DaemonLogger } from './logger.js'; -import type { ReceivedEvent } from './event-receiver.js'; -import type { CorrelatedThreat } from './correlation-engine.js'; import { postWithRetry, sendUrgentAlert } from './alerter.js'; + +/** Inlined from deleted event-receiver module */ +export interface ReceivedEvent { + source: string; + type: string; + timestamp: string; + agentId?: string; + data?: Record; +} + +/** Inlined from deleted correlation-engine module */ +export interface CorrelatedThreat { + id: string; + name: string; + severity: 'critical' | 'high' | 'medium'; + confidence: number; + attackChain: string[]; +} import * as os from 'node:os'; // ── Security Event Categories ──────────────────────────────────────────────── diff --git a/src/daemon/runner.ts b/src/daemon/runner.ts index 98242ab..ace1ead 100644 --- a/src/daemon/runner.ts +++ b/src/daemon/runner.ts @@ -1,744 +1,70 @@ +/** + * g0 v2 Daemon Runner — focused on OpenClaw/MCP monitoring. + */ import { loadDaemonConfig, type DaemonConfig } from './config.js'; import { DaemonLogger } from './logger.js'; import { removePid } from './process.js'; import { getMachineId } from '../platform/machine-id.js'; -import { EventReceiver } from './event-receiver.js'; -import { BaselineManager } from './behavioral-baseline.js'; -import { correlateEvents } from './correlation-engine.js'; -import { getCostSnapshot } from './cost-monitor.js'; -import { NotificationManager } from './notification-manager.js'; +import { detectCognitiveDrift, loadCognitiveBaseline } from './openclaw-drift.js'; +import { detectRunningAgents, getAgentSummary, type AgentWatchResult } from './agent-watchers/index.js'; let running = true; let config: DaemonConfig; let logger: DaemonLogger; -let eventReceiver: EventReceiver | null = null; -let killSwitchMonitor: import('./kill-switch.js').KillSwitchMonitor | null = null; -let baselineManager: BaselineManager | null = null; -let notificationManager: NotificationManager | null = null; -// Track OpenClaw audit state across ticks for heartbeat reporting -let lastOpenClawStatus: 'secure' | 'warn' | 'critical' | undefined; -let lastOpenClawFailedChecks = 0; -let lastOpenClawDriftEvents = 0; - -async function main(): Promise { +export async function startDaemon(): Promise { config = loadDaemonConfig(); logger = new DaemonLogger(config.logFile); - // Handle signals for graceful shutdown — register early so that a SIGTERM - // arriving during the rest of initialization triggers a clean exit instead - // of an abrupt process termination. - process.on('SIGTERM', async () => { - logger.info('Received SIGTERM, shutting down'); - running = false; - stopFastEgressLoop(); - if (notificationManager) { - notificationManager.stop(); - await notificationManager.flush(); - } - if (eventReceiver) await eventReceiver.stop(); - removePid(config.pidFile); - process.exit(0); - }); + process.on('SIGTERM', () => { logger.info('Received SIGTERM'); running = false; removePid(config.pidFile); process.exit(0); }); + process.on('SIGINT', () => { logger.info('Received SIGINT'); running = false; removePid(config.pidFile); process.exit(0); }); - process.on('SIGINT', async () => { - logger.info('Received SIGINT, shutting down'); - running = false; - stopFastEgressLoop(); - if (notificationManager) { - notificationManager.stop(); - await notificationManager.flush(); - } - if (eventReceiver) await eventReceiver.stop(); - removePid(config.pidFile); - process.exit(0); - }); - - // Signal to the parent process that we survived initialization. - // The parent holds an IPC channel open and waits for this message - // before reporting the PID and exiting. - if (process.send) { - process.send({ type: 'daemon-ready' }); - } + if (process.send) process.send({ type: 'daemon-ready' }); - logger.info('Daemon starting'); + logger.info('g0 daemon starting (v2 — OpenClaw/MCP focused)'); logger.info(`Interval: ${config.intervalMinutes} minutes`); logger.info(`Machine ID: ${getMachineId()}`); - if (config.alerting?.webhookUrl) { - logger.info(`Alerting: webhook configured (${config.alerting.format ?? 'generic'} format)`); - - const notifMode = config.alerting.notifications?.mode ?? 'off'; - if (notifMode !== 'off') { - const suppressEventTypes = config.alerting.notifications?.suppressEventTypes; - notificationManager = new NotificationManager({ - alertConfig: config.alerting, - logger, - mode: notifMode, - intervalMinutes: config.alerting.notifications?.intervalMinutes, - rateLimitSeconds: config.alerting.notifications?.rateLimitSeconds, - suppressEventTypes, - }); - logger.info(`Notifications: ${notifMode} mode${suppressEventTypes?.length ? ` (suppressing: ${suppressEventTypes.join(', ')})` : ''}`); - } - } - if (config.enforcement?.stopContainersOnCritical) { - logger.info(`Enforcement: container stop enabled (threshold: ${config.enforcement.criticalThreshold ?? 2} ticks)`); - } - if (config.enforcement?.applyEgressRules) { - logger.info('Enforcement: iptables egress rules enabled'); - } - - // Start fast egress loop if configured - const egressInterval = config.openclaw?.egressIntervalSeconds; - if (config.openclaw?.enabled && config.openclaw?.egressAllowlist?.length && egressInterval !== 0) { - const intervalSec = egressInterval ?? 60; - logger.info(`Fast egress loop: every ${intervalSec}s`); - startFastEgressLoop(intervalSec); - } - - // Initialize kill switch monitor - if (config.killSwitch?.autoEnabled !== false) { - try { - const { createKillSwitchMonitor } = await import('./kill-switch.js'); - killSwitchMonitor = createKillSwitchMonitor(config.killSwitch?.rules); - logger.info('Kill switch monitor initialized'); - } catch (err) { - logger.error(`Kill switch monitor init failed: ${err instanceof Error ? err.message : err}`); - } - } - - // Initialize behavioral baseline manager - try { - baselineManager = new BaselineManager(); - logger.info(`Behavioral baseline initialized (learning=${baselineManager.getBaseline().learningMode})`); - } catch (err) { - logger.error(`Behavioral baseline init failed: ${err instanceof Error ? err.message : err}`); - } - - // Start event receiver if configured - if (config.eventReceiver?.enabled) { - eventReceiver = new EventReceiver({ - port: config.eventReceiver.port, - bind: config.eventReceiver.bind, - authToken: config.eventReceiver.authToken, - logFile: config.eventReceiver.logFile, - logger, - onEvent: (event) => { - // Log high-severity events as warnings - if (event.type.includes('injection') || event.type.includes('blocked')) { - logger.warn(`Security event: ${event.source}/${event.type}`); - } - // Feed into notification manager - notificationManager?.recordEvent(event); - // Feed into behavioral baseline - if (baselineManager && event.type.includes('tool_call')) { - const toolName = (event.data?.toolName as string) ?? event.type; - const anomalies = baselineManager.recordToolCall(toolName, event.timestamp); - for (const anomaly of anomalies) { - logger.warn(`Behavioral anomaly: ${anomaly.type} — ${anomaly.toolName} (expected=${anomaly.expected}, actual=${anomaly.actual})`); - // Alert on behavioral anomalies - if (config.alerting?.webhookUrl) { - import('./alerter.js').then(({ sendUrgentAlert }) => - sendUrgentAlert(config.alerting!, `Behavioral anomaly: ${anomaly.type}`, - `Tool: ${anomaly.toolName}, expected=${anomaly.expected}, actual=${anomaly.actual}`, 'high') - ).catch(() => {}); - } - } - } - // Feed into kill switch monitor - if (killSwitchMonitor) { - const triggered = killSwitchMonitor.recordEvent(event.type, event.timestamp); - if (triggered) { - logger.warn(`KILL SWITCH AUTO-ACTIVATED: ${triggered.reason}`); - // Alert on kill switch activation - if (config.alerting?.webhookUrl) { - import('./alerter.js').then(({ sendUrgentAlert }) => - sendUrgentAlert(config.alerting!, 'KILL SWITCH ACTIVATED', triggered.reason, 'critical') - ).catch(() => {}); - } - } - } - }, - }); - try { - await eventReceiver.start(); - } catch (err) { - logger.error(`Failed to start event receiver: ${err instanceof Error ? err.message : err}`); - eventReceiver = null; - } - } - - // Run initial tick immediately - await tick(); - - // Schedule recurring ticks - const intervalMs = config.intervalMinutes * 60 * 1000; while (running) { - await sleep(intervalMs); - if (!running) break; - await tick(); - } -} - -async function tick(): Promise { - logger.info('Tick started'); - const startTime = Date.now(); - const tickIssues: string[] = []; - - try { - // 1. MCP config scan - if (config.mcpScan) { - await runMCPScan(); - } - - // 2. MCP pin check - if (config.mcpPinCheck) { - await runPinCheck(); - } - - // 3. Inventory diff on watch paths - if (config.inventoryDiff && config.watchPaths.length > 0) { - await runInventoryDiff(); - } - - // 4. Full endpoint scan (network + artifacts + drift) - if (config.networkScan || config.artifactScan) { - await runEndpointScan(); - } - - // 5. Host hardening audit - await runHostHardening(); - - // 6. OpenClaw deployment audit (with drift, alerting, enforcement) - if (config.openclaw?.enabled) { - await runOpenClawAudit(); - } - - // 7. Agent watcher (detect running AI agents) - if (config.fleet?.reportAgents !== false) { - await runAgentWatch(); - } - - // 8. Fleet registration - if (config.fleet?.enabled) { - await runFleetRegistration(); - } - - // 9. Correlation engine — cross-source attack chain detection - if (eventReceiver) { - const { recentEvents } = eventReceiver.getStats(); - if (recentEvents.length > 0) { - try { - const threats = correlateEvents([], [], recentEvents, [], []); - if (threats.length > 0) { - for (const threat of threats) { - logger.warn(`Correlated threat: [${threat.id}] ${threat.name} (severity=${threat.severity}, confidence=${threat.confidence})`); - tickIssues.push(`${threat.id}: ${threat.name}`); - } - notificationManager?.recordCorrelationThreats(threats); - } - } catch (err) { - logger.error(`Correlation engine failed: ${err instanceof Error ? err.message : err}`); - } - } - } - - // 10. Cost monitoring — budget warnings and circuit breaker - if (config.costMonitor?.enabled) { - try { - const eventsDir = config.costMonitor.eventsDir ?? config.eventReceiver?.logFile?.replace(/[^/]+$/, '') ?? ''; - if (eventsDir) { - const snapshot = getCostSnapshot(eventsDir, { - hourlyLimitUsd: config.costMonitor.hourlyLimitUsd, - dailyLimitUsd: config.costMonitor.dailyLimitUsd, - monthlyLimitUsd: config.costMonitor.monthlyLimitUsd, - circuitBreakerEnabled: config.costMonitor.circuitBreakerEnabled, - }); - - logger.info(`Cost monitor: hourly=$${snapshot.hourly} daily=$${snapshot.daily} monthly=$${snapshot.monthly} breaker=${snapshot.breaker}`); - - if (snapshot.breaker === 'warning') { - logger.warn('Cost monitor: approaching budget limit'); - tickIssues.push('Cost approaching budget limit'); - if (config.alerting?.webhookUrl) { - try { - const { sendUrgentAlert } = await import('./alerter.js'); - await sendUrgentAlert(config.alerting, 'Cost approaching budget limit', - `Hourly: $${snapshot.hourly}, Daily: $${snapshot.daily}, Monthly: $${snapshot.monthly}`, 'high'); - } catch {} - } - } else if (snapshot.breaker === 'tripped') { - logger.warn('Cost monitor: budget limit EXCEEDED — circuit breaker tripped'); - tickIssues.push('Cost budget exceeded'); - if (config.alerting?.webhookUrl) { - try { - const { sendUrgentAlert } = await import('./alerter.js'); - await sendUrgentAlert(config.alerting, 'Cost budget EXCEEDED — circuit breaker tripped', - `Hourly: $${snapshot.hourly}, Daily: $${snapshot.daily}, Monthly: $${snapshot.monthly}`, 'critical'); - } catch {} - } - // Auto-activate kill switch if configured - if (killSwitchMonitor && config.costMonitor.circuitBreakerEnabled) { - const triggered = killSwitchMonitor.recordEvent('cost-breaker-tripped', new Date().toISOString()); - if (triggered) { - logger.warn(`KILL SWITCH AUTO-ACTIVATED: ${triggered.reason}`); - } - } - } - } - } catch (err) { - logger.error(`Cost monitor failed: ${err instanceof Error ? err.message : err}`); - } - } - - // 11. Safety-net flush for notification manager (catches events the interval timer missed) - if (notificationManager && notificationManager.getPendingCount() > 0) { - try { - await notificationManager.flush(); - } catch (err) { - logger.error(`Notification safety flush failed: ${err instanceof Error ? err.message : err}`); - } - } - - const elapsed = Date.now() - startTime; - logger.info(`Tick completed in ${elapsed}ms`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error(`Tick failed: ${msg}`); - - } -} - -async function runMCPScan(): Promise { - try { - const { scanAllMCPConfigs } = await import('../mcp/analyzer.js'); - const result = scanAllMCPConfigs(); - logger.info(`MCP scan: ${result.summary.totalServers} servers, ${result.summary.totalFindings} findings`); - } catch (err) { - logger.error(`MCP scan failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runPinCheck(): Promise { - try { - const { loadPinFile, checkPins } = await import('../mcp/hash-pinning.js'); - const { scanAllMCPConfigs } = await import('../mcp/analyzer.js'); - - const pinFile = loadPinFile('.g0-pins.json'); - if (!pinFile) { - logger.info('No pin file found, skipping pin check'); - return; - } - - const result = scanAllMCPConfigs(); - if (result.tools.length === 0) return; - - const check = checkPins(result.tools, pinFile); - if (check.mismatches.length > 0) { - logger.warn(`Pin check: ${check.mismatches.length} mismatches detected!`); - for (const m of check.mismatches) { - logger.warn(` MISMATCH: ${m.toolName} — description changed`); - } - } else { - logger.info(`Pin check: ${check.matches} tools verified`); - } - } catch (err) { - logger.error(`Pin check failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runInventoryDiff(): Promise { - for (const watchPath of config.watchPaths) { try { - const { runDiscovery, runGraphBuild } = await import('../pipeline.js'); - const { buildInventory } = await import('../inventory/builder.js'); - - const discovery = await runDiscovery(watchPath); - const graph = runGraphBuild(watchPath, discovery); - const inventory = buildInventory(graph, discovery); - - logger.info(`Inventory for ${watchPath}: ${inventory.summary.totalModels} models, ${inventory.summary.totalTools} tools`); - } catch (err) { - logger.error(`Inventory diff for ${watchPath} failed: ${err instanceof Error ? err.message : err}`); - } - } -} - -async function runOpenClawAudit(): Promise { - try { - const { auditOpenClawDeployment } = await import('../mcp/openclaw-deployment.js'); - const { detectOpenClawDrift, saveLastAudit } = await import('./openclaw-drift.js'); - const ocConfig = config.openclaw!; + const tickStart = Date.now(); - // ── Run the audit ───────────────────────────────────────────────── - const result = await auditOpenClawDeployment({ - agentDataPath: ocConfig.agentDataPath, - composePath: ocConfig.composePath, - dockerDaemonConfigPath: ocConfig.dockerDaemonConfigPath, - egressAllowlist: ocConfig.egressAllowlist, - }); - - const { summary } = result; - logger.info( - `OpenClaw audit: ${summary.passed} passed, ${summary.failed} failed, ` + - `${summary.errors} errors, ${summary.skipped} skipped — status: ${summary.overallStatus}`, - ); - - // Log failed checks at warn level - for (const check of result.checks) { - if (check.status === 'fail') { - const level = check.severity === 'critical' || check.severity === 'high' ? 'warn' : 'info'; - logger[level](` [${check.id}] ${check.name}: ${check.detail}`); - } - } - - // Update module-level state for heartbeat - lastOpenClawStatus = summary.overallStatus; - lastOpenClawFailedChecks = summary.failed; - - // ── Drift detection ─────────────────────────────────────────────── - const drift = detectOpenClawDrift(result); - lastOpenClawDriftEvents = drift.events.length; - - if (drift.events.length > 0) { - const newFailures = drift.events.filter(e => e.type === 'new-failure' || e.type === 'regression'); - const resolved = drift.events.filter(e => e.type === 'resolved'); - - if (newFailures.length > 0) { - logger.warn(`Drift: ${newFailures.length} new/regressed failures`); - for (const event of newFailures) { - logger.warn(` [${event.type}] ${event.title}`); - } - } - - if (resolved.length > 0) { - logger.info(`Drift: ${resolved.length} issues resolved`); - for (const event of resolved) { - logger.info(` [resolved] ${event.title}`); - } - } - - // Status change - const statusChange = drift.events.find(e => e.type === 'status-change'); - if (statusChange) { - logger.warn(`Drift: ${statusChange.title}`); - } - } - - // Save for next drift comparison - saveLastAudit(result); - - // ── Cognitive file integrity monitoring ──────────────────────────── - if (ocConfig.agentDataPath) { + // 1. Detect running AI agents try { - const { detectCognitiveDrift } = await import('./openclaw-drift.js'); - const openclawDir = ocConfig.agentDataPath.replace(/\/agents\/?$/, ''); - const cogDrift = detectCognitiveDrift(openclawDir); - - if (cogDrift.events.length > 0) { - for (const event of cogDrift.events) { - const level = event.severity === 'critical' ? 'warn' : 'info'; - logger[level](`Cognitive drift: [${event.type}] ${event.detail}`); - } - } - } catch (err) { - logger.error(`Cognitive drift check failed: ${err instanceof Error ? err.message : err}`); - } - } - - // ── Webhook alerting ────────────────────────────────────────────── - if (config.alerting?.webhookUrl) { - const onChangeOnly = config.alerting.onChangeOnly ?? true; - const shouldAlert = onChangeOnly - ? drift.events.some(e => e.type !== 'resolved') // Alert on new/regression/status-change - : summary.overallStatus !== 'secure'; // Alert whenever not secure - - if (shouldAlert) { - try { - const { sendWebhookAlert } = await import('./alerter.js'); - const failedChecks = result.checks.filter(c => c.status === 'fail'); - const alertResult = await sendWebhookAlert( - config.alerting, - failedChecks, - drift.events, - summary.overallStatus, - ); - - if (alertResult.sent) { - logger.info(`Webhook alert sent (status: ${alertResult.statusCode})`); - } else if (alertResult.error) { - logger.warn(`Webhook alert skipped: ${alertResult.error}`); - } - } catch (err) { - logger.error(`Webhook alert failed: ${err instanceof Error ? err.message : err}`); - } - } - } - - // ── Enforcement ─────────────────────────────────────────────────── - if (config.enforcement) { - try { - const { enforceOnCritical } = await import('./enforcement.js'); - const enforcement = await enforceOnCritical(result, config.enforcement, logger); - if (enforcement.actioned) { - logger.warn(`Enforcement actions taken: ${enforcement.actions.join(', ')}`); - } - } catch (err) { - logger.error(`Enforcement failed: ${err instanceof Error ? err.message : err}`); - } - - // Apply auditd rules if observability checks failed - if (config.enforcement.applyAuditdRules) { - const obsCheckIds = ['OC-H-031', 'OC-H-032', 'OC-H-033']; - const obsFailed = result.checks.some(c => obsCheckIds.includes(c.id) && c.status === 'fail'); - if (obsFailed) { + const agentResult: AgentWatchResult = detectRunningAgents(); + const summary = getAgentSummary(agentResult); + logger.info(`Agents: ${summary}`); + } catch (err) { logger.error(`Agent detection failed: ${err}`); } + + // 2. OpenClaw cognitive drift + if (config.watchPaths?.length) { + for (const watchPath of config.watchPaths) { try { - const { generateAuditdRules, applyAuditdRules } = await import('../endpoint/auditd-rules.js'); - const ruleSet = generateAuditdRules({ - agentDataPath: ocConfig.agentDataPath, - }); - const auditdResult = applyAuditdRules(ruleSet, logger); - if (auditdResult.applied) { - logger.info(`auditd enforcement: ${auditdResult.rulesLoaded} rules installed`); + const drift = detectCognitiveDrift(watchPath); + if (drift.events.length > 0) { + for (const event of drift.events) { + logger.warn(`OpenClaw drift: ${event.type} in ${event.file} — ${event.detail}`); + } } - } catch (err) { - logger.error(`auditd enforcement failed: ${err instanceof Error ? err.message : err}`); - } + } catch (err) { logger.error(`Cognitive drift check failed for ${watchPath}: ${err}`); } } } - } - - } catch (err) { - logger.error(`OpenClaw audit failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runAgentWatch(): Promise { - try { - const { detectRunningAgents } = await import('./agent-watchers/index.js'); - const result = detectRunningAgents(); - const running = result.agents.filter(a => a.status === 'running'); - if (running.length > 0) { - logger.info(`Agent watcher: ${running.length} active agents detected (${running.map(a => a.type).join(', ')})`); - } - } catch (err) { - logger.error(`Agent watcher failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runFleetRegistration(): Promise { - try { - const { registerMember, pruneStaleMembers } = await import('./fleet.js'); - registerMember(getMachineId(), { - endpointScore: undefined, - openclawStatus: lastOpenClawStatus, - openclawFailedChecks: lastOpenClawFailedChecks, - }, { - group: config.fleet?.group, - tags: config.fleet?.tags, - }); - - // Prune stale members every tick - pruneStaleMembers(72); - } catch (err) { - logger.error(`Fleet registration failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runHostHardening(): Promise { - try { - const { auditHostHardening } = await import('../endpoint/host-hardening.js'); - const result = await auditHostHardening(); - const passed = result.checks.filter(c => c.status === 'pass').length; - const failed = result.checks.filter(c => c.status === 'fail').length; - const skipped = result.checks.filter(c => c.status === 'skip').length; - logger.info(`Host hardening: ${passed} passed, ${failed} failed, ${skipped} skipped (${result.platform})`); - - for (const check of result.checks) { - if (check.status === 'fail') { - const level = check.severity === 'critical' || check.severity === 'high' ? 'warn' : 'info'; - logger[level](` [${check.id}] ${check.name}: ${check.detail}`); - } - } - // Alert on host hardening failures - const hostFailedChecks = result.checks.filter(c => c.status === 'fail'); - if (hostFailedChecks.length > 0 && config.alerting?.webhookUrl) { - try { - const { sendWebhookAlert } = await import('./alerter.js'); - const hostStatus = hostFailedChecks.some(c => c.severity === 'critical') ? 'critical' as const - : hostFailedChecks.some(c => c.severity === 'high') ? 'warn' as const : 'secure' as const; - if (hostStatus !== 'secure') { - await sendWebhookAlert(config.alerting, hostFailedChecks, [], hostStatus); - logger.info('Host hardening alert sent'); - } - } catch (err) { - logger.warn(`Host hardening alert failed: ${err instanceof Error ? err.message : err}`); - } - } - - } catch (err) { - logger.error(`Host hardening failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function runEndpointScan(): Promise { - try { - const { scanEndpoint } = await import('../endpoint/scanner.js'); - const { detectDrift, saveLastScan, loadLastScan } = await import('../endpoint/drift.js'); - - const result = await scanEndpoint({ - network: config.networkScan, - artifacts: config.artifactScan, - }); - - logger.info(`Endpoint scan: score=${result.score.total} (${result.score.grade}), findings=${result.summary.totalFindings}, network=${result.summary.networkServices} services, credentials=${result.summary.credentialExposures}`); - - // Drift detection - if (config.driftDetection) { - const previous = loadLastScan(); - if (previous) { - const drift = detectDrift(previous, result); - if (drift.events.length > 0) { - logger.warn(`Drift detected: ${drift.events.length} events, score delta=${drift.scoreDelta}`); - for (const event of drift.events) { - const level = event.severity === 'critical' || event.severity === 'high' ? 'warn' : 'info'; - logger[level](` [${event.type}] ${event.title}`); - } - } - } - } - - // Save for next drift comparison - saveLastScan(result); - - } catch (err) { - logger.error(`Endpoint scan failed: ${err instanceof Error ? err.message : err}`); - } -} - -// ── Fast Egress Loop ────────────────────────────────────────────────────── - -let egressLoopTimer: ReturnType | undefined; - -function startFastEgressLoop(intervalSeconds: number): void { - // Run immediately, then on interval - runFastEgressCheck(); - egressLoopTimer = setInterval(runFastEgressCheck, intervalSeconds * 1000); -} - -async function runFastEgressCheck(): Promise { - if (!running || !config.openclaw?.egressAllowlist?.length) return; - - try { - const { scanEgress } = await import('../endpoint/egress-monitor.js'); - - const result = await scanEgress({ - allowlist: config.openclaw.egressAllowlist, - perContainer: true, - }); - - if (result.violations.length === 0) return; - - logger.warn( - `Fast egress: ${result.violations.length} violations detected (${result.totalConnections} connections)`, - ); - - for (const v of result.violations.slice(0, 5)) { - const dest = v.connection.remoteHost || v.connection.remote; - const container = v.connection.container ? ` (${v.connection.container})` : ''; - logger.warn(` ${dest}${container} — ${v.reason}`); - } - if (result.violations.length > 5) { - logger.warn(` ... and ${result.violations.length - 5} more`); - } - - // Route egress violations through NotificationManager for batching/rate-limiting - // instead of firing immediate webhook alerts (prevents alert spam) - if (notificationManager) { - for (const v of result.violations) { - const dest = v.connection.remoteHost || v.connection.remote; - const container = v.connection.container ?? undefined; - notificationManager.recordEvent({ - source: 'custom', - type: 'egress.violation', - timestamp: new Date().toISOString(), - data: { - reason: v.reason, - remote: dest, - container, - severity: 'critical', - }, - }); - } - } else if (config.alerting?.webhookUrl) { - // Fallback: direct alert only when NotificationManager is not configured - try { - const { sendWebhookAlert } = await import('./alerter.js'); - const egressFindings = result.violations.map(v => ({ - id: 'OC-H-019' as const, - name: 'Egress violation', - severity: 'critical' as const, - status: 'fail' as const, - detail: v.reason, - })); - await sendWebhookAlert(config.alerting, egressFindings, [], 'critical'); - } catch (err) { - logger.error(`Fast egress webhook failed: ${err instanceof Error ? err.message : err}`); - } - } - - // Apply iptables rules if enforcement configured - if (config.enforcement?.applyEgressRules) { - await applyEgressEnforcement(); - } - } catch (err) { - logger.error(`Fast egress check failed: ${err instanceof Error ? err.message : err}`); - } -} - -async function applyEgressEnforcement(): Promise { - const allowlist = config.openclaw?.egressAllowlist; - if (!allowlist?.length) return; - - try { - const { generateIptablesRules, applyIptablesRules } = await import('../endpoint/egress-rules.js'); - - const ruleSet = await generateIptablesRules(allowlist); - - if (ruleSet.unresolved.length > 0) { - logger.warn(`Egress rules: ${ruleSet.unresolved.length} allowlist entries could not be resolved`); - } - - const result = applyIptablesRules(ruleSet, logger); - if (result.applied) { - logger.info(`Egress enforcement: ${result.rulesApplied} iptables rules applied`); - } - if (result.errors.length > 0) { - logger.warn(`Egress enforcement: ${result.errors.length} rules failed to apply`); + logger.info(`Tick complete in ${Date.now() - tickStart}ms`); + await sleep(config.intervalMinutes * 60_000); + } catch (err) { + logger.error(`Tick error: ${err}`); + await sleep(60_000); } - } catch (err) { - logger.error(`Egress enforcement failed: ${err instanceof Error ? err.message : err}`); - } -} - -export function stopFastEgressLoop(): void { - if (egressLoopTimer) { - clearInterval(egressLoopTimer); - egressLoopTimer = undefined; } } function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => { const timer = setTimeout(resolve, ms); if (timer.unref) timer.unref(); }); } // Run if this is the daemon process if (process.env.G0_DAEMON === '1') { - // Install global error handlers early — before any async work — so that - // crashes during module loading or config parsing are captured to the - // startup log (stdout/stderr are redirected to a file by forkDaemon). process.on('uncaughtException', (err) => { console.error('Daemon uncaught exception:', err); process.exit(1); @@ -748,10 +74,10 @@ if (process.env.G0_DAEMON === '1') { process.exit(1); }); - main().catch(err => { + startDaemon().catch(err => { console.error('Daemon fatal error:', err); process.exit(1); }); } -export { main as runDaemon, tick }; +export { startDaemon as runDaemon }; diff --git a/tests/unit/behavioral-baseline.test.ts b/tests/unit/behavioral-baseline.test.ts deleted file mode 100644 index e7acea3..0000000 --- a/tests/unit/behavioral-baseline.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -describe('Behavioral Baseline', () => { - let tmpDir: string; - let baselinePath: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'g0-baseline-test-')); - baselinePath = path.join(tmpDir, 'baseline.json'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - describe('learning mode', () => { - it('starts in learning mode', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ baselinePath }); - expect(mgr.getBaseline().learningMode).toBe(true); - }); - - it('tracks tool calls during learning', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ baselinePath }); - - mgr.recordToolCall('bash'); - mgr.recordToolCall('bash'); - mgr.recordToolCall('read_file'); - - const baseline = mgr.getBaseline(); - expect(baseline.toolFrequency['bash'].count).toBe(2); - expect(baseline.toolFrequency['read_file'].count).toBe(1); - expect(baseline.totalEvents).toBe(3); - }); - - it('returns no anomalies during learning', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ baselinePath }); - - const anomalies = mgr.recordToolCall('bash'); - expect(anomalies).toHaveLength(0); - }); - - it('transitions out of learning after duration', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ - baselinePath, - learningDurationMs: 100, // 100ms for testing - }); - - mgr.recordToolCall('bash'); - - // Wait past learning duration - await new Promise(r => setTimeout(r, 150)); - - mgr.recordToolCall('bash'); - expect(mgr.getBaseline().learningMode).toBe(false); - }); - }); - - describe('detection mode', () => { - it('detects new tools not in baseline', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ - baselinePath, - learningDurationMs: 50, - }); - - // Learning: only see 'bash' - mgr.recordToolCall('bash'); - await new Promise(r => setTimeout(r, 100)); - mgr.recordToolCall('bash'); // exits learning - - // Detection: see new tool - const anomalies = mgr.recordToolCall('dangerous_tool'); - expect(anomalies.length).toBeGreaterThan(0); - expect(anomalies[0].type).toBe('new-tool-first-seen'); - expect(anomalies[0].toolName).toBe('dangerous_tool'); - }); - - it('detects tool burst', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ - baselinePath, - learningDurationMs: 50, - burstThreshold: 5, - burstWindowMs: 60000, - }); - - // Learning phase - mgr.recordToolCall('bash'); - await new Promise(r => setTimeout(r, 100)); - mgr.recordToolCall('bash'); // exits learning - - // Fire many calls quickly - let burstDetected = false; - for (let i = 0; i < 10; i++) { - const anomalies = mgr.recordToolCall('bash'); - if (anomalies.some(a => a.type === 'tool-burst')) { - burstDetected = true; - break; - } - } - expect(burstDetected).toBe(true); - }); - }); - - describe('persistence', () => { - it('saves and loads baseline', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ baselinePath }); - - mgr.recordToolCall('bash'); - mgr.recordToolCall('read_file'); - mgr.save(); - - // Create new manager from same path - const mgr2 = new BaselineManager({ baselinePath }); - const baseline = mgr2.getBaseline(); - expect(baseline.toolFrequency['bash'].count).toBe(1); - expect(baseline.toolFrequency['read_file'].count).toBe(1); - expect(baseline.totalEvents).toBe(2); - }); - - it('resets baseline', async () => { - const { BaselineManager } = await import('../../src/daemon/behavioral-baseline.js'); - const mgr = new BaselineManager({ baselinePath }); - - mgr.recordToolCall('bash'); - mgr.reset(); - - const baseline = mgr.getBaseline(); - expect(baseline.totalEvents).toBe(0); - expect(Object.keys(baseline.toolFrequency)).toHaveLength(0); - expect(baseline.learningMode).toBe(true); - }); - }); -}); diff --git a/tests/unit/correlation-engine.test.ts b/tests/unit/correlation-engine.test.ts deleted file mode 100644 index ac74231..0000000 --- a/tests/unit/correlation-engine.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { ReceivedEvent } from '../../src/daemon/event-receiver.js'; -import type { CVEEntry } from '../../src/intelligence/cve-feed.js'; -import type { IOCMatch } from '../../src/intelligence/ioc-database.js'; - -describe('Correlation Engine', () => { - describe('correlateEvents', () => { - it('detects confirmed injection vulnerability (CT-001)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [{ id: 'AA-TS-001', severity: 'high', domain: 'tool-safety', name: 'Missing input validation' }], - [], - [{ source: 'g0-plugin', type: 'injection.detected', timestamp: new Date().toISOString(), data: {} }], - [], - [], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-001'); - expect(threats[0].severity).toBe('critical'); - expect(threats[0].confidence).toBeGreaterThanOrEqual(90); - }); - - it('detects known CVE with exposed gateway (CT-002)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [{ id: 'OC-H-001', severity: 'critical', domain: 'network', name: 'Gateway bind not loopback' }], - [], - [], - [{ id: 'CVE-2026-28363', severity: 'critical', cvss: 9.9, description: 'safeBins bypass', affectedVersions: ['< 0.12.4'], references: [], source: 'openclaw-advisory' }], - [], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-002'); - }); - - it('detects active compromise (CT-003)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [], - [], - [{ source: 'g0-plugin', type: 'behavioral.anomaly', timestamp: new Date().toISOString(), data: {} }], - [], - [{ type: 'domain', indicator: 'webhook.site', matched: 'webhook.site', description: 'exfil', severity: 'high' }], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-003'); - }); - - it('detects confirmed exploit without sandbox (CT-004)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [{ id: 'AA-TS-100', severity: 'high', domain: 'tool-safety', name: 'Missing sandboxing' }], - [{ category: 'prompt-injection', passed: false }], - [], - [], - [], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-004'); - }); - - it('detects cognitive poisoning (CT-005)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [], - [], - [ - { source: 'g0-plugin', type: 'cognitive-file-modified', timestamp: new Date().toISOString(), data: {} }, - { source: 'g0-plugin', type: 'injection.detected', timestamp: new Date().toISOString(), data: {} }, - ], - [], - [], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-005'); - }); - - it('detects cost abuse (CT-006)', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [], - [], - [ - { source: 'g0-plugin', type: 'cost.warning', timestamp: new Date().toISOString(), data: {} }, - { source: 'g0-plugin', type: 'new-tool-first-seen', timestamp: new Date().toISOString(), data: {} }, - ], - [], - [], - ); - - expect(threats.length).toBeGreaterThan(0); - expect(threats[0].id).toBe('CT-006'); - }); - - it('returns empty when no correlations match', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents([], [], [], [], []); - expect(threats).toHaveLength(0); - }); - - it('returns multiple threats when multiple rules match', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [ - { id: 'AA-TS-001', severity: 'high', domain: 'tool-safety', name: 'Missing input validation' }, - { id: 'OC-H-001', severity: 'critical', domain: 'network', name: 'Gateway bind exposed' }, - ], - [], - [{ source: 'g0-plugin', type: 'injection.detected', timestamp: new Date().toISOString(), data: {} }], - [{ id: 'CVE-2026-28363', severity: 'critical', cvss: 9.9, description: 'test', affectedVersions: [], references: [], source: 'openclaw-advisory' }], - [], - ); - - expect(threats.length).toBeGreaterThanOrEqual(2); - // Should be sorted by severity - expect(threats[0].severity).toBe('critical'); - }); - - it('sorts threats by severity then confidence', async () => { - const { correlateEvents } = await import('../../src/daemon/correlation-engine.js'); - - const threats = correlateEvents( - [ - { id: 'AA-TS-001', severity: 'high', domain: 'tool-safety', name: 'Missing input validation' }, - { id: 'AA-TS-100', severity: 'high', domain: 'tool-safety', name: 'Missing sandboxing' }, - ], - [{ category: 'test', passed: false }], - [{ source: 'g0-plugin', type: 'injection.detected', timestamp: new Date().toISOString(), data: {} }], - [], - [], - ); - - if (threats.length >= 2) { - const severityOrder = { critical: 0, high: 1, medium: 2 }; - for (let i = 1; i < threats.length; i++) { - const prev = severityOrder[threats[i - 1].severity] ?? 3; - const curr = severityOrder[threats[i].severity] ?? 3; - expect(curr).toBeGreaterThanOrEqual(prev); - } - } - }); - }); - - describe('getCorrelationRules', () => { - it('returns all rule definitions', async () => { - const { getCorrelationRules } = await import('../../src/daemon/correlation-engine.js'); - const rules = getCorrelationRules(); - expect(rules.length).toBeGreaterThanOrEqual(6); - expect(rules[0]).toHaveProperty('id'); - expect(rules[0]).toHaveProperty('name'); - expect(rules[0]).toHaveProperty('severity'); - }); - }); -}); diff --git a/tests/unit/cost-monitor.test.ts b/tests/unit/cost-monitor.test.ts deleted file mode 100644 index 52159f0..0000000 --- a/tests/unit/cost-monitor.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -describe('Cost Monitor', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'g0-cost-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - describe('estimateSessionCost', () => { - it('calculates cost from Anthropic-format usage', async () => { - const { estimateSessionCost } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'session.jsonl'); - const lines = [ - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 1000, output_tokens: 500 }, timestamp: new Date().toISOString() }), - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 2000, output_tokens: 1000 }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const details = estimateSessionCost(sessionFile); - expect(details).toHaveLength(1); - expect(details[0].model).toContain('sonnet'); - expect(details[0].inputTokens).toBe(3000); - expect(details[0].outputTokens).toBe(1500); - expect(details[0].cost).toBeGreaterThan(0); - }); - - it('calculates cost from OpenAI-format usage', async () => { - const { estimateSessionCost } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'session.jsonl'); - const lines = [ - JSON.stringify({ model: 'gpt-4o', data: { usage: { prompt_tokens: 5000, completion_tokens: 2000 } }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const details = estimateSessionCost(sessionFile); - expect(details).toHaveLength(1); - expect(details[0].model).toBe('gpt-4o'); - expect(details[0].cost).toBeGreaterThan(0); - }); - - it('handles multiple models in same session', async () => { - const { estimateSessionCost } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'session.jsonl'); - const lines = [ - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 1000, output_tokens: 500 }, timestamp: new Date().toISOString() }), - JSON.stringify({ model: 'gpt-4o', data: { usage: { prompt_tokens: 1000, completion_tokens: 500 } }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const details = estimateSessionCost(sessionFile); - expect(details).toHaveLength(2); - }); - - it('returns empty for nonexistent file', async () => { - const { estimateSessionCost } = await import('../../src/daemon/cost-monitor.js'); - const details = estimateSessionCost('/nonexistent'); - expect(details).toHaveLength(0); - }); - - it('skips lines without token usage', async () => { - const { estimateSessionCost } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'session.jsonl'); - const lines = [ - JSON.stringify({ type: 'tool.called', data: { toolName: 'bash' } }), - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 100, output_tokens: 50 }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const details = estimateSessionCost(sessionFile); - expect(details).toHaveLength(1); - }); - }); - - describe('getCostSnapshot', () => { - it('computes snapshot from session files', async () => { - const { getCostSnapshot } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'events.jsonl'); - const lines = [ - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 10000, output_tokens: 5000 }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const snapshot = getCostSnapshot(tmpDir, {}); - expect(snapshot.hourly).toBeGreaterThan(0); - expect(snapshot.daily).toBeGreaterThan(0); - expect(snapshot.monthly).toBeGreaterThan(0); - expect(snapshot.breaker).toBe('ok'); - expect(snapshot.details.length).toBeGreaterThan(0); - }); - - it('trips circuit breaker when limit exceeded', async () => { - const { getCostSnapshot } = await import('../../src/daemon/cost-monitor.js'); - - // Write a huge usage - const sessionFile = path.join(tmpDir, 'events.jsonl'); - const lines = [ - JSON.stringify({ model: 'gpt-4', usage: { input_tokens: 1000000, output_tokens: 500000 }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const snapshot = getCostSnapshot(tmpDir, { - hourlyLimitUsd: 1.0, - circuitBreakerEnabled: true, - }); - expect(snapshot.breaker).toBe('tripped'); - }); - - it('warns at 80% threshold', async () => { - const { getCostSnapshot } = await import('../../src/daemon/cost-monitor.js'); - - const sessionFile = path.join(tmpDir, 'events.jsonl'); - // Claude Sonnet: 3000 input + 15000 output per 1M tokens - // 100K input = $0.30, 50K output = $0.75 → total $1.05 - const lines = [ - JSON.stringify({ model: 'claude-3.5-sonnet', usage: { input_tokens: 100000, output_tokens: 50000 }, timestamp: new Date().toISOString() }), - ]; - fs.writeFileSync(sessionFile, lines.join('\n')); - - const snapshot = getCostSnapshot(tmpDir, { - hourlyLimitUsd: 1.20, - circuitBreakerEnabled: true, - }); - expect(snapshot.breaker).toBe('warning'); - }); - - it('returns zero for empty directory', async () => { - const { getCostSnapshot } = await import('../../src/daemon/cost-monitor.js'); - const emptyDir = path.join(tmpDir, 'empty'); - fs.mkdirSync(emptyDir); - - const snapshot = getCostSnapshot(emptyDir, {}); - expect(snapshot.hourly).toBe(0); - expect(snapshot.daily).toBe(0); - expect(snapshot.monthly).toBe(0); - }); - - it('handles nested JSONL files', async () => { - const { getCostSnapshot } = await import('../../src/daemon/cost-monitor.js'); - - const subDir = path.join(tmpDir, 'agent-1'); - fs.mkdirSync(subDir); - fs.writeFileSync( - path.join(subDir, 'session.jsonl'), - JSON.stringify({ model: 'gpt-4o', usage: { input_tokens: 1000, output_tokens: 500 }, timestamp: new Date().toISOString() }), - ); - - const snapshot = getCostSnapshot(tmpDir, {}); - expect(snapshot.hourly).toBeGreaterThan(0); - }); - }); - - describe('getModelPricing', () => { - it('returns pricing table', async () => { - const { getModelPricing } = await import('../../src/daemon/cost-monitor.js'); - const pricing = getModelPricing(); - expect(pricing['gpt-4o']).toBeDefined(); - expect(pricing['gpt-4o'].inputPer1M).toBeGreaterThan(0); - expect(pricing['claude-opus-4']).toBeDefined(); - }); - }); -}); diff --git a/tests/unit/daemon.test.ts b/tests/unit/daemon.test.ts deleted file mode 100644 index e3d3f52..0000000 --- a/tests/unit/daemon.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -// ─── Daemon Config ─────────────────────────────────────────────────────────── - -describe('daemon config', () => { - it('DEFAULT_DAEMON_CONFIG has correct defaults', async () => { - const { DEFAULT_DAEMON_CONFIG } = await import('../../src/daemon/config.js'); - expect(DEFAULT_DAEMON_CONFIG.intervalMinutes).toBe(30); - expect(DEFAULT_DAEMON_CONFIG.upload).toBe(true); - expect(DEFAULT_DAEMON_CONFIG.mcpScan).toBe(true); - expect(DEFAULT_DAEMON_CONFIG.mcpPinCheck).toBe(true); - expect(DEFAULT_DAEMON_CONFIG.inventoryDiff).toBe(true); - expect(DEFAULT_DAEMON_CONFIG.watchPaths).toEqual([]); - }); - - it('loadDaemonConfig returns defaults when no file exists', async () => { - const { loadDaemonConfig } = await import('../../src/daemon/config.js'); - const config = loadDaemonConfig(); - expect(config.intervalMinutes).toBe(30); - expect(config.upload).toBe(true); - }); -}); - -// ─── Daemon Logger ─────────────────────────────────────────────────────────── - -describe('daemon logger', () => { - const testDir = path.join(os.tmpdir(), `g0-logger-test-${Date.now()}`); - const logPath = path.join(testDir, 'test.log'); - - beforeEach(() => { - fs.mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(testDir, { recursive: true, force: true }); - }); - - it('writes log entries with timestamps', async () => { - const { DaemonLogger } = await import('../../src/daemon/logger.js'); - const logger = new DaemonLogger(logPath); - - logger.info('Test info message'); - logger.warn('Test warning'); - logger.error('Test error'); - - const content = fs.readFileSync(logPath, 'utf-8'); - expect(content).toContain('[INFO] Test info message'); - expect(content).toContain('[WARN] Test warning'); - expect(content).toContain('[ERROR] Test error'); - }); - - it('tail returns last N lines', async () => { - const { DaemonLogger } = await import('../../src/daemon/logger.js'); - const logger = new DaemonLogger(logPath); - - for (let i = 0; i < 10; i++) { - logger.info(`Line ${i}`); - } - - const lines = logger.tail(3); - expect(lines).toHaveLength(3); - expect(lines[2]).toContain('Line 9'); - }); - - it('tail returns empty array for missing file', async () => { - const { DaemonLogger } = await import('../../src/daemon/logger.js'); - const logger = new DaemonLogger(path.join(testDir, 'nonexistent.log')); - expect(logger.tail()).toEqual([]); - }); -}); - -// ─── Daemon Process ────────────────────────────────────────────────────────── - -describe('daemon process', () => { - const testDir = path.join(os.tmpdir(), `g0-process-test-${Date.now()}`); - const pidFile = path.join(testDir, 'test.pid'); - - beforeEach(() => { - fs.mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(testDir, { recursive: true, force: true }); - }); - - it('readPid returns null for missing file', async () => { - const { readPid } = await import('../../src/daemon/process.js'); - expect(readPid(pidFile)).toBeNull(); - }); - - it('writePid and readPid round-trip for current process', async () => { - const { writePid, readPid, removePid } = await import('../../src/daemon/process.js'); - writePid(pidFile, process.pid); - expect(readPid(pidFile)).toBe(process.pid); - removePid(pidFile); - expect(fs.existsSync(pidFile)).toBe(false); - }); - - it('readPid cleans up stale PID files', async () => { - const { readPid } = await import('../../src/daemon/process.js'); - // Write a PID that doesn't exist - fs.writeFileSync(pidFile, '999999999\n'); - expect(readPid(pidFile)).toBeNull(); - // PID file should be cleaned up - expect(fs.existsSync(pidFile)).toBe(false); - }); - - it('stopDaemon returns false for non-running daemon', async () => { - const { stopDaemon } = await import('../../src/daemon/process.js'); - expect(stopDaemon(pidFile)).toBe(false); - }); - - it('forkDaemon throws if already running', async () => { - const { writePid, forkDaemon, removePid } = await import('../../src/daemon/process.js'); - // Simulate running daemon using our own PID - writePid(pidFile, process.pid); - - await expect(forkDaemon(pidFile)).rejects.toThrow('already running'); - - removePid(pidFile); - }); -}); - -// ─── Daemon Runner ─────────────────────────────────────────────────────────── - -describe('daemon runner', () => { - it('exports tick function', async () => { - const { tick } = await import('../../src/daemon/runner.js'); - expect(typeof tick).toBe('function'); - }); -}); diff --git a/tests/unit/enforcement.test.ts b/tests/unit/enforcement.test.ts deleted file mode 100644 index e7a41ac..0000000 --- a/tests/unit/enforcement.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock child_process before importing the module -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), -})); - -import { execFileSync } from 'node:child_process'; -import { - enforceOnCritical, - resetCriticalCounter, - getConsecutiveCriticalTicks, -} from '../../src/daemon/enforcement.js'; -import type { DeploymentAuditResult } from '../../src/mcp/openclaw-deployment.js'; -import type { DaemonConfig } from '../../src/daemon/config.js'; - -const mockExecFileSync = vi.mocked(execFileSync); - -function makeLogger() { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; -} - -function makeResult( - overallStatus: 'secure' | 'warn' | 'critical', - checks: DeploymentAuditResult['checks'] = [], -): DeploymentAuditResult { - const failed = checks.filter(c => c.status === 'fail').length; - return { - checks, - summary: { - total: checks.length, - passed: checks.length - failed, - failed, - errors: 0, - skipped: 0, - overallStatus, - }, - }; -} - -function makeConfig( - overrides: Partial> = {}, -): NonNullable { - return { - criticalThreshold: 2, - stopContainersOnCritical: false, - ...overrides, - }; -} - -describe('enforcement', () => { - beforeEach(() => { - resetCriticalCounter(); - vi.clearAllMocks(); - }); - - it('resets critical counter', () => { - expect(getConsecutiveCriticalTicks()).toBe(0); - }); - - it('returns no action when status is not critical', async () => { - const result = makeResult('secure'); - const config = makeConfig(); - const logger = makeLogger(); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(res.actions).toHaveLength(0); - expect(getConsecutiveCriticalTicks()).toBe(0); - }); - - it('increments critical counter but does not enforce below threshold', async () => { - const result = makeResult('critical'); - const config = makeConfig({ criticalThreshold: 3 }); - const logger = makeLogger(); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(getConsecutiveCriticalTicks()).toBe(1); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('1/3')); - }); - - it('resets counter when status returns to non-critical', async () => { - const logger = makeLogger(); - const config = makeConfig(); - - // Push counter up - await enforceOnCritical(makeResult('critical'), config, logger); - expect(getConsecutiveCriticalTicks()).toBe(1); - - // Non-critical resets - await enforceOnCritical(makeResult('warn'), config, logger); - expect(getConsecutiveCriticalTicks()).toBe(0); - }); - - it('enforces container stop when threshold reached and docker checks fail', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-021', name: 'Docker socket', severity: 'critical' as const, status: 'fail' as const, detail: 'exposed' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 2, - stopContainersOnCritical: true, - protectedContainers: ['db-*'], - }); - - // First tick - await enforceOnCritical(result, config, logger); - - // Second tick triggers enforcement - mockExecFileSync - .mockReturnValueOnce('web-app\ndb-primary\nworker\n') // docker ps - .mockReturnValueOnce('') // docker stop web-app - .mockReturnValueOnce(''); // docker stop worker - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(true); - expect(res.actions).toContain('docker stop web-app'); - expect(res.actions).toContain('docker stop worker'); - // db-primary should be protected by db-* pattern - expect(res.actions).not.toContain('docker stop db-primary'); - }); - - it('skips containers matching exact protected name', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-025', name: 'test', severity: 'critical' as const, status: 'fail' as const, detail: 'fail' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 1, - stopContainersOnCritical: true, - protectedContainers: ['critical-svc'], - }); - - mockExecFileSync - .mockReturnValueOnce('critical-svc\nother-svc\n') - .mockReturnValueOnce(''); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actions).toEqual(['docker stop other-svc']); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Skipping protected')); - }); - - it('handles docker ps failure gracefully', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-021', name: 'test', severity: 'critical' as const, status: 'fail' as const, detail: 'fail' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 1, - stopContainersOnCritical: true, - }); - - mockExecFileSync.mockImplementationOnce(() => { throw new Error('docker not found'); }); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Could not list Docker')); - }); - - it('handles docker stop failure gracefully', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-027', name: 'test', severity: 'critical' as const, status: 'fail' as const, detail: 'fail' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 1, - stopContainersOnCritical: true, - }); - - mockExecFileSync - .mockReturnValueOnce('my-container\n') - .mockImplementationOnce(() => { throw new Error('permission denied'); }); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop')); - }); - - it('executes custom command with audit data on stdin', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-021', name: 'Socket check', severity: 'critical' as const, status: 'fail' as const, detail: 'exposed socket' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 1, - onCriticalCommand: '/usr/bin/notify --alert', - }); - - mockExecFileSync.mockReturnValueOnce(''); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(true); - expect(res.actions).toContain('exec: /usr/bin/notify --alert'); - - // Verify execFileSync was called with correct args - expect(mockExecFileSync).toHaveBeenCalledWith( - '/usr/bin/notify', - ['--alert'], - expect.objectContaining({ - input: expect.stringContaining('"overallStatus":"critical"'), - env: expect.objectContaining({ - G0_AUDIT_STATUS: 'critical', - G0_AUDIT_FAILED: '1', - }), - }), - ); - }); - - it('handles empty custom command', async () => { - const logger = makeLogger(); - const result = makeResult('critical'); - const config = makeConfig({ - criticalThreshold: 1, - onCriticalCommand: ' ', - }); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('onCriticalCommand is empty')); - }); - - it('handles custom command failure', async () => { - const logger = makeLogger(); - const result = makeResult('critical'); - const config = makeConfig({ - criticalThreshold: 1, - onCriticalCommand: '/bin/false', - }); - - mockExecFileSync.mockImplementationOnce(() => { throw new Error('exit code 1'); }); - - const res = await enforceOnCritical(result, config, logger); - expect(res.actioned).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Custom command failed')); - }); - - it('does not stop containers when no docker checks fail', async () => { - const logger = makeLogger(); - const checks = [ - { id: 'OC-H-040', name: 'Non-docker', severity: 'critical' as const, status: 'fail' as const, detail: 'fail' }, - ]; - const result = makeResult('critical', checks); - const config = makeConfig({ - criticalThreshold: 1, - stopContainersOnCritical: true, - }); - - const res = await enforceOnCritical(result, config, logger); - // No docker ps call should have been made - expect(mockExecFileSync).not.toHaveBeenCalled(); - expect(res.actioned).toBe(false); - }); -}); diff --git a/tests/unit/event-receiver.test.ts b/tests/unit/event-receiver.test.ts deleted file mode 100644 index f2c473f..0000000 --- a/tests/unit/event-receiver.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import { EventReceiver, type ReceivedEvent } from '../../src/daemon/event-receiver.js'; - -// Minimal mock logger -const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -let receiver: EventReceiver; -let port: number; -const events: ReceivedEvent[] = []; - -function getPort(): number { - // Use a random high port to avoid conflicts - return 10000 + Math.floor(Math.random() * 50000); -} - -describe('EventReceiver', () => { - beforeEach(async () => { - events.length = 0; - port = getPort(); - receiver = new EventReceiver({ - port, - bind: '127.0.0.1', - logger: mockLogger as any, - onEvent: (event) => { events.push(event); }, - }); - await receiver.start(); - }); - - afterEach(async () => { - await receiver.stop(); - }); - - it('responds to health check', async () => { - const res = await fetch(`http://127.0.0.1:${port}/health`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe('ok'); - expect(body.events).toBe(0); - }); - - it('receives g0-plugin events on /events', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'tool.executed', - timestamp: '2026-01-01T00:00:00Z', - agentId: 'agent-1', - data: { toolName: 'bash', durationMs: 42 }, - }), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.received).toBe(true); - expect(events).toHaveLength(1); - expect(events[0].type).toBe('tool.executed'); - expect(events[0].source).toBe('g0-plugin'); - expect(events[0].agentId).toBe('agent-1'); - }); - - it('receives events on / (root)', async () => { - const res = await fetch(`http://127.0.0.1:${port}/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'test', data: {} }), - }); - expect(res.status).toBe(200); - expect(events).toHaveLength(1); - }); - - it('receives Falco events on /falco', async () => { - const res = await fetch(`http://127.0.0.1:${port}/falco`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - rule: 'OpenClaw Egress Violation', - priority: 'Warning', - output: 'connection to 1.2.3.4:443', - time: '2026-01-01T00:00:00Z', - tags: ['openclaw', 'egress'], - }), - }); - expect(res.status).toBe(200); - expect(events).toHaveLength(1); - expect(events[0].source).toBe('falcosidekick'); - expect(events[0].type).toBe('OpenClaw Egress Violation'); - }); - - it('returns 404 for unknown routes', async () => { - const res = await fetch(`http://127.0.0.1:${port}/unknown`); - expect(res.status).toBe(404); - }); - - it('returns 400 for invalid JSON', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'not json', - }); - expect(res.status).toBe(400); - }); - - it('tracks event count and recent events', async () => { - for (let i = 0; i < 3; i++) { - await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: `event-${i}`, data: {} }), - }); - } - - const stats = receiver.getStats(); - expect(stats.eventCount).toBe(3); - expect(stats.recentEvents).toHaveLength(3); - }); - - it('handles CORS preflight', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'OPTIONS', - }); - expect(res.status).toBe(204); - expect(res.headers.get('access-control-allow-origin')).toBe('127.0.0.1'); - }); -}); - -describe('EventReceiver with auth', () => { - beforeEach(async () => { - events.length = 0; - port = getPort(); - receiver = new EventReceiver({ - port, - bind: '127.0.0.1', - authToken: 'test-secret-token', - logger: mockLogger as any, - onEvent: (event) => { events.push(event); }, - }); - await receiver.start(); - }); - - afterEach(async () => { - await receiver.stop(); - }); - - it('rejects requests without auth token', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'test', data: {} }), - }); - expect(res.status).toBe(401); - }); - - it('accepts requests with valid auth token', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer test-secret-token', - }, - body: JSON.stringify({ type: 'test', data: {} }), - }); - expect(res.status).toBe(200); - expect(events).toHaveLength(1); - }); - - it('rejects requests with wrong auth token', async () => { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer wrong-token', - }, - body: JSON.stringify({ type: 'test', data: {} }), - }); - expect(res.status).toBe(401); - }); -}); - -describe('EventReceiver with JSONL persistence', () => { - let tmpDir: string; - let logFilePath: string; - - beforeEach(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'g0-events-')); - logFilePath = path.join(tmpDir, 'events.jsonl'); - events.length = 0; - port = getPort(); - receiver = new EventReceiver({ - port, - bind: '127.0.0.1', - logFile: logFilePath, - logger: mockLogger as any, - onEvent: (event) => { events.push(event); }, - }); - await receiver.start(); - }); - - afterEach(async () => { - await receiver.stop(); - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('writes events to JSONL file', async () => { - await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'test.event', data: { foo: 'bar' } }), - }); - - await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'test.event2', data: { baz: 1 } }), - }); - - // Small delay for write stream to flush - await new Promise(r => setTimeout(r, 100)); - - expect(fs.existsSync(logFilePath)).toBe(true); - const lines = fs.readFileSync(logFilePath, 'utf-8').trim().split('\n'); - expect(lines).toHaveLength(2); - - const event1 = JSON.parse(lines[0]); - expect(event1.type).toBe('test.event'); - expect(event1.source).toBe('g0-plugin'); - - const event2 = JSON.parse(lines[1]); - expect(event2.type).toBe('test.event2'); - }); - - it('events survive receiver stop/restart', async () => { - await fetch(`http://127.0.0.1:${port}/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'persist.test', data: {} }), - }); - - await new Promise(r => setTimeout(r, 100)); - await receiver.stop(); - - // File should still exist with the event - expect(fs.existsSync(logFilePath)).toBe(true); - const lines = fs.readFileSync(logFilePath, 'utf-8').trim().split('\n'); - expect(lines).toHaveLength(1); - const event = JSON.parse(lines[0]); - expect(event.type).toBe('persist.test'); - }); -}); diff --git a/tests/unit/fleet.test.ts b/tests/unit/fleet.test.ts deleted file mode 100644 index 591a1c7..0000000 --- a/tests/unit/fleet.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -// Mock the G0 dir to use a temp directory -const tmpDir = path.join(os.tmpdir(), `g0-fleet-test-${Date.now()}`); -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os'); - return { - ...actual, - homedir: () => tmpDir, - }; -}); - -const { - loadFleetState, - saveFleetState, - registerMember, - pruneStaleMembers, - getFleetSummary, - findCommonFailures, -} = await import('../../src/daemon/fleet.js'); - -describe('Fleet Management', () => { - beforeEach(() => { - fs.mkdirSync(path.join(tmpDir, '.g0'), { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return empty state when no file exists', () => { - const state = loadFleetState(); - expect(state.members).toEqual([]); - }); - - it('should register and persist a fleet member', () => { - const member = registerMember('machine-001', { - endpointScore: 85, - endpointGrade: 'B', - }, { group: 'engineering', tags: ['dev'] }); - - expect(member.machineId).toBe('machine-001'); - expect(member.scores.endpointScore).toBe(85); - expect(member.group).toBe('engineering'); - - // Verify persistence - const state = loadFleetState(); - expect(state.members).toHaveLength(1); - expect(state.members[0].machineId).toBe('machine-001'); - }); - - it('should update existing member on re-registration', () => { - registerMember('machine-001', { endpointScore: 70, endpointGrade: 'C' }); - registerMember('machine-001', { endpointScore: 90, endpointGrade: 'A' }); - - const state = loadFleetState(); - expect(state.members).toHaveLength(1); - expect(state.members[0].scores.endpointScore).toBe(90); - }); - - it('should prune stale members', () => { - const state = loadFleetState(); - state.members = [ - { - machineId: 'old-machine', - hostname: 'old', - platform: 'darwin-arm64', - tags: [], - lastSeen: new Date(Date.now() - 100 * 60 * 60 * 1000).toISOString(), // 100h ago - scores: {}, - }, - { - machineId: 'new-machine', - hostname: 'new', - platform: 'darwin-arm64', - tags: [], - lastSeen: new Date().toISOString(), - scores: {}, - }, - ]; - saveFleetState(state); - - const pruned = pruneStaleMembers(72); - expect(pruned).toBe(1); - - const updated = loadFleetState(); - expect(updated.members).toHaveLength(1); - expect(updated.members[0].machineId).toBe('new-machine'); - }); - - it('should compute fleet summary with aggregate scoring', () => { - registerMember('m1', { endpointScore: 90, endpointGrade: 'A' }, { group: 'eng' }); - registerMember('m2', { endpointScore: 70, endpointGrade: 'C' }, { group: 'eng' }); - registerMember('m3', { endpointScore: 50, endpointGrade: 'D', openclawStatus: 'critical' }, { group: 'ops' }); - - const summary = getFleetSummary(); - expect(summary.totalMembers).toBe(3); - expect(summary.byGroup).toEqual({ eng: 2, ops: 1 }); - expect(summary.avgEndpointScore).toBe(70); // (90+70+50)/3 ≈ 70 - expect(summary.worstGrade).toBe('D'); - expect(summary.criticalMembers).toHaveLength(1); - expect(summary.aggregateScore).toBeLessThan(70); // penalized by critical member - }); - - it('should find common failures across fleet', () => { - registerMember('m1', { openclawStatus: 'critical', hostHardeningFailed: 3 }); - registerMember('m2', { openclawStatus: 'critical', hostHardeningFailed: 2 }); - registerMember('m3', { openclawStatus: 'warn' }); - - const failures = findCommonFailures(); - expect(failures.length).toBeGreaterThan(0); - - const critical = failures.find(f => f.issue === 'openclaw-critical'); - expect(critical).toBeDefined(); - expect(critical!.affectedCount).toBe(2); - - const hardening = failures.find(f => f.issue === 'host-hardening-failures'); - expect(hardening).toBeDefined(); - expect(hardening!.affectedCount).toBe(2); - }); - - it('should return no common failures with single member', () => { - registerMember('m1', { openclawStatus: 'critical' }); - - const failures = findCommonFailures(); - // Single member can't have cross-machine issues - expect(failures).toEqual([]); - }); -}); diff --git a/tests/unit/kill-switch.test.ts b/tests/unit/kill-switch.test.ts deleted file mode 100644 index b2ae19c..0000000 --- a/tests/unit/kill-switch.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -describe('Kill Switch', () => { - let tmpDir: string; - let switchPath: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'g0-killswitch-test-')); - switchPath = path.join(tmpDir, '.killswitch'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - describe('activateKillSwitch / deactivateKillSwitch', () => { - it('creates kill switch state file', async () => { - const { activateKillSwitch } = await import('../../src/daemon/kill-switch.js'); - const state = activateKillSwitch('Security breach detected', 'admin'); - expect(state.active).toBe(true); - expect(state.reason).toBe('Security breach detected'); - expect(state.activatedBy).toBe('admin'); - expect(state.timestamp).toBeTruthy(); - }); - }); - - describe('isKillSwitchActive', () => { - it('returns inactive when no file exists', async () => { - const { isKillSwitchActive } = await import('../../src/daemon/kill-switch.js'); - const state = isKillSwitchActive(switchPath); - expect(state.active).toBe(false); - }); - - it('returns active when kill switch file exists', async () => { - const { isKillSwitchActive } = await import('../../src/daemon/kill-switch.js'); - const state = { active: true, reason: 'test', timestamp: new Date().toISOString() }; - fs.writeFileSync(switchPath, JSON.stringify(state)); - - const result = isKillSwitchActive(switchPath); - expect(result.active).toBe(true); - expect(result.reason).toBe('test'); - }); - }); - - describe('KillSwitchMonitor', () => { - it('does not trigger below threshold', async () => { - const { createKillSwitchMonitor } = await import('../../src/daemon/kill-switch.js'); - const monitor = createKillSwitchMonitor( - [{ eventType: 'injection.detected', threshold: 5, windowSeconds: 60 }], - switchPath, - ); - - for (let i = 0; i < 4; i++) { - const result = monitor.recordEvent('injection.detected'); - expect(result).toBeNull(); - } - }); - - it('triggers at threshold', async () => { - const { createKillSwitchMonitor } = await import('../../src/daemon/kill-switch.js'); - const monitor = createKillSwitchMonitor( - [{ eventType: 'injection.detected', threshold: 3, windowSeconds: 60 }], - switchPath, - ); - - monitor.recordEvent('injection.detected'); - monitor.recordEvent('injection.detected'); - const result = monitor.recordEvent('injection.detected'); - - expect(result).not.toBeNull(); - expect(result!.active).toBe(true); - expect(result!.activatedBy).toBe('auto-monitor'); - - // Verify file was written - expect(fs.existsSync(switchPath)).toBe(true); - }); - - it('tracks event counts correctly', async () => { - const { createKillSwitchMonitor } = await import('../../src/daemon/kill-switch.js'); - const monitor = createKillSwitchMonitor( - [ - { eventType: 'injection.detected', threshold: 10, windowSeconds: 60 }, - { eventType: 'tool.blocked', threshold: 10, windowSeconds: 60 }, - ], - switchPath, - ); - - monitor.recordEvent('injection.detected'); - monitor.recordEvent('injection.detected'); - monitor.recordEvent('tool.blocked'); - - const counts = monitor.getEventCounts(); - expect(counts['injection.detected']).toBe(2); - expect(counts['tool.blocked']).toBe(1); - }); - - it('resets counters', async () => { - const { createKillSwitchMonitor } = await import('../../src/daemon/kill-switch.js'); - const monitor = createKillSwitchMonitor( - [{ eventType: 'test', threshold: 10, windowSeconds: 60 }], - switchPath, - ); - - monitor.recordEvent('test'); - monitor.recordEvent('test'); - monitor.reset(); - - const counts = monitor.getEventCounts(); - expect(counts['test']).toBe(0); - }); - - it('ignores events outside time window', async () => { - const { createKillSwitchMonitor } = await import('../../src/daemon/kill-switch.js'); - const monitor = createKillSwitchMonitor( - [{ eventType: 'injection.detected', threshold: 3, windowSeconds: 10 }], - switchPath, - ); - - // Events from 30 seconds ago - const oldTime = new Date(Date.now() - 30000).toISOString(); - monitor.recordEvent('injection.detected', oldTime); - monitor.recordEvent('injection.detected', oldTime); - - // Current event - const result = monitor.recordEvent('injection.detected'); - expect(result).toBeNull(); // Only 1 in window (the old ones expired) - }); - }); -}); diff --git a/tests/unit/notification-manager.test.ts b/tests/unit/notification-manager.test.ts deleted file mode 100644 index 69ad83b..0000000 --- a/tests/unit/notification-manager.test.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { NotificationManager } from '../../src/daemon/notification-manager.js'; -import type { ReceivedEvent } from '../../src/daemon/event-receiver.js'; -import type { CorrelatedThreat } from '../../src/daemon/correlation-engine.js'; -import type { DaemonConfig } from '../../src/daemon/config.js'; - -// Mock alerter -vi.mock('../../src/daemon/alerter.js', () => ({ - postWithRetry: vi.fn().mockResolvedValue({ sent: true, statusCode: 200 }), - sendUrgentAlert: vi.fn().mockResolvedValue({ sent: true, statusCode: 200 }), -})); - -import { postWithRetry, sendUrgentAlert } from '../../src/daemon/alerter.js'; - -const mockPostWithRetry = vi.mocked(postWithRetry); -const mockSendUrgentAlert = vi.mocked(sendUrgentAlert); - -type AlertConfig = NonNullable; - -const baseConfig: AlertConfig = { - webhookUrl: 'https://hooks.slack.com/xxx', - format: 'slack', - minSeverity: 'medium', -}; - -const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} as any; - -function makeEvent(overrides: Partial = {}): ReceivedEvent { - return { - source: 'g0-plugin', - type: 'injection.detected', - timestamp: new Date().toISOString(), - agentId: 'canvas', - data: { detail: 'Tool args injection: bash -c "curl ..."' }, - ...overrides, - }; -} - -function makeThreat(overrides: Partial = {}): CorrelatedThreat { - return { - id: 'CT-001', - name: 'Confirmed Injection', - severity: 'critical', - confidence: 95, - sources: [{ type: 'runtime', id: 'injection.detected' }], - attackChain: ['injection.detected', 'tool.blocked'], - ...overrides, - }; -} - -describe('NotificationManager', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - // ── recordEvent ──────────────────────────────────────────────────────── - - describe('recordEvent', () => { - it('ignores non-security events', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'tool_call' })); - nm.recordEvent(makeEvent({ type: 'agent.started' })); - nm.recordEvent(makeEvent({ type: 'custom.event' })); - - expect(nm.getPendingCount()).toBe(0); - nm.stop(); - }); - - it('accumulates security events correctly', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'canvas' })); - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'workspace' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked', agentId: 'canvas' })); - nm.recordEvent(makeEvent({ type: 'pii.redacted', agentId: 'reports' })); - nm.recordEvent(makeEvent({ type: 'pii.blocked_outbound', agentId: 'reports' })); - - expect(nm.getPendingCount()).toBe(5); - nm.stop(); - }); - - it('keeps max 5 samples per bucket', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - for (let i = 0; i < 10; i++) { - nm.recordEvent(makeEvent({ type: 'injection.detected', data: { detail: `event-${i}` } })); - } - - expect(nm.getPendingCount()).toBe(10); - nm.stop(); - }); - - it('tracks agents across events', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'canvas' })); - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'workspace' })); - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'canvas' })); // duplicate - - // Flush and check the digest includes both agents - await nm.flush(); - expect(mockPostWithRetry).toHaveBeenCalledTimes(1); - const body = mockPostWithRetry.mock.calls[0][1] as any; - // Slack format: check that agents are mentioned - const blocksStr = JSON.stringify(body); - expect(blocksStr).toContain('canvas'); - expect(blocksStr).toContain('workspace'); - nm.stop(); - }); - }); - - // ── off mode ─────────────────────────────────────────────────────────── - - describe('off mode', () => { - it('never sends anything', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'off', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked' })); - nm.recordCorrelationThreats([makeThreat()]); - - expect(nm.getPendingCount()).toBe(0); - const result = await nm.flush(); - expect(result.sent).toBe(false); - expect(result.eventCount).toBe(0); - expect(mockPostWithRetry).not.toHaveBeenCalled(); - expect(mockSendUrgentAlert).not.toHaveBeenCalled(); - nm.stop(); - }); - }); - - // ── realtime mode ────────────────────────────────────────────────────── - - describe('realtime mode', () => { - it('sends alert immediately on security event', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - rateLimitSeconds: 60, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - expect(mockSendUrgentAlert).toHaveBeenCalledWith( - baseConfig, - 'Plugin: injection.detected', - expect.stringContaining('Tool args injection'), - 'critical', - ); - nm.stop(); - }); - - it('rate-limits per category', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - rateLimitSeconds: 60, - }); - - // First event — sent - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - - // Second event within cooldown — suppressed - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - - // Third event within cooldown — still suppressed - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - nm.stop(); - }); - - it('sends again after cooldown expires', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - rateLimitSeconds: 60, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - - // Suppress during cooldown - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - - // Advance past cooldown - vi.advanceTimersByTime(61_000); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(2); - - // Should include suppressed count - const detail = mockSendUrgentAlert.mock.calls[1][2] as string; - expect(detail).toContain('2 more since last alert'); - nm.stop(); - }); - - it('different categories have independent rate limits', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - rateLimitSeconds: 60, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked' })); - nm.recordEvent(makeEvent({ type: 'pii.redacted' })); - - // All three should fire — different categories - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(3); - nm.stop(); - }); - - it('sends correlation threats immediately', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - }); - - nm.recordCorrelationThreats([makeThreat()]); - - expect(mockSendUrgentAlert).toHaveBeenCalledTimes(1); - expect(mockSendUrgentAlert).toHaveBeenCalledWith( - baseConfig, - expect.stringContaining('CT-001'), - expect.stringContaining('95%'), - 'critical', - ); - nm.stop(); - }); - }); - - // ── interval mode ───────────────────────────────────────────────────── - - describe('interval mode', () => { - it('accumulates without sending', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - intervalMinutes: 5, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked' })); - - expect(mockSendUrgentAlert).not.toHaveBeenCalled(); - expect(mockPostWithRetry).not.toHaveBeenCalled(); - expect(nm.getPendingCount()).toBe(2); - nm.stop(); - }); - - it('flush sends correct slack format and resets state', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - intervalMinutes: 5, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'canvas' })); - nm.recordEvent(makeEvent({ type: 'injection.detected', agentId: 'workspace' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked', agentId: 'canvas', data: { detail: 'curl blocked' } })); - nm.recordEvent(makeEvent({ type: 'pii.redacted', agentId: 'reports', data: { detail: '8 redacted' } })); - - const result = await nm.flush(); - expect(result.sent).toBe(true); - expect(result.eventCount).toBe(4); - - // Check Slack Block Kit structure - const body = mockPostWithRetry.mock.calls[0][1] as any; - expect(body.attachments).toBeDefined(); - expect(body.attachments[0].blocks[0].type).toBe('header'); - expect(body.attachments[0].blocks[0].text.text).toContain('Security Digest'); - - // State should be reset - expect(nm.getPendingCount()).toBe(0); - nm.stop(); - }); - - it('flush sends discord format', async () => { - const nm = new NotificationManager({ - alertConfig: { ...baseConfig, format: 'discord' }, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - await nm.flush(); - - const body = mockPostWithRetry.mock.calls[0][1] as any; - expect(body.embeds).toBeDefined(); - expect(body.embeds[0].title).toContain('Security Digest'); - nm.stop(); - }); - - it('flush sends generic format', async () => { - const nm = new NotificationManager({ - alertConfig: { ...baseConfig, format: 'generic' }, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - await nm.flush(); - - const body = mockPostWithRetry.mock.calls[0][1] as any; - expect(body.source).toBe('g0-daemon'); - expect(body.type).toBe('security-digest'); - expect(body.totalEvents).toBe(1); - expect(body.categories.injection).toBeDefined(); - expect(body.categories.injection.count).toBe(1); - nm.stop(); - }); - - it('flush sends pagerduty format', async () => { - const nm = new NotificationManager({ - alertConfig: { ...baseConfig, format: 'pagerduty', routingKey: 'test-key' }, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - await nm.flush(); - - const body = mockPostWithRetry.mock.calls[0][1] as any; - expect(body.routing_key).toBe('test-key'); - expect(body.event_action).toBe('trigger'); - expect(body.payload.component).toBe('g0-plugin'); - nm.stop(); - }); - - it('flush no-ops when empty', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - const result = await nm.flush(); - expect(result.sent).toBe(false); - expect(result.eventCount).toBe(0); - expect(mockPostWithRetry).not.toHaveBeenCalled(); - nm.stop(); - }); - - it('interval timer triggers flush automatically', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - intervalMinutes: 5, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - - // Advance past the interval - await vi.advanceTimersByTimeAsync(5 * 60_000 + 100); - - expect(mockPostWithRetry).toHaveBeenCalledTimes(1); - nm.stop(); - }); - }); - - // ── correlation ──────────────────────────────────────────────────────── - - describe('correlation', () => { - it('threats included in digest', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordCorrelationThreats([makeThreat()]); - - await nm.flush(); - const body = mockPostWithRetry.mock.calls[0][1] as any; - const blocksStr = JSON.stringify(body); - expect(blocksStr).toContain('CT-001'); - expect(blocksStr).toContain('Confirmed Injection'); - nm.stop(); - }); - - it('flush sends when only threats present (no events)', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordCorrelationThreats([makeThreat()]); - - const result = await nm.flush(); - expect(result.sent).toBe(true); - nm.stop(); - }); - }); - - // ── shutdown ─────────────────────────────────────────────────────────── - - describe('shutdown', () => { - it('stop() clears interval timer', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - intervalMinutes: 5, - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.stop(); - - // Advance past the interval — should NOT flush because timer was stopped - vi.advanceTimersByTime(10 * 60_000); - expect(mockPostWithRetry).not.toHaveBeenCalled(); - }); - - it('final flush sends pending events', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked' })); - nm.stop(); - - const result = await nm.flush(); - expect(result.sent).toBe(true); - expect(result.eventCount).toBe(2); - }); - }); - - // ── plugin event data in digests ───────────────────────────────────── - - describe('plugin event summarization', () => { - it('includes patterns and phase from injection events', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ - type: 'injection.detected', - agentId: 'avnish', - data: { - patterns: ['ignore\\s+previous\\s+instructions'], - severity: 'high', - phase: 'llm_input', - model: 'claude-sonnet-4-6', - }, - })); - - await nm.flush(); - const body = JSON.stringify(mockPostWithRetry.mock.calls[0][1]); - expect(body).toContain('llm_input'); - expect(body).toContain('ignore'); - nm.stop(); - }); - - it('includes toolName from tool.blocked events', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ - type: 'tool.blocked', - agentId: 'krish', - data: { toolName: 'bash', reason: 'blocked list' }, - })); - - await nm.flush(); - const body = JSON.stringify(mockPostWithRetry.mock.calls[0][1]); - expect(body).toContain('bash'); - expect(body).toContain('blocked list'); - nm.stop(); - }); - - it('truncates summarized event to 120 chars', async () => { - const nm = new NotificationManager({ - alertConfig: { ...baseConfig, format: 'generic' }, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ - type: 'injection.detected', - data: { - phase: 'llm_input', - toolName: 'very_long_tool_name_that_keeps_going', - patterns: ['pattern_one_is_quite_long', 'pattern_two_is_also_long', 'pattern_three_long'], - detail: 'This is a very long detail string that adds even more characters to push past the limit', - reason: 'Extra reason text that should cause truncation of the summary', - model: 'claude-sonnet-4-6-with-extra-long-model-name', - }, - })); - - await nm.flush(); - const body = mockPostWithRetry.mock.calls[0][1] as any; - const sample: string = body.categories.injection.samples[0]; - expect(sample.length).toBeLessThanOrEqual(120); - expect(sample).toMatch(/\.\.\.$/); - nm.stop(); - }); - - it('falls back to event type when no data fields present', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ - type: 'pii.redacted', - data: {}, - })); - - await nm.flush(); - const body = JSON.stringify(mockPostWithRetry.mock.calls[0][1]); - expect(body).toContain('pii.redacted'); - nm.stop(); - }); - }); - - // ── suppressEventTypes ───────────────────────────────────────────────── - - describe('suppressEventTypes', () => { - it('suppressed event types are not recorded', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - suppressEventTypes: ['pii.redacted', 'pii.detected'], - }); - - nm.recordEvent(makeEvent({ type: 'pii.redacted' })); - nm.recordEvent(makeEvent({ type: 'pii.detected' })); - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.stop(); - - expect(nm.getPendingCount()).toBe(1); - const result = await nm.flush(); - expect(result.eventCount).toBe(1); - }); - - it('non-suppressed events still flow through', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - suppressEventTypes: ['pii.redacted'], - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.recordEvent(makeEvent({ type: 'tool.blocked' })); - nm.stop(); - - expect(nm.getPendingCount()).toBe(2); - }); - - it('empty suppressEventTypes suppresses nothing', async () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'interval', - suppressEventTypes: [], - }); - - nm.recordEvent(makeEvent({ type: 'pii.redacted' })); - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - nm.stop(); - - expect(nm.getPendingCount()).toBe(2); - }); - - it('suppressed events are not sent in realtime mode', () => { - const nm = new NotificationManager({ - alertConfig: baseConfig, - logger: mockLogger, - mode: 'realtime', - suppressEventTypes: ['pii.redacted'], - }); - - nm.recordEvent(makeEvent({ type: 'pii.redacted' })); - nm.stop(); - - expect(mockSendUrgentAlert).not.toHaveBeenCalled(); - expect(nm.getPendingCount()).toBe(0); - }); - }); - - // ── no webhookUrl ────────────────────────────────────────────────────── - - describe('no webhookUrl', () => { - it('flush returns not sent when no webhookUrl', async () => { - const nm = new NotificationManager({ - alertConfig: { format: 'slack' }, - logger: mockLogger, - mode: 'interval', - }); - - nm.recordEvent(makeEvent({ type: 'injection.detected' })); - - const result = await nm.flush(); - expect(result.sent).toBe(false); - expect(result.eventCount).toBe(1); - nm.stop(); - }); - }); -}); From 33339f9a9d8463152ae6c38574366e2e029a1bd8 Mon Sep 17 00:00:00 2001 From: JBAhire Date: Fri, 3 Apr 2026 09:49:35 -0700 Subject: [PATCH 2/2] fix: remove enforcement tests for deleted module --- tests/unit/daemon-openclaw.test.ts | 52 ------------------------------ 1 file changed, 52 deletions(-) diff --git a/tests/unit/daemon-openclaw.test.ts b/tests/unit/daemon-openclaw.test.ts index dcbf6e0..295f0ba 100644 --- a/tests/unit/daemon-openclaw.test.ts +++ b/tests/unit/daemon-openclaw.test.ts @@ -314,58 +314,6 @@ describe('alerter', () => { }); }); -// ── Enforcement ─────────────────────────────────────────────────────────── - -describe('enforcement', () => { - describe('enforceOnCritical', () => { - it('does not action until threshold is reached', async () => { - const { enforceOnCritical, resetCriticalCounter } = await import('../../src/daemon/enforcement.js'); - - resetCriticalCounter(); - - const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any; - - const result = { - checks: [ - { id: 'OC-H-021', name: 'Docker socket', severity: 'critical' as const, status: 'fail' as const, detail: 'Mounted' }, - ], - summary: { total: 1, passed: 0, failed: 1, errors: 0, skipped: 0, overallStatus: 'critical' as const }, - }; - - // First tick — below threshold (default 2) - const first = await enforceOnCritical(result, { criticalThreshold: 2 }, mockLogger); - expect(first.actioned).toBe(false); - - // Second tick — reaches threshold, but no stop config - const second = await enforceOnCritical(result, { criticalThreshold: 2 }, mockLogger); - expect(second.actioned).toBe(false); // No stop or command configured - }); - - it('resets counter when status is not critical', async () => { - const { enforceOnCritical, resetCriticalCounter, getConsecutiveCriticalTicks } = await import('../../src/daemon/enforcement.js'); - - resetCriticalCounter(); - const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any; - - // Tick with critical - await enforceOnCritical( - { checks: [], summary: { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0, overallStatus: 'critical' as const } }, - { criticalThreshold: 3 }, - mockLogger, - ); - expect(getConsecutiveCriticalTicks()).toBe(1); - - // Tick with secure — should reset - await enforceOnCritical( - { checks: [], summary: { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0, overallStatus: 'secure' as const } }, - { criticalThreshold: 3 }, - mockLogger, - ); - expect(getConsecutiveCriticalTicks()).toBe(0); - }); - }); -}); - // ── Heartbeat Status Derivation ─────────────────────────────────────────── describe('heartbeat status', () => {