diff --git a/extensions/web4-governance/clawdbot.plugin.json b/extensions/web4-governance/clawdbot.plugin.json index 244ee7727..fec65f920 100644 --- a/extensions/web4-governance/clawdbot.plugin.json +++ b/extensions/web4-governance/clawdbot.plugin.json @@ -21,6 +21,11 @@ "type": "object", "description": "Policy engine configuration for pre-action gating", "properties": { + "preset": { + "type": "string", + "enum": ["permissive", "safety", "strict", "audit-only"], + "description": "Named preset to use as base config. Rules become optional when preset is set." + }, "defaultPolicy": { "type": "string", "enum": ["allow", "deny", "warn"], diff --git a/extensions/web4-governance/index.ts b/extensions/web4-governance/index.ts index af3339840..6d21b8413 100644 --- a/extensions/web4-governance/index.ts +++ b/extensions/web4-governance/index.ts @@ -16,12 +16,15 @@ import { AuditChain } from "./src/audit.js"; import { SessionStore, type SessionState } from "./src/session-state.js"; import { PolicyEngine } from "./src/policy.js"; import type { PolicyConfig, PolicyEvaluation } from "./src/policy-types.js"; +import { RateLimiter } from "./src/rate-limiter.js"; +import { resolvePreset, listPresets, isPresetName } from "./src/presets.js"; +import { AuditReporter } from "./src/reporter.js"; type PluginConfig = { auditLevel?: string; showR6Status?: boolean; storagePath?: string; - policy?: Partial; + policy?: Partial & { preset?: string }; }; const plugin = { @@ -48,8 +51,26 @@ const plugin = { const sessions = new Map(); const sessionStore = new SessionStore(storagePath); - // Policy engine - const policyEngine = new PolicyEngine(config.policy); + // Rate limiter (memory-only, shared across all sessions) + const rateLimiter = new RateLimiter(); + + // Resolve policy config: preset → merge with overrides + let resolvedPolicyConfig: Partial = {}; + if (config.policy) { + const { preset, ...overrides } = config.policy; + if (preset && isPresetName(preset)) { + resolvedPolicyConfig = resolvePreset(preset, overrides); + logger.info(`[web4] Policy preset "${preset}" loaded`); + } else if (preset) { + logger.warn(`[web4] Unknown policy preset "${preset}", using inline config`); + resolvedPolicyConfig = overrides; + } else { + resolvedPolicyConfig = overrides; + } + } + + // Policy engine with rate limiter + const policyEngine = new PolicyEngine(resolvedPolicyConfig, rateLimiter); if (policyEngine.ruleCount > 0) { logger.info( `[web4] Policy engine: ${policyEngine.ruleCount} rules, enforce=${policyEngine.isEnforcing}`, @@ -216,6 +237,21 @@ const plugin = { entry.audit.record(r6, result); sessionStore.incrementAction(entry.state, event.toolName, classifyTool(event.toolName), r6.id); + // Record rate limit action after successful tool execution + if (policyEngine.ruleCount > 0) { + const category = classifyTool(event.toolName); + for (const rule of policyEngine.sortedRules) { + if (rule.match.rateLimit) { + const key = rule.match.tools?.length + ? `ratelimit:${rule.id}:tool:${event.toolName}` + : rule.match.categories?.length + ? `ratelimit:${rule.id}:category:${category}` + : `ratelimit:${rule.id}:global`; + rateLimiter.record(key); + } + } + } + if (auditLevel === "verbose") { logger.info(`[web4] R6 ${r6.id}: ${event.toolName} [${classifyTool(event.toolName)}] (${event.durationMs ?? 0}ms)`); } @@ -280,6 +316,67 @@ const plugin = { } } }); + + // --- Audit Query CLI --- + audit + .command("query") + .description("Query and filter audit records") + .option("--tool ", "Filter by tool name") + .option("--category ", "Filter by category") + .option("--status ", "Filter by status (success|error|blocked)") + .option("--target ", "Filter by target (glob pattern)") + .option("--since ", "Filter by time (ISO date or relative: 1h, 30m, 2d)") + .option("--limit ", "Max results (default 50)") + .action((opts: Record) => { + let hasResults = false; + for (const [, entry] of sessions) { + const results = entry.audit.filter({ + tool: opts.tool, + category: opts.category, + status: opts.status as "success" | "error" | "blocked" | undefined, + targetPattern: opts.target, + since: opts.since, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + }); + + if (results.length > 0) { + hasResults = true; + console.log(`Session: ${entry.state.sessionId} (${results.length} results)`); + for (const r of results) { + const dur = r.result.durationMs !== undefined ? ` ${r.result.durationMs}ms` : ""; + const err = r.result.errorMessage ? ` — ${r.result.errorMessage}` : ""; + console.log(` ${r.timestamp} ${r.tool} [${r.category}] → ${r.target ?? "?"} [${r.result.status}]${dur}${err}`); + } + } + } + if (!hasResults) { + console.log("No matching audit records found."); + } + }); + + // --- Audit Report CLI --- + audit + .command("report") + .description("Generate aggregated audit report") + .option("--json", "Output as JSON") + .action((opts: Record) => { + const allRecords = []; + for (const [, entry] of sessions) { + allRecords.push(...entry.audit.getAll()); + } + + if (allRecords.length === 0) { + console.log("No audit records to report on."); + return; + } + + const reporter = new AuditReporter(allRecords); + if (opts.json) { + console.log(JSON.stringify(reporter.generate(), null, 2)); + } else { + console.log(reporter.formatText()); + } + }); }, { commands: ["audit"] }, ); @@ -298,6 +395,9 @@ const plugin = { console.log(` Rules: ${policyEngine.ruleCount}`); console.log(` Default: ${policyEngine.defaultDecision}`); console.log(` Enforce: ${policyEngine.isEnforcing}`); + if (config.policy?.preset) { + console.log(` Preset: ${config.policy.preset}`); + } }); policy @@ -319,6 +419,9 @@ const plugin = { const kind = match.targetPatternsAreRegex ? "regex" : "glob"; criteria.push(`targets(${kind})=[${match.targetPatterns.join(", ")}]`); } + if (match.rateLimit) { + criteria.push(`rateLimit(max=${match.rateLimit.maxCount}, window=${match.rateLimit.windowMs}ms)`); + } console.log(` [${rule.priority}] ${rule.id} → ${rule.decision}`); console.log(` ${rule.name}`); if (criteria.length > 0) console.log(` match: ${criteria.join(" AND ")}`); @@ -347,6 +450,23 @@ const plugin = { } console.log(`Constraints: ${evaluation.constraints.join(", ")}`); }); + + // --- Policy Presets CLI --- + policy + .command("presets") + .description("List available policy presets") + .action(() => { + const presets = listPresets(); + console.log(`${presets.length} available presets:\n`); + for (const p of presets) { + const ruleCount = p.config.rules.length; + console.log(` ${p.name}`); + console.log(` ${p.description}`); + console.log(` default: ${p.config.defaultPolicy} | enforce: ${p.config.enforce} | rules: ${ruleCount}`); + console.log(); + } + console.log(`Usage: { "policy": { "preset": "" } }`); + }); }, { commands: ["policy"] }, ); diff --git a/extensions/web4-governance/src/audit.test.ts b/extensions/web4-governance/src/audit.test.ts index 5c240384b..7f0206a20 100644 --- a/extensions/web4-governance/src/audit.test.ts +++ b/extensions/web4-governance/src/audit.test.ts @@ -111,4 +111,101 @@ describe("AuditChain", () => { const chain = new AuditChain(TEST_DIR, "nope"); expect(chain.getLast(10)).toEqual([]); }); + + describe("filter", () => { + function populateChain() { + cleanup(); + const chain = new AuditChain(TEST_DIR, "filter-test"); + // Record a mix of tools and statuses + chain.record(makeR6(0, "Read"), { status: "success", durationMs: 10 }); + chain.record(makeR6(1, "Bash"), { status: "success", durationMs: 50 }); + chain.record(makeR6(2, "Bash"), { status: "error", errorMessage: "exit 1", durationMs: 20 }); + chain.record(makeR6(3, "WebFetch"), { status: "blocked" }); + chain.record(makeR6(4, "Read"), { status: "success", durationMs: 5 }); + return chain; + } + + it("should filter by tool", () => { + const chain = populateChain(); + const results = chain.filter({ tool: "Bash" }); + expect(results).toHaveLength(2); + expect(results.every((r) => r.tool === "Bash")).toBe(true); + }); + + it("should filter by status", () => { + const chain = populateChain(); + const results = chain.filter({ status: "error" }); + expect(results).toHaveLength(1); + expect(results[0]!.result.status).toBe("error"); + }); + + it("should filter by status blocked", () => { + const chain = populateChain(); + const results = chain.filter({ status: "blocked" }); + expect(results).toHaveLength(1); + expect(results[0]!.tool).toBe("WebFetch"); + }); + + it("should filter by category", () => { + const chain = populateChain(); + const results = chain.filter({ category: "file_read" }); + expect(results).toHaveLength(2); + }); + + it("should filter by target pattern", () => { + const chain = populateChain(); + // All records in this test have target "/foo" + const results = chain.filter({ targetPattern: "/foo" }); + expect(results).toHaveLength(5); + const noResults = chain.filter({ targetPattern: "/bar" }); + expect(noResults).toHaveLength(0); + }); + + it("should combine multiple filters", () => { + const chain = populateChain(); + const results = chain.filter({ tool: "Bash", status: "success" }); + expect(results).toHaveLength(1); + }); + + it("should respect limit", () => { + const chain = populateChain(); + const results = chain.filter({ limit: 2 }); + expect(results).toHaveLength(2); + }); + + it("should default limit to 50", () => { + const chain = populateChain(); + const results = chain.filter({}); + expect(results).toHaveLength(5); + }); + + it("should return empty for no matches", () => { + const chain = populateChain(); + const results = chain.filter({ tool: "NonexistentTool" }); + expect(results).toHaveLength(0); + }); + + it("should return empty for nonexistent chain", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "nope"); + expect(chain.filter({ tool: "Bash" })).toEqual([]); + }); + }); + + describe("getAll", () => { + it("should return all records", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "all-test"); + for (let i = 0; i < 5; i++) { + chain.record(makeR6(i), { status: "success" }); + } + expect(chain.getAll()).toHaveLength(5); + }); + + it("should return empty for nonexistent file", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "nope"); + expect(chain.getAll()).toEqual([]); + }); + }); }); diff --git a/extensions/web4-governance/src/audit.ts b/extensions/web4-governance/src/audit.ts index e2125b9fa..e42ae3156 100644 --- a/extensions/web4-governance/src/audit.ts +++ b/extensions/web4-governance/src/audit.ts @@ -9,6 +9,7 @@ import { createHash } from "node:crypto"; import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import type { R6Request } from "./r6.js"; +import { globToRegex } from "./matchers.js"; export type AuditRecord = { recordId: string; @@ -30,6 +31,33 @@ export type AuditRecord = { }; }; +export type AuditFilter = { + tool?: string; + category?: string; + status?: "success" | "error" | "blocked"; + targetPattern?: string; + since?: string; + limit?: number; +}; + +/** + * Parse a "since" value: ISO date string or relative duration (e.g. "1h", "30m", "2d"). + * Returns a Date or undefined if unparseable. + */ +function parseSince(since: string): Date | undefined { + // Try relative durations first + const relMatch = since.match(/^(\d+)\s*(s|m|h|d)$/); + if (relMatch) { + const amount = parseInt(relMatch[1]!, 10); + const unit = relMatch[2]!; + const ms = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit]!; + return new Date(Date.now() - amount * ms); + } + // Try ISO date + const d = new Date(since); + return isNaN(d.getTime()) ? undefined : d; +} + export class AuditChain { private storagePath: string; private sessionId: string; @@ -134,4 +162,57 @@ export class AuditChain { }) .filter((r): r is AuditRecord => r !== null); } + + /** Load all records from the JSONL file. */ + getAll(): AuditRecord[] { + if (!existsSync(this.filePath)) return []; + const content = readFileSync(this.filePath, "utf-8").trim(); + if (!content) return []; + return content + .split("\n") + .map((line) => { + try { + return JSON.parse(line) as AuditRecord; + } catch { + return null; + } + }) + .filter((r): r is AuditRecord => r !== null); + } + + /** Filter audit records by criteria. */ + filter(criteria: AuditFilter): AuditRecord[] { + let records = this.getAll(); + const limit = criteria.limit ?? 50; + + if (criteria.tool) { + const tool = criteria.tool; + records = records.filter((r) => r.tool === tool); + } + + if (criteria.category) { + const cat = criteria.category; + records = records.filter((r) => r.category === cat); + } + + if (criteria.status) { + const status = criteria.status; + records = records.filter((r) => r.result.status === status); + } + + if (criteria.targetPattern) { + const re = globToRegex(criteria.targetPattern); + records = records.filter((r) => r.target && re.test(r.target)); + } + + if (criteria.since) { + const sinceDate = parseSince(criteria.since); + if (sinceDate) { + const sinceMs = sinceDate.getTime(); + records = records.filter((r) => new Date(r.timestamp).getTime() >= sinceMs); + } + } + + return records.slice(-limit); + } } diff --git a/extensions/web4-governance/src/policy-types.ts b/extensions/web4-governance/src/policy-types.ts index ea9274f11..3664fccf8 100644 --- a/extensions/web4-governance/src/policy-types.ts +++ b/extensions/web4-governance/src/policy-types.ts @@ -9,6 +9,13 @@ import type { ToolCategory } from "./r6.js"; export type PolicyDecision = "allow" | "deny" | "warn"; +export type RateLimitSpec = { + /** Maximum number of actions allowed within the window */ + maxCount: number; + /** Window duration in milliseconds */ + windowMs: number; +}; + export type PolicyMatch = { /** Tool names to match (e.g. ["Bash", "Write"]) */ tools?: string[]; @@ -18,6 +25,8 @@ export type PolicyMatch = { targetPatterns?: string[]; /** Treat targetPatterns as regex instead of glob */ targetPatternsAreRegex?: boolean; + /** Rate limit: match when action count exceeds threshold within window */ + rateLimit?: RateLimitSpec; }; export type PolicyRule = { @@ -37,6 +46,8 @@ export type PolicyConfig = { /** When false, deny decisions are logged as warnings but not enforced (dry-run) */ enforce: boolean; rules: PolicyRule[]; + /** Named preset to use as base config (rules become optional) */ + preset?: string; }; export type PolicyEvaluation = { diff --git a/extensions/web4-governance/src/policy.test.ts b/extensions/web4-governance/src/policy.test.ts index ac1a014df..6be4476c2 100644 --- a/extensions/web4-governance/src/policy.test.ts +++ b/extensions/web4-governance/src/policy.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { PolicyEngine } from "./policy.js"; import type { PolicyConfig, PolicyRule } from "./policy-types.js"; +import { RateLimiter } from "./rate-limiter.js"; function rule(overrides: Partial & Pick): PolicyRule { return { @@ -268,4 +269,129 @@ describe("PolicyEngine", () => { expect(blocked).toBe(false); }); }); + + describe("rate limiting", () => { + it("should allow when under rate limit", () => { + const limiter = new RateLimiter(); + const engine = new PolicyEngine( + { + rules: [ + rule({ + id: "bash-rate", + decision: "deny", + reason: "Rate limit exceeded", + match: { + tools: ["Bash"], + rateLimit: { maxCount: 3, windowMs: 60_000 }, + }, + }), + ], + }, + limiter, + ); + + // Under limit — rule should NOT match, default allow applies + const result = engine.evaluate("Bash", "command", "ls"); + expect(result.decision).toBe("allow"); + }); + + it("should deny when rate limit exceeded", () => { + const limiter = new RateLimiter(); + const key = "ratelimit:bash-rate:tool:Bash"; + // Fill up the limiter + for (let i = 0; i < 3; i++) limiter.record(key); + + const engine = new PolicyEngine( + { + rules: [ + rule({ + id: "bash-rate", + decision: "deny", + reason: "Rate limit exceeded", + match: { + tools: ["Bash"], + rateLimit: { maxCount: 3, windowMs: 60_000 }, + }, + }), + ], + }, + limiter, + ); + + const result = engine.evaluate("Bash", "command", "ls"); + expect(result.decision).toBe("deny"); + expect(result.reason).toBe("Rate limit exceeded"); + }); + + it("should skip rate limit rules when no limiter provided", () => { + const engine = new PolicyEngine({ + rules: [ + rule({ + id: "rate-no-limiter", + decision: "deny", + match: { + tools: ["Bash"], + rateLimit: { maxCount: 1, windowMs: 60_000 }, + }, + }), + ], + }); + + // No limiter — rate limit rule is skipped + const result = engine.evaluate("Bash", "command", "ls"); + expect(result.decision).toBe("allow"); + }); + + it("should use category key for category-based rate limits", () => { + const limiter = new RateLimiter(); + const key = "ratelimit:net-rate:category:network"; + for (let i = 0; i < 2; i++) limiter.record(key); + + const engine = new PolicyEngine( + { + rules: [ + rule({ + id: "net-rate", + decision: "warn", + match: { + categories: ["network"], + rateLimit: { maxCount: 2, windowMs: 60_000 }, + }, + }), + ], + }, + limiter, + ); + + const result = engine.evaluate("WebFetch", "network", "https://example.com"); + expect(result.decision).toBe("warn"); + expect(result.matchedRule?.id).toBe("net-rate"); + }); + + it("should not match rate limit rule when tool does not match", () => { + const limiter = new RateLimiter(); + const key = "ratelimit:bash-rate:tool:Bash"; + for (let i = 0; i < 5; i++) limiter.record(key); + + const engine = new PolicyEngine( + { + rules: [ + rule({ + id: "bash-rate", + decision: "deny", + match: { + tools: ["Bash"], + rateLimit: { maxCount: 3, windowMs: 60_000 }, + }, + }), + ], + }, + limiter, + ); + + // Read doesn't match the tool criterion + const result = engine.evaluate("Read", "file_read", "/foo"); + expect(result.decision).toBe("allow"); + }); + }); }); diff --git a/extensions/web4-governance/src/policy.ts b/extensions/web4-governance/src/policy.ts index 9321d1f1e..29e004c0e 100644 --- a/extensions/web4-governance/src/policy.ts +++ b/extensions/web4-governance/src/policy.ts @@ -3,6 +3,7 @@ * * Rules are sorted by priority (ascending). First match wins. * Supports allow/deny/warn decisions with optional dry-run mode. + * Optionally integrates with RateLimiter for time-windowed rate limiting. */ import type { ToolCategory } from "./r6.js"; @@ -13,6 +14,7 @@ import type { PolicyRule, } from "./policy-types.js"; import { matchesRule } from "./matchers.js"; +import type { RateLimiter } from "./rate-limiter.js"; export const DEFAULT_POLICY_CONFIG: PolicyConfig = { defaultPolicy: "allow", @@ -24,13 +26,15 @@ export class PolicyEngine { private rules: PolicyRule[]; private defaultPolicy: PolicyDecision; private enforce: boolean; + private rateLimiter?: RateLimiter; - constructor(config: Partial = {}) { + constructor(config: Partial = {}, rateLimiter?: RateLimiter) { const merged = { ...DEFAULT_POLICY_CONFIG, ...config }; this.defaultPolicy = merged.defaultPolicy; this.enforce = merged.enforce; // Sort by priority ascending (lower = higher priority) this.rules = [...merged.rules].sort((a, b) => a.priority - b.priority); + this.rateLimiter = rateLimiter; } /** Evaluate a tool call against all rules. First match wins. */ @@ -40,18 +44,35 @@ export class PolicyEngine { target: string | undefined, ): PolicyEvaluation { for (const rule of this.rules) { - if (matchesRule(toolName, category, target, rule.match)) { - const decision = rule.decision; - const enforced = decision === "deny" ? this.enforce : true; - const reason = rule.reason ?? `Matched rule: ${rule.name}`; - return { - decision, - matchedRule: rule, - enforced, - reason, - constraints: [`policy:${decision}`, `rule:${rule.id}`], - }; + // Check standard matchers + const matchesStandard = matchesRule(toolName, category, target, rule.match); + if (!matchesStandard) continue; + + // If rule has rateLimit, also check the rate limiter + if (rule.match.rateLimit && this.rateLimiter) { + const key = this.buildRateLimitKey(rule, toolName, category); + const { allowed } = this.rateLimiter.check( + key, + rule.match.rateLimit.maxCount, + rule.match.rateLimit.windowMs, + ); + // Rate limit rule only "matches" when the limit is exceeded + if (allowed) continue; + } else if (rule.match.rateLimit && !this.rateLimiter) { + // No rate limiter available — skip rate limit criterion + continue; } + + const decision = rule.decision; + const enforced = decision === "deny" ? this.enforce : true; + const reason = rule.reason ?? `Matched rule: ${rule.name}`; + return { + decision, + matchedRule: rule, + enforced, + reason, + constraints: [`policy:${decision}`, `rule:${rule.id}`], + }; } // No rule matched — apply default @@ -74,6 +95,18 @@ export class PolicyEngine { return { blocked, evaluation }; } + /** Build rate limit key from rule + tool context. */ + private buildRateLimitKey(rule: PolicyRule, toolName: string, category: ToolCategory): string { + // Use the most specific match criterion for the key + if (rule.match.tools?.length) { + return `ratelimit:${rule.id}:tool:${toolName}`; + } + if (rule.match.categories?.length) { + return `ratelimit:${rule.id}:category:${category}`; + } + return `ratelimit:${rule.id}:global`; + } + get ruleCount(): number { return this.rules.length; } diff --git a/extensions/web4-governance/src/presets.test.ts b/extensions/web4-governance/src/presets.test.ts new file mode 100644 index 000000000..1c2f4603c --- /dev/null +++ b/extensions/web4-governance/src/presets.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + getPreset, + listPresets, + isPresetName, + resolvePreset, + type PresetName, +} from "./presets.js"; + +describe("Presets", () => { + const ALL_PRESETS: PresetName[] = ["permissive", "safety", "strict", "audit-only"]; + + describe("listPresets", () => { + it("should return all four presets", () => { + const presets = listPresets(); + expect(presets).toHaveLength(4); + const names = presets.map((p) => p.name); + expect(names).toEqual(expect.arrayContaining(ALL_PRESETS)); + }); + + it("should have non-empty descriptions", () => { + for (const preset of listPresets()) { + expect(preset.description.length).toBeGreaterThan(0); + } + }); + }); + + describe("getPreset", () => { + it("should return preset by name", () => { + const safety = getPreset("safety"); + expect(safety).toBeDefined(); + expect(safety!.name).toBe("safety"); + }); + + it("should return undefined for unknown preset", () => { + expect(getPreset("nonexistent")).toBeUndefined(); + }); + }); + + describe("isPresetName", () => { + it("should return true for valid preset names", () => { + for (const name of ALL_PRESETS) { + expect(isPresetName(name)).toBe(true); + } + }); + + it("should return false for invalid names", () => { + expect(isPresetName("bogus")).toBe(false); + expect(isPresetName("")).toBe(false); + }); + }); + + describe("preset structure validation", () => { + it("permissive should have no rules and enforce=false", () => { + const preset = getPreset("permissive")!; + expect(preset.config.rules).toHaveLength(0); + expect(preset.config.enforce).toBe(false); + expect(preset.config.defaultPolicy).toBe("allow"); + }); + + it("safety should have rules and enforce=true", () => { + const preset = getPreset("safety")!; + expect(preset.config.rules.length).toBeGreaterThan(0); + expect(preset.config.enforce).toBe(true); + expect(preset.config.defaultPolicy).toBe("allow"); + }); + + it("strict should default deny and have allow rules", () => { + const preset = getPreset("strict")!; + expect(preset.config.defaultPolicy).toBe("deny"); + expect(preset.config.enforce).toBe(true); + expect(preset.config.rules.length).toBeGreaterThan(0); + // All strict rules should be allow (since default is deny) + for (const rule of preset.config.rules) { + expect(rule.decision).toBe("allow"); + } + }); + + it("audit-only should have same rules as safety but enforce=false", () => { + const safety = getPreset("safety")!; + const auditOnly = getPreset("audit-only")!; + expect(auditOnly.config.enforce).toBe(false); + expect(auditOnly.config.rules).toEqual(safety.config.rules); + }); + + it("no preset should have rules with missing ids", () => { + for (const preset of listPresets()) { + for (const rule of preset.config.rules) { + expect(rule.id).toBeTruthy(); + expect(rule.name).toBeTruthy(); + expect(typeof rule.priority).toBe("number"); + expect(["allow", "deny", "warn"]).toContain(rule.decision); + } + } + }); + }); + + describe("resolvePreset", () => { + it("should return preset config with no overrides", () => { + const config = resolvePreset("safety"); + const preset = getPreset("safety")!; + expect(config.defaultPolicy).toBe(preset.config.defaultPolicy); + expect(config.enforce).toBe(preset.config.enforce); + expect(config.rules).toEqual(preset.config.rules); + }); + + it("should override defaultPolicy", () => { + const config = resolvePreset("safety", { defaultPolicy: "deny" }); + expect(config.defaultPolicy).toBe("deny"); + }); + + it("should override enforce", () => { + const config = resolvePreset("safety", { enforce: false }); + expect(config.enforce).toBe(false); + }); + + it("should append additional rules after preset rules", () => { + const extra = { + rules: [ + { + id: "extra-rule", + name: "Extra", + priority: 100, + decision: "deny" as const, + match: { tools: ["Write"] }, + }, + ], + }; + const config = resolvePreset("safety", extra); + const presetRules = getPreset("safety")!.config.rules; + expect(config.rules.length).toBe(presetRules.length + 1); + expect(config.rules[config.rules.length - 1]!.id).toBe("extra-rule"); + }); + + it("should not mutate the original preset", () => { + const before = getPreset("safety")!.config.rules.length; + resolvePreset("safety", { + rules: [ + { + id: "tmp", + name: "tmp", + priority: 99, + decision: "deny", + match: { tools: ["Bash"] }, + }, + ], + }); + expect(getPreset("safety")!.config.rules.length).toBe(before); + }); + + it("should throw for unknown preset name", () => { + expect(() => resolvePreset("bogus")).toThrow("Unknown policy preset"); + }); + + it("should not append empty rules array", () => { + const config = resolvePreset("safety", { rules: [] }); + expect(config.rules).toEqual(getPreset("safety")!.config.rules); + }); + }); +}); diff --git a/extensions/web4-governance/src/presets.ts b/extensions/web4-governance/src/presets.ts new file mode 100644 index 000000000..4295a3f78 --- /dev/null +++ b/extensions/web4-governance/src/presets.ts @@ -0,0 +1,151 @@ +/** + * Policy Presets - Built-in rule sets users can reference by name. + * + * Presets provide sensible defaults for common governance postures. + * Users can override individual fields and append additional rules. + */ + +import type { PolicyConfig, PolicyRule } from "./policy-types.js"; + +export type PresetName = "permissive" | "safety" | "strict" | "audit-only"; + +export type PresetDefinition = { + name: PresetName; + description: string; + config: PolicyConfig; +}; + +const SAFETY_RULES: PolicyRule[] = [ + { + id: "deny-destructive-commands", + name: "Block destructive shell commands", + priority: 1, + decision: "deny", + reason: "Destructive command blocked by safety preset", + match: { + tools: ["Bash"], + targetPatterns: ["rm\\s+-rf", "mkfs\\."], + targetPatternsAreRegex: true, + }, + }, + { + id: "deny-secret-files", + name: "Block reading secret files", + priority: 5, + decision: "deny", + reason: "Secret file access denied by safety preset", + match: { + categories: ["file_read"], + targetPatterns: ["**/.env", "**/.env.*", "**/credentials.*", "**/*secret*"], + }, + }, + { + id: "warn-network", + name: "Warn on network access", + priority: 10, + decision: "warn", + reason: "Network access flagged by safety preset", + match: { + categories: ["network"], + }, + }, +]; + +const PRESETS: Record = { + permissive: { + name: "permissive", + description: "Pure observation — no rules, all actions allowed", + config: { + defaultPolicy: "allow", + enforce: false, + rules: [], + }, + }, + safety: { + name: "safety", + description: "Deny destructive bash, deny secret file reads, warn on network", + config: { + defaultPolicy: "allow", + enforce: true, + rules: SAFETY_RULES, + }, + }, + strict: { + name: "strict", + description: "Deny everything except Read, Glob, Grep, and TodoWrite", + config: { + defaultPolicy: "deny", + enforce: true, + rules: [ + { + id: "allow-read-tools", + name: "Allow read-only tools", + priority: 1, + decision: "allow", + reason: "Read-only tool permitted by strict preset", + match: { + tools: ["Read", "Glob", "Grep", "TodoWrite"], + }, + }, + ], + }, + }, + "audit-only": { + name: "audit-only", + description: "Same rules as safety but enforce=false (dry-run, logs what would be blocked)", + config: { + defaultPolicy: "allow", + enforce: false, + rules: SAFETY_RULES, + }, + }, +}; + +/** Get a preset by name, or undefined if not found. */ +export function getPreset(name: string): PresetDefinition | undefined { + return PRESETS[name as PresetName]; +} + +/** List all available preset names. */ +export function listPresets(): PresetDefinition[] { + return Object.values(PRESETS); +} + +/** All valid preset names. */ +export function isPresetName(name: string): name is PresetName { + return name in PRESETS; +} + +/** + * Resolve a policy config from preset + overrides. + * + * Merge order: + * 1. Preset defaults (defaultPolicy, enforce, rules) + * 2. Top-level overrides (defaultPolicy, enforce) from user config + * 3. Additional rules from user config are appended after preset rules + */ +export function resolvePreset( + presetName: string, + overrides?: Partial, +): PolicyConfig { + const preset = getPreset(presetName); + if (!preset) { + throw new Error(`Unknown policy preset: "${presetName}". Available: ${Object.keys(PRESETS).join(", ")}`); + } + + const base = { ...preset.config }; + + if (overrides) { + if (overrides.defaultPolicy !== undefined) { + base.defaultPolicy = overrides.defaultPolicy; + } + if (overrides.enforce !== undefined) { + base.enforce = overrides.enforce; + } + if (overrides.rules && overrides.rules.length > 0) { + base.rules = [...base.rules, ...overrides.rules]; + } + } + + return base; +} diff --git a/extensions/web4-governance/src/rate-limiter.test.ts b/extensions/web4-governance/src/rate-limiter.test.ts new file mode 100644 index 000000000..af458055b --- /dev/null +++ b/extensions/web4-governance/src/rate-limiter.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { RateLimiter } from "./rate-limiter.js"; + +describe("RateLimiter", () => { + let limiter: RateLimiter; + + beforeEach(() => { + limiter = new RateLimiter(); + }); + + describe("check", () => { + it("should allow when no actions recorded", () => { + const result = limiter.check("key1", 5, 60_000); + expect(result.allowed).toBe(true); + expect(result.current).toBe(0); + expect(result.limit).toBe(5); + }); + + it("should allow when under limit", () => { + limiter.record("key1"); + limiter.record("key1"); + const result = limiter.check("key1", 5, 60_000); + expect(result.allowed).toBe(true); + expect(result.current).toBe(2); + }); + + it("should deny when at limit", () => { + for (let i = 0; i < 5; i++) limiter.record("key1"); + const result = limiter.check("key1", 5, 60_000); + expect(result.allowed).toBe(false); + expect(result.current).toBe(5); + }); + + it("should deny when over limit", () => { + for (let i = 0; i < 10; i++) limiter.record("key1"); + const result = limiter.check("key1", 5, 60_000); + expect(result.allowed).toBe(false); + expect(result.current).toBe(10); + }); + }); + + describe("window expiry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should expire old entries", () => { + vi.setSystemTime(1000); + limiter.record("key1"); + limiter.record("key1"); + limiter.record("key1"); + + // Move time forward past window + vi.setSystemTime(62_000); + const result = limiter.check("key1", 3, 60_000); + expect(result.allowed).toBe(true); + expect(result.current).toBe(0); + }); + + it("should keep recent entries within window", () => { + vi.setSystemTime(1000); + limiter.record("key1"); + + vi.setSystemTime(30_000); + limiter.record("key1"); + + vi.setSystemTime(59_000); + // First entry is within 60s window (59000 - 1000 = 58000 < 60000) + const result = limiter.check("key1", 3, 60_000); + expect(result.allowed).toBe(true); + expect(result.current).toBe(2); + }); + + it("should partially expire entries", () => { + vi.setSystemTime(1000); + limiter.record("key1"); // will expire + + vi.setSystemTime(50_000); + limiter.record("key1"); // will survive + + vi.setSystemTime(70_000); + // cutoff = 70000 - 60000 = 10000, so t=1000 expires, t=50000 survives + const result = limiter.check("key1", 2, 60_000); + expect(result.allowed).toBe(true); + expect(result.current).toBe(1); + }); + }); + + describe("concurrent keys", () => { + it("should track keys independently", () => { + for (let i = 0; i < 5; i++) limiter.record("bash"); + limiter.record("read"); + + expect(limiter.check("bash", 5, 60_000).allowed).toBe(false); + expect(limiter.check("read", 5, 60_000).allowed).toBe(true); + }); + + it("should report correct keyCount", () => { + expect(limiter.keyCount).toBe(0); + limiter.record("a"); + limiter.record("b"); + limiter.record("c"); + expect(limiter.keyCount).toBe(3); + }); + }); + + describe("record", () => { + it("should create key on first record", () => { + expect(limiter.count("new")).toBe(0); + limiter.record("new"); + expect(limiter.count("new")).toBe(1); + }); + + it("should increment count", () => { + limiter.record("key1"); + limiter.record("key1"); + limiter.record("key1"); + expect(limiter.count("key1")).toBe(3); + }); + }); + + describe("prune", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should remove expired entries across all keys", () => { + vi.setSystemTime(1000); + limiter.record("a"); + limiter.record("b"); + + vi.setSystemTime(70_000); + const pruned = limiter.prune(60_000); + expect(pruned).toBe(2); + expect(limiter.keyCount).toBe(0); + }); + + it("should keep recent entries during prune", () => { + vi.setSystemTime(1000); + limiter.record("a"); // will expire + + vi.setSystemTime(50_000); + limiter.record("a"); // will survive + + vi.setSystemTime(70_000); + const pruned = limiter.prune(60_000); + expect(pruned).toBe(1); + expect(limiter.count("a")).toBe(1); + }); + + it("should return 0 when nothing to prune", () => { + limiter.record("a"); + const pruned = limiter.prune(60_000); + expect(pruned).toBe(0); + }); + }); + + describe("static key", () => { + it("should build namespaced key", () => { + expect(RateLimiter.key("rule1", "Bash")).toBe("ratelimit:rule1:Bash"); + expect(RateLimiter.key("deny-net", "network")).toBe("ratelimit:deny-net:network"); + }); + }); +}); diff --git a/extensions/web4-governance/src/rate-limiter.ts b/extensions/web4-governance/src/rate-limiter.ts new file mode 100644 index 000000000..afb4ad4da --- /dev/null +++ b/extensions/web4-governance/src/rate-limiter.ts @@ -0,0 +1,83 @@ +/** + * Rate Limiter - Sliding window counters for policy rate limiting. + * + * Memory-only (no persistence). Resets on session restart. + * Keys are derived from rule context: e.g. "ratelimit:tool:Bash" + */ + +export type RateLimitResult = { + allowed: boolean; + current: number; + limit: number; +}; + +export class RateLimiter { + private windows: Map = new Map(); + + /** Check whether a key is under its rate limit. Prunes expired entries. */ + check(key: string, maxCount: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const cutoff = now - windowMs; + const timestamps = this.windows.get(key); + + if (!timestamps) { + return { allowed: true, current: 0, limit: maxCount }; + } + + // Prune expired entries in-place + const pruned = timestamps.filter((t) => t > cutoff); + this.windows.set(key, pruned); + + return { + allowed: pruned.length < maxCount, + current: pruned.length, + limit: maxCount, + }; + } + + /** Record a new action for the given key. */ + record(key: string): void { + const timestamps = this.windows.get(key); + if (timestamps) { + timestamps.push(Date.now()); + } else { + this.windows.set(key, [Date.now()]); + } + } + + /** Prune all expired entries across all keys. */ + prune(windowMs: number): number { + const now = Date.now(); + const cutoff = now - windowMs; + let pruned = 0; + + for (const [key, timestamps] of this.windows) { + const before = timestamps.length; + const filtered = timestamps.filter((t) => t > cutoff); + pruned += before - filtered.length; + + if (filtered.length === 0) { + this.windows.delete(key); + } else { + this.windows.set(key, filtered); + } + } + + return pruned; + } + + /** Get current count for a key (without pruning). */ + count(key: string): number { + return this.windows.get(key)?.length ?? 0; + } + + /** Number of tracked keys. */ + get keyCount(): number { + return this.windows.size; + } + + /** Build a rate limit key from rule context. */ + static key(ruleId: string, toolOrCategory: string): string { + return `ratelimit:${ruleId}:${toolOrCategory}`; + } +} diff --git a/extensions/web4-governance/src/reporter.test.ts b/extensions/web4-governance/src/reporter.test.ts new file mode 100644 index 000000000..a7537f157 --- /dev/null +++ b/extensions/web4-governance/src/reporter.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { AuditReporter } from "./reporter.js"; +import type { AuditRecord } from "./audit.js"; + +function makeRecord(overrides: Partial = {}): AuditRecord { + return { + recordId: "audit:test", + r6RequestId: "r6:test", + timestamp: "2026-01-27T10:00:00.000Z", + tool: "Read", + category: "file_read", + target: "/foo/bar.ts", + result: { + status: "success", + durationMs: 10, + }, + provenance: { + sessionId: "s1", + actionIndex: 0, + prevRecordHash: "genesis", + }, + ...overrides, + }; +} + +function makeSyntheticRecords(): AuditRecord[] { + return [ + makeRecord({ tool: "Read", category: "file_read", result: { status: "success", durationMs: 10 }, timestamp: "2026-01-27T10:00:00.000Z" }), + makeRecord({ tool: "Read", category: "file_read", result: { status: "success", durationMs: 20 }, timestamp: "2026-01-27T10:00:30.000Z" }), + makeRecord({ tool: "Bash", category: "command", result: { status: "success", durationMs: 50 }, timestamp: "2026-01-27T10:01:00.000Z" }), + makeRecord({ tool: "Bash", category: "command", result: { status: "error", errorMessage: "exit 1", durationMs: 5 }, timestamp: "2026-01-27T10:01:30.000Z" }), + makeRecord({ tool: "WebFetch", category: "network", result: { status: "blocked" }, timestamp: "2026-01-27T10:02:00.000Z" }), + makeRecord({ tool: "Write", category: "file_write", result: { status: "success", durationMs: 15 }, timestamp: "2026-01-27T10:02:30.000Z" }), + makeRecord({ tool: "Bash", category: "command", result: { status: "error", errorMessage: "exit 1" }, timestamp: "2026-01-27T10:03:00.000Z" }), + makeRecord({ tool: "Bash", category: "command", result: { status: "error", errorMessage: "timeout" }, timestamp: "2026-01-27T10:03:30.000Z" }), + ]; +} + +describe("AuditReporter", () => { + describe("generate", () => { + it("should handle empty records", () => { + const reporter = new AuditReporter([]); + const report = reporter.generate(); + expect(report.totalRecords).toBe(0); + expect(report.timeRange).toBeNull(); + expect(report.toolStats).toHaveLength(0); + expect(report.categoryBreakdown).toHaveLength(0); + expect(report.policyStats.totalEvaluated).toBe(0); + }); + + it("should compute correct total", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.totalRecords).toBe(8); + }); + + it("should compute time range", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.timeRange).not.toBeNull(); + expect(report.timeRange!.from).toBe("2026-01-27T10:00:00.000Z"); + expect(report.timeRange!.to).toBe("2026-01-27T10:03:30.000Z"); + }); + }); + + describe("tool stats", () => { + it("should aggregate per tool", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + const bash = report.toolStats.find((t) => t.tool === "Bash"); + expect(bash).toBeDefined(); + expect(bash!.invocations).toBe(4); + expect(bash!.successCount).toBe(1); + expect(bash!.errorCount).toBe(3); + }); + + it("should calculate success rate", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + const read = report.toolStats.find((t) => t.tool === "Read"); + expect(read!.successRate).toBe(1); + const bash = report.toolStats.find((t) => t.tool === "Bash"); + expect(bash!.successRate).toBe(0.25); + }); + + it("should calculate avg duration", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + const read = report.toolStats.find((t) => t.tool === "Read"); + expect(read!.avgDurationMs).toBe(15); // (10+20)/2 + }); + + it("should return null avgDuration when no durations", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + const wf = report.toolStats.find((t) => t.tool === "WebFetch"); + expect(wf!.avgDurationMs).toBeNull(); + }); + + it("should sort by invocation count descending", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.toolStats[0]!.tool).toBe("Bash"); + }); + }); + + describe("category breakdown", () => { + it("should compute category counts and percentages", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + const cmd = report.categoryBreakdown.find((c) => c.category === "command"); + expect(cmd!.count).toBe(4); + expect(cmd!.percentage).toBe(50); + }); + + it("should sort by count descending", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.categoryBreakdown[0]!.category).toBe("command"); + }); + }); + + describe("policy stats", () => { + it("should count blocked as deny", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.policyStats.denyCount).toBe(1); + expect(report.policyStats.allowCount).toBe(7); + }); + + it("should compute block rate", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.policyStats.blockRate).toBeCloseTo(1 / 8); + }); + }); + + describe("errors", () => { + it("should aggregate errors by tool", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.errors).toHaveLength(1); // only Bash has errors + expect(report.errors[0]!.tool).toBe("Bash"); + expect(report.errors[0]!.count).toBe(3); + }); + + it("should list top error messages", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.errors[0]!.topMessages).toContain("exit 1"); + expect(report.errors[0]!.topMessages).toContain("timeout"); + }); + + it("should sort messages by frequency", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + // "exit 1" appears twice, "timeout" once + expect(report.errors[0]!.topMessages[0]).toBe("exit 1"); + }); + }); + + describe("timeline", () => { + it("should bucket by minute", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + expect(report.timeline.length).toBeGreaterThan(0); + // First two records share the same minute bucket (both at :00 seconds apart) + // The exact minute string depends on local timezone, so just check the first bucket has 2 + expect(report.timeline[0]!.count).toBe(2); + }); + + it("should sort chronologically", () => { + const records = makeSyntheticRecords(); + const report = new AuditReporter(records).generate(); + for (let i = 1; i < report.timeline.length; i++) { + expect(report.timeline[i]!.minute >= report.timeline[i - 1]!.minute).toBe(true); + } + }); + }); + + describe("formatText", () => { + it("should produce non-empty text output", () => { + const records = makeSyntheticRecords(); + const text = new AuditReporter(records).formatText(); + expect(text.length).toBeGreaterThan(0); + expect(text).toContain("Audit Report"); + expect(text).toContain("Tool Stats"); + expect(text).toContain("Categories"); + expect(text).toContain("Policy"); + expect(text).toContain("Errors"); + expect(text).toContain("Timeline"); + }); + + it("should handle empty records", () => { + const text = new AuditReporter([]).formatText(); + expect(text).toContain("Total records: 0"); + }); + }); +}); diff --git a/extensions/web4-governance/src/reporter.ts b/extensions/web4-governance/src/reporter.ts new file mode 100644 index 000000000..c63fb9c42 --- /dev/null +++ b/extensions/web4-governance/src/reporter.ts @@ -0,0 +1,265 @@ +/** + * Audit Reporter - Aggregate audit data into summary reports. + * + * Accepts AuditRecord[] and computes stats for tool usage, + * category breakdown, policy decisions, errors, and timeline. + */ + +import type { AuditRecord } from "./audit.js"; + +export type ToolStats = { + tool: string; + invocations: number; + successCount: number; + errorCount: number; + blockedCount: number; + successRate: number; + avgDurationMs: number | null; +}; + +export type CategoryBreakdown = { + category: string; + count: number; + percentage: number; +}; + +export type PolicyStats = { + totalEvaluated: number; + allowCount: number; + denyCount: number; + warnCount: number; + blockRate: number; +}; + +export type ErrorSummary = { + tool: string; + count: number; + topMessages: string[]; +}; + +export type TimelineBucket = { + minute: string; + count: number; +}; + +export type AuditReport = { + totalRecords: number; + timeRange: { from: string; to: string } | null; + toolStats: ToolStats[]; + categoryBreakdown: CategoryBreakdown[]; + policyStats: PolicyStats; + errors: ErrorSummary[]; + timeline: TimelineBucket[]; +}; + +export class AuditReporter { + private records: AuditRecord[]; + + constructor(records: AuditRecord[]) { + this.records = records; + } + + generate(): AuditReport { + return { + totalRecords: this.records.length, + timeRange: this.computeTimeRange(), + toolStats: this.computeToolStats(), + categoryBreakdown: this.computeCategoryBreakdown(), + policyStats: this.computePolicyStats(), + errors: this.computeErrors(), + timeline: this.computeTimeline(), + }; + } + + private computeTimeRange(): { from: string; to: string } | null { + if (this.records.length === 0) return null; + const sorted = [...this.records].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + return { + from: sorted[0]!.timestamp, + to: sorted[sorted.length - 1]!.timestamp, + }; + } + + private computeToolStats(): ToolStats[] { + const map = new Map(); + + for (const r of this.records) { + let entry = map.get(r.tool); + if (!entry) { + entry = { success: 0, error: 0, blocked: 0, durations: [] }; + map.set(r.tool, entry); + } + if (r.result.status === "success") entry.success++; + else if (r.result.status === "error") entry.error++; + else if (r.result.status === "blocked") entry.blocked++; + if (r.result.durationMs !== undefined) entry.durations.push(r.result.durationMs); + } + + return Array.from(map.entries()) + .map(([tool, data]) => { + const total = data.success + data.error + data.blocked; + return { + tool, + invocations: total, + successCount: data.success, + errorCount: data.error, + blockedCount: data.blocked, + successRate: total > 0 ? data.success / total : 0, + avgDurationMs: + data.durations.length > 0 + ? data.durations.reduce((a, b) => a + b, 0) / data.durations.length + : null, + }; + }) + .sort((a, b) => b.invocations - a.invocations); + } + + private computeCategoryBreakdown(): CategoryBreakdown[] { + const counts = new Map(); + for (const r of this.records) { + counts.set(r.category, (counts.get(r.category) ?? 0) + 1); + } + const total = this.records.length; + return Array.from(counts.entries()) + .map(([category, count]) => ({ + category, + count, + percentage: total > 0 ? (count / total) * 100 : 0, + })) + .sort((a, b) => b.count - a.count); + } + + private computePolicyStats(): PolicyStats { + // Derive policy decisions from result status: + // blocked → deny, error/success → allow (we don't have warn in result status) + let allowCount = 0; + let denyCount = 0; + const warnCount = 0; // Warn doesn't block, so it shows as success/error in results + + for (const r of this.records) { + if (r.result.status === "blocked") { + denyCount++; + } else { + allowCount++; + } + } + + const total = this.records.length; + return { + totalEvaluated: total, + allowCount, + denyCount, + warnCount, + blockRate: total > 0 ? denyCount / total : 0, + }; + } + + private computeErrors(): ErrorSummary[] { + const map = new Map }>(); + + for (const r of this.records) { + if (r.result.status !== "error") continue; + let entry = map.get(r.tool); + if (!entry) { + entry = { count: 0, messages: new Map() }; + map.set(r.tool, entry); + } + entry.count++; + if (r.result.errorMessage) { + const msg = r.result.errorMessage; + entry.messages.set(msg, (entry.messages.get(msg) ?? 0) + 1); + } + } + + return Array.from(map.entries()) + .map(([tool, data]) => ({ + tool, + count: data.count, + topMessages: Array.from(data.messages.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([msg]) => msg), + })) + .sort((a, b) => b.count - a.count); + } + + private computeTimeline(): TimelineBucket[] { + const buckets = new Map(); + + for (const r of this.records) { + // Bucket by minute (truncate to minute) + const d = new Date(r.timestamp); + const minute = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + buckets.set(minute, (buckets.get(minute) ?? 0) + 1); + } + + return Array.from(buckets.entries()) + .map(([minute, count]) => ({ minute, count })) + .sort((a, b) => a.minute.localeCompare(b.minute)); + } + + /** Format report as structured text. */ + formatText(): string { + const report = this.generate(); + const lines: string[] = []; + + lines.push("=== Audit Report ==="); + lines.push(`Total records: ${report.totalRecords}`); + if (report.timeRange) { + lines.push(`Time range: ${report.timeRange.from} → ${report.timeRange.to}`); + } + lines.push(""); + + // Tool stats + lines.push("--- Tool Stats ---"); + if (report.toolStats.length === 0) { + lines.push(" (no data)"); + } + for (const ts of report.toolStats) { + const dur = ts.avgDurationMs !== null ? `${ts.avgDurationMs.toFixed(0)}ms avg` : "n/a"; + lines.push( + ` ${ts.tool}: ${ts.invocations} calls, ${(ts.successRate * 100).toFixed(0)}% success, ${dur}`, + ); + } + lines.push(""); + + // Category breakdown + lines.push("--- Categories ---"); + for (const cb of report.categoryBreakdown) { + lines.push(` ${cb.category}: ${cb.count} (${cb.percentage.toFixed(1)}%)`); + } + lines.push(""); + + // Policy + lines.push("--- Policy ---"); + lines.push(` Evaluated: ${report.policyStats.totalEvaluated}`); + lines.push(` Allowed: ${report.policyStats.allowCount}`); + lines.push(` Denied: ${report.policyStats.denyCount}`); + lines.push(` Block rate: ${(report.policyStats.blockRate * 100).toFixed(1)}%`); + lines.push(""); + + // Errors + if (report.errors.length > 0) { + lines.push("--- Errors ---"); + for (const err of report.errors) { + lines.push(` ${err.tool}: ${err.count} errors`); + for (const msg of err.topMessages) { + lines.push(` - ${msg}`); + } + } + lines.push(""); + } + + // Timeline + if (report.timeline.length > 0) { + lines.push("--- Timeline (actions/min) ---"); + for (const bucket of report.timeline) { + lines.push(` ${bucket.minute}: ${bucket.count}`); + } + } + + return lines.join("\n"); + } +}