openclaw/extensions/web4-governance/src/policy.test.ts
dennis palatov 5d83c337d4 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>
2026-01-28 13:33:36 -08:00

398 lines
13 KiB
TypeScript

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 {
name: overrides.id,
priority: 50,
decision: "deny",
...overrides,
};
}
describe("PolicyEngine", () => {
describe("constructor", () => {
it("should use defaults when no config provided", () => {
const engine = new PolicyEngine();
expect(engine.ruleCount).toBe(0);
expect(engine.isEnforcing).toBe(true);
});
it("should sort rules by priority ascending", () => {
const engine = new PolicyEngine({
rules: [
rule({ id: "low", priority: 100, match: { tools: ["Write"] } }),
rule({ id: "high", priority: 1, match: { tools: ["Bash"] } }),
rule({ id: "mid", priority: 50, match: { tools: ["Read"] } }),
],
});
// Bash (priority 1) should match before Write (priority 100)
const evalBash = engine.evaluate("Bash", "command", "ls");
expect(evalBash.matchedRule?.id).toBe("high");
});
});
describe("evaluate", () => {
it("should return default policy when no rules match", () => {
const engine = new PolicyEngine({ defaultPolicy: "allow", rules: [] });
const result = engine.evaluate("Read", "file_read", "/foo");
expect(result.decision).toBe("allow");
expect(result.matchedRule).toBeUndefined();
expect(result.constraints).toEqual(["policy:allow", "rule:default"]);
});
it("should match deny rule by tool name", () => {
const engine = new PolicyEngine({
rules: [rule({ id: "no-bash", match: { tools: ["Bash"] } })],
});
const result = engine.evaluate("Bash", "command", "ls");
expect(result.decision).toBe("deny");
expect(result.matchedRule?.id).toBe("no-bash");
expect(result.constraints).toEqual(["policy:deny", "rule:no-bash"]);
});
it("should match by category", () => {
const engine = new PolicyEngine({
rules: [
rule({ id: "no-network", decision: "warn", match: { categories: ["network"] } }),
],
});
const result = engine.evaluate("WebFetch", "network", "https://example.com");
expect(result.decision).toBe("warn");
expect(result.matchedRule?.id).toBe("no-network");
});
it("should match by target pattern (regex)", () => {
const engine = new PolicyEngine({
rules: [
rule({
id: "no-rm-rf",
match: { targetPatterns: ["rm\\s+-rf"], targetPatternsAreRegex: true },
}),
],
});
expect(engine.evaluate("Bash", "command", "rm -rf /").decision).toBe("deny");
expect(engine.evaluate("Bash", "command", "ls -la").decision).toBe("allow");
});
it("should match by target pattern (glob)", () => {
const engine = new PolicyEngine({
rules: [
rule({
id: "no-env-files",
match: { targetPatterns: ["**/.env*"] },
}),
],
});
expect(engine.evaluate("Read", "file_read", "/project/.env").decision).toBe("deny");
expect(engine.evaluate("Read", "file_read", "/project/.env.local").decision).toBe("deny");
expect(engine.evaluate("Read", "file_read", "/project/src/index.ts").decision).toBe("allow");
});
it("should AND all match criteria", () => {
const engine = new PolicyEngine({
rules: [
rule({
id: "bash-rm-only",
match: {
tools: ["Bash"],
targetPatterns: ["rm\\s+-rf"],
targetPatternsAreRegex: true,
},
}),
],
});
// Both match → deny
expect(engine.evaluate("Bash", "command", "rm -rf /tmp").decision).toBe("deny");
// Tool matches, target doesn't → allow (default)
expect(engine.evaluate("Bash", "command", "ls").decision).toBe("allow");
// Target matches, tool doesn't → allow (default)
expect(engine.evaluate("Read", "file_read", "rm -rf").decision).toBe("allow");
});
it("should use first-match-wins (priority order)", () => {
const engine = new PolicyEngine({
rules: [
rule({ id: "allow-read", priority: 1, decision: "allow", match: { tools: ["Read"] } }),
rule({ id: "deny-all-reads", priority: 10, decision: "deny", match: { categories: ["file_read"] } }),
],
});
// Read matches both, but allow-read has higher priority
expect(engine.evaluate("Read", "file_read", "/foo").decision).toBe("allow");
// Glob only matches deny-all-reads
expect(engine.evaluate("Glob", "file_read", "/src").decision).toBe("deny");
});
it("should include reason from matched rule", () => {
const engine = new PolicyEngine({
rules: [
rule({ id: "x", reason: "Blocked for safety", match: { tools: ["Bash"] } }),
],
});
expect(engine.evaluate("Bash", "command", "ls").reason).toBe("Blocked for safety");
});
it("should use fallback reason when rule has none", () => {
const engine = new PolicyEngine({
rules: [rule({ id: "x", match: { tools: ["Bash"] } })],
});
expect(engine.evaluate("Bash", "command", "ls").reason).toBe("Matched rule: x");
});
it("should respect default policy of deny", () => {
const engine = new PolicyEngine({ defaultPolicy: "deny", rules: [] });
const result = engine.evaluate("Read", "file_read", "/foo");
expect(result.decision).toBe("deny");
expect(result.constraints).toContain("policy:deny");
});
});
describe("shouldBlock", () => {
it("should block when deny + enforce", () => {
const engine = new PolicyEngine({
enforce: true,
rules: [rule({ id: "x", match: { tools: ["Bash"] } })],
});
const { blocked } = engine.shouldBlock("Bash", "command", "ls");
expect(blocked).toBe(true);
});
it("should not block when deny + no enforce (dry-run)", () => {
const engine = new PolicyEngine({
enforce: false,
rules: [rule({ id: "x", match: { tools: ["Bash"] } })],
});
const { blocked, evaluation } = engine.shouldBlock("Bash", "command", "ls");
expect(blocked).toBe(false);
expect(evaluation.decision).toBe("deny");
expect(evaluation.enforced).toBe(false);
});
it("should not block on allow decisions", () => {
const engine = new PolicyEngine({
enforce: true,
rules: [rule({ id: "x", decision: "allow", match: { tools: ["Bash"] } })],
});
expect(engine.shouldBlock("Bash", "command", "ls").blocked).toBe(false);
});
it("should not block on warn decisions", () => {
const engine = new PolicyEngine({
enforce: true,
rules: [rule({ id: "x", decision: "warn", match: { tools: ["Bash"] } })],
});
expect(engine.shouldBlock("Bash", "command", "ls").blocked).toBe(false);
});
});
describe("real-world policy scenarios", () => {
const config: PolicyConfig = {
defaultPolicy: "allow",
enforce: true,
rules: [
{
id: "deny-destructive-commands",
name: "Block destructive shell commands",
priority: 1,
decision: "deny",
reason: "Destructive command blocked",
match: {
tools: ["Bash"],
targetPatterns: ["rm\\s+-rf", "mkfs\\."],
targetPatternsAreRegex: true,
},
},
{
id: "warn-network",
name: "Warn on network access",
priority: 10,
decision: "warn",
match: { categories: ["network"] },
},
{
id: "deny-secrets",
name: "Block reading secret files",
priority: 5,
decision: "deny",
reason: "Secret file access denied",
match: {
categories: ["file_read"],
targetPatterns: ["**/.env", "**/.env.*", "**/credentials.*", "**/*secret*"],
},
},
],
};
const engine = new PolicyEngine(config);
it("should block rm -rf", () => {
const { blocked, evaluation } = engine.shouldBlock("Bash", "command", "rm -rf /tmp/data");
expect(blocked).toBe(true);
expect(evaluation.reason).toBe("Destructive command blocked");
});
it("should block mkfs", () => {
const { blocked } = engine.shouldBlock("Bash", "command", "mkfs.ext4 /dev/sda1");
expect(blocked).toBe(true);
});
it("should allow safe bash commands", () => {
const { blocked } = engine.shouldBlock("Bash", "command", "ls -la /tmp");
expect(blocked).toBe(false);
});
it("should warn on network access", () => {
const eval1 = engine.evaluate("WebFetch", "network", "https://example.com");
expect(eval1.decision).toBe("warn");
expect(eval1.matchedRule?.id).toBe("warn-network");
});
it("should block .env file reads", () => {
const { blocked } = engine.shouldBlock("Read", "file_read", "/project/.env");
expect(blocked).toBe(true);
});
it("should block credentials file reads", () => {
const { blocked } = engine.shouldBlock("Read", "file_read", "/home/user/credentials.json");
expect(blocked).toBe(true);
});
it("should allow normal file reads", () => {
const { blocked } = engine.shouldBlock("Read", "file_read", "/project/src/index.ts");
expect(blocked).toBe(false);
});
it("should allow normal file writes", () => {
const { blocked } = engine.shouldBlock("Write", "file_write", "/project/src/index.ts");
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");
});
});
});