web4-governance: complete Tier 1.5 — presets, rate limiting, audit query & reporter (#4)
Four features to complete the open-source governance tier: 1. Policy Presets (presets.ts): Built-in rule sets (permissive, safety, strict, audit-only) users can reference by name. Merge order: preset defaults → top-level overrides → additional rules appended. 2. Rate Limiting (rate-limiter.ts): Sliding window counters per key (tool or category). RateLimiter integrated into PolicyEngine — rate limit rules only match when threshold exceeded within window. Memory-only, resets on session restart. 3. Audit Query (audit.ts filter()): Filter audit records by tool, category, status, target pattern (glob), time range (ISO or relative like "1h"), and limit. CLI: moltbot audit query. 4. Audit Reporter (reporter.ts): Aggregates audit data into tool stats, category breakdown, policy decisions, error summaries, and per-minute timeline. Text and JSON output formats. All wired into index.ts with CLI commands (policy presets, audit query, audit report) and plugin.json schema updated with preset field. 151 tests pass across 9 test files. Co-authored-by: dp-web4 <dp@metalinxx.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e96541b4c
commit
df22a4fff7
@ -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"],
|
||||
|
||||
@ -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<PolicyConfig>;
|
||||
policy?: Partial<PolicyConfig> & { preset?: string };
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
@ -48,8 +51,26 @@ const plugin = {
|
||||
const sessions = new Map<string, { state: SessionState; audit: AuditChain }>();
|
||||
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<PolicyConfig> = {};
|
||||
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 <tool>", "Filter by tool name")
|
||||
.option("--category <category>", "Filter by category")
|
||||
.option("--status <status>", "Filter by status (success|error|blocked)")
|
||||
.option("--target <pattern>", "Filter by target (glob pattern)")
|
||||
.option("--since <duration>", "Filter by time (ISO date or relative: 1h, 30m, 2d)")
|
||||
.option("--limit <n>", "Max results (default 50)")
|
||||
.action((opts: Record<string, string>) => {
|
||||
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<string, boolean>) => {
|
||||
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": "<name>" } }`);
|
||||
});
|
||||
},
|
||||
{ commands: ["policy"] },
|
||||
);
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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<PolicyRule> & Pick<PolicyRule, "id" | "match">): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<PolicyConfig> = {}) {
|
||||
constructor(config: Partial<PolicyConfig> = {}, 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;
|
||||
}
|
||||
|
||||
160
extensions/web4-governance/src/presets.test.ts
Normal file
160
extensions/web4-governance/src/presets.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
extensions/web4-governance/src/presets.ts
Normal file
151
extensions/web4-governance/src/presets.ts
Normal file
@ -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<PresetName, PresetDefinition> = {
|
||||
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>,
|
||||
): 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;
|
||||
}
|
||||
172
extensions/web4-governance/src/rate-limiter.test.ts
Normal file
172
extensions/web4-governance/src/rate-limiter.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
83
extensions/web4-governance/src/rate-limiter.ts
Normal file
83
extensions/web4-governance/src/rate-limiter.ts
Normal file
@ -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<string, number[]> = 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}`;
|
||||
}
|
||||
}
|
||||
199
extensions/web4-governance/src/reporter.test.ts
Normal file
199
extensions/web4-governance/src/reporter.test.ts
Normal file
@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
265
extensions/web4-governance/src/reporter.ts
Normal file
265
extensions/web4-governance/src/reporter.ts
Normal file
@ -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<string, { success: number; error: number; blocked: number; durations: number[] }>();
|
||||
|
||||
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<string, number>();
|
||||
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<string, { count: number; messages: Map<string, number> }>();
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user