openclaw/extensions/web4-governance/index.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

479 lines
19 KiB
TypeScript

/**
* Web4 Governance Plugin for Moltbot
*
* Adds R6 workflow formalism, audit trails, and session identity
* to moltbot agent sessions. Uses internal hooks for session lifecycle
* and typed after_tool_call hooks for tool-level audit.
*/
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
import { join } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { createSoftLCT } from "./src/soft-lct.js";
import { createR6Request, hashOutput, classifyTool, extractTarget } from "./src/r6.js";
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> & { preset?: string };
};
const plugin = {
id: "web4-governance",
name: "Web4 Governance",
description: "R6 workflow formalism, audit trails, and trust-native session identity",
configSchema: {
type: "object" as const,
additionalProperties: false,
properties: {
auditLevel: { type: "string", enum: ["minimal", "standard", "verbose"], default: "standard" },
showR6Status: { type: "boolean", default: true },
storagePath: { type: "string" },
},
},
register(api: MoltbotPluginApi) {
const config = (api.pluginConfig ?? {}) as PluginConfig;
const storagePath = config.storagePath ?? join(homedir(), ".web4");
const auditLevel = config.auditLevel ?? "standard";
const logger = api.logger;
// Per-session state (keyed by sessionKey)
const sessions = new Map<string, { state: SessionState; audit: AuditChain }>();
const sessionStore = new SessionStore(storagePath);
// 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}`,
);
}
// Stash for passing policy evaluations from before_tool_call to after_tool_call
const policyStash = new Map<string, PolicyEvaluation>();
function getOrCreateSession(sessionKey: string): { state: SessionState; audit: AuditChain } {
let entry = sessions.get(sessionKey);
if (!entry) {
const sessionId = sessionKey || randomUUID();
const lct = createSoftLCT(sessionId);
const state: SessionState = {
sessionId,
lct,
actionIndex: 0,
startedAt: new Date().toISOString(),
toolCounts: {},
categoryCounts: {},
};
const audit = new AuditChain(storagePath, sessionId);
sessionStore.save(state);
entry = { state, audit };
sessions.set(sessionKey, entry);
logger.info(`[web4] Session ${lct.tokenId} initialized (${auditLevel} audit)`);
}
return entry;
}
// --- Internal Hooks (these actually fire in current moltbot) ---
// Hook into agent bootstrap - fires when agent session starts
api.registerHook(["agent", "agent:bootstrap"], async (event) => {
const sessionKey = event.sessionKey || "default";
const entry = getOrCreateSession(sessionKey);
logger.info(`[web4] Governance active: ${entry.state.lct.tokenId} (session: ${sessionKey})`);
}, { name: "web4-agent-bootstrap", description: "Initialize Web4 governance session on agent bootstrap" });
// Hook into session events
api.registerHook(["session"], async (event) => {
const sessionKey = event.sessionKey || "default";
if (event.action === "new" || event.action === "start") {
const entry = getOrCreateSession(sessionKey);
logger.info(`[web4] Session ${event.action}: ${entry.state.lct.tokenId}`);
} else if (event.action === "end" || event.action === "stop" || event.action === "reset") {
const entry = sessions.get(sessionKey);
if (entry) {
const verification = entry.audit.verify();
logger.info(
`[web4] Session ${event.action}: ${entry.state.actionIndex} actions, ` +
`chain ${verification.valid ? "VALID" : "INVALID"} (${verification.recordCount} records)`,
);
sessionStore.save(entry.state);
sessions.delete(sessionKey);
}
}
}, { name: "web4-session-lifecycle", description: "Track Web4 session lifecycle events" });
// Hook into command events - captures all agent commands
api.registerHook(["command"], async (event) => {
const sessionKey = event.sessionKey || "default";
const entry = getOrCreateSession(sessionKey);
const { state } = entry;
// Create R6 request for the command
const toolName = String(event.context.command ?? event.action ?? "unknown");
const params = (event.context ?? {}) as Record<string, unknown>;
const r6 = createR6Request(
state.sessionId,
undefined,
toolName,
params,
state.actionIndex,
state.lastR6Id,
auditLevel,
);
// Record in audit chain with success (commands that reach hooks succeeded)
const result = {
status: "success" as const,
outputHash: hashOutput(event.context),
};
r6.result = result;
entry.audit.record(r6, result);
// Update session state
const category = classifyTool(toolName);
sessionStore.incrementAction(state, toolName, category, r6.id);
if (auditLevel === "verbose") {
logger.info(`[web4] R6 ${r6.id}: ${toolName} [${category}] → ${r6.request.target ?? "(no target)"}`);
}
}, { name: "web4-command-audit", description: "Record R6 audit entries for agent commands" });
// --- Typed Tool Hooks (wired via pi-tools.hooks.ts) ---
// Pre-action policy gating
api.on("before_tool_call", (event, ctx) => {
if (policyEngine.ruleCount === 0) return;
const category = classifyTool(event.toolName);
const target = extractTarget(event.toolName, event.params);
const { blocked, evaluation } = policyEngine.shouldBlock(event.toolName, category, target);
// Stash evaluation for after_tool_call to pick up
const sid = ctx.sessionKey ?? ctx.agentId ?? "default";
policyStash.set(sid, evaluation);
if (evaluation.decision === "warn") {
logger.warn(
`[web4] Policy WARN: ${event.toolName} [${category}] → ${target ?? "(no target)"}${evaluation.reason}`,
);
}
if (blocked) {
logger.warn(
`[web4] Policy DENY: ${event.toolName} [${category}] → ${target ?? "(no target)"}${evaluation.reason}`,
);
return { block: true, blockReason: `[web4-policy] ${evaluation.reason}` };
}
if (evaluation.decision === "deny" && !evaluation.enforced) {
// Dry-run mode: log but don't block
logger.warn(
`[web4] Policy DENY (dry-run): ${event.toolName} [${category}] → ${target ?? "(no target)"}${evaluation.reason}`,
);
}
});
api.on("after_tool_call", (event, ctx) => {
const sid = ctx.sessionKey ?? ctx.agentId ?? "default";
const entry = sessions.get(sid);
if (!entry) return;
// Pick up stashed policy evaluation from before_tool_call
const policyEval = policyStash.get(sid);
policyStash.delete(sid);
const r6 = createR6Request(
entry.state.sessionId,
ctx.agentId,
event.toolName,
event.params,
entry.state.actionIndex,
entry.state.lastR6Id,
auditLevel,
);
// Write policy constraints to R6
if (policyEval) {
r6.rules.constraints = policyEval.constraints;
}
const result = {
status: (event.error ? "error" : "success") as "success" | "error",
outputHash: event.result ? hashOutput(event.result) : undefined,
errorMessage: event.error,
durationMs: event.durationMs,
};
r6.result = result;
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)`);
}
});
// --- CLI Commands ---
api.registerCli(
({ program }) => {
const audit = program.command("audit").description("Web4 governance audit trail");
audit
.command("summary")
.description("Show session audit summary")
.action(() => {
for (const [sid, entry] of sessions) {
const v = entry.audit.verify();
console.log(`Session: ${entry.state.lct.tokenId}`);
console.log(` Actions: ${entry.state.actionIndex}`);
console.log(` Audit records: ${v.recordCount}`);
console.log(` Chain valid: ${v.valid}`);
console.log(` Tools: ${JSON.stringify(entry.state.toolCounts)}`);
console.log(` Categories: ${JSON.stringify(entry.state.categoryCounts)}`);
}
if (sessions.size === 0) {
console.log("No active governance sessions.");
}
});
audit
.command("verify")
.description("Verify audit chain integrity")
.argument("[sessionId]", "Session ID to verify")
.action((sessionId?: string) => {
if (sessionId) {
const chain = new AuditChain(storagePath, sessionId);
const result = chain.verify();
console.log(`Chain valid: ${result.valid}`);
console.log(`Records: ${result.recordCount}`);
if (result.errors.length > 0) {
console.log("Errors:");
for (const e of result.errors) console.log(` - ${e}`);
}
} else {
for (const [, entry] of sessions) {
const result = entry.audit.verify();
console.log(`${entry.state.sessionId}: ${result.valid ? "VALID" : "INVALID"} (${result.recordCount} records)`);
}
}
});
audit
.command("last")
.description("Show last N audit records")
.argument("[count]", "Number of records", "10")
.action((countStr: string) => {
const count = parseInt(countStr, 10) || 10;
for (const [, entry] of sessions) {
const records = entry.audit.getLast(count);
for (const r of records) {
console.log(`${r.timestamp} ${r.tool}${r.target ?? "?"} [${r.result.status}]`);
}
}
});
// --- 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"] },
);
// --- Policy Admin CLI ---
api.registerCli(
({ program }) => {
const policy = program.command("policy").description("Web4 policy engine administration");
policy
.command("status")
.description("Show policy engine status")
.action(() => {
console.log(`Policy engine:`);
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
.command("rules")
.description("List all policy rules in evaluation order")
.action(() => {
const rules = policyEngine.sortedRules;
if (rules.length === 0) {
console.log("No policy rules configured.");
return;
}
console.log(`${rules.length} rules (priority order):\n`);
for (const rule of rules) {
const match = rule.match;
const criteria: string[] = [];
if (match.tools) criteria.push(`tools=[${match.tools.join(", ")}]`);
if (match.categories) criteria.push(`categories=[${match.categories.join(", ")}]`);
if (match.targetPatterns) {
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 ")}`);
if (rule.reason) console.log(` reason: ${rule.reason}`);
console.log();
}
console.log(`Default: ${policyEngine.defaultDecision} | Enforce: ${policyEngine.isEnforcing}`);
});
policy
.command("test")
.description("Dry-run a tool call against the policy engine")
.argument("<toolName>", "Tool name (e.g. Bash, Read, WebFetch)")
.argument("[target]", "Target string (e.g. command, file path, URL)")
.action((toolName: string, target?: string) => {
const category = classifyTool(toolName);
const evaluation = policyEngine.evaluate(toolName, category, target);
console.log(`Tool: ${toolName}`);
console.log(`Category: ${category}`);
console.log(`Target: ${target ?? "(none)"}`);
console.log(`Decision: ${evaluation.decision}`);
console.log(`Enforced: ${evaluation.enforced}`);
console.log(`Reason: ${evaluation.reason}`);
if (evaluation.matchedRule) {
console.log(`Rule: ${evaluation.matchedRule.id} (priority ${evaluation.matchedRule.priority})`);
}
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"] },
);
logger.info(`[web4] Web4 Governance plugin loaded (audit: ${auditLevel})`);
},
};
export default plugin;