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>
479 lines
19 KiB
TypeScript
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;
|