From 9e96541b4c1307361ac98842a011f67f6dca7730 Mon Sep 17 00:00:00 2001 From: dennis palatov Date: Tue, 27 Jan 2026 21:32:39 -0800 Subject: [PATCH] Add web4-governance plugin with R6 audit trails and policy engine (#3) * Add policy engine to web4-governance (Tier 1.5) Configurable rule-based pre-action gating via before_tool_call hook. Rules match by tool name, category, and target pattern (glob/regex). Decisions (allow/deny/warn) enforced or dry-run, recorded in R6 constraints. Co-Authored-By: Claude Opus 4.5 * Add policy admin CLI: status, rules, test commands * Add README and update ARCHITECTURE.md for Tier 1.5 policy engine --------- Co-authored-by: dp-web4 Co-authored-by: Claude Opus 4.5 --- extensions/web4-governance/ARCHITECTURE.md | 134 +++++++ extensions/web4-governance/README.md | 343 ++++++++++++++++++ .../web4-governance/clawdbot.plugin.json | 75 ++++ extensions/web4-governance/index.ts | 128 ++++++- .../web4-governance/src/matchers.test.ts | 120 ++++++ extensions/web4-governance/src/matchers.ts | 86 +++++ .../web4-governance/src/policy-types.ts | 52 +++ extensions/web4-governance/src/policy.test.ts | 271 ++++++++++++++ extensions/web4-governance/src/policy.ts | 93 +++++ 9 files changed, 1299 insertions(+), 3 deletions(-) create mode 100644 extensions/web4-governance/ARCHITECTURE.md create mode 100644 extensions/web4-governance/README.md create mode 100644 extensions/web4-governance/src/matchers.test.ts create mode 100644 extensions/web4-governance/src/matchers.ts create mode 100644 extensions/web4-governance/src/policy-types.ts create mode 100644 extensions/web4-governance/src/policy.test.ts create mode 100644 extensions/web4-governance/src/policy.ts diff --git a/extensions/web4-governance/ARCHITECTURE.md b/extensions/web4-governance/ARCHITECTURE.md new file mode 100644 index 000000000..22ce2f813 --- /dev/null +++ b/extensions/web4-governance/ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Web4 Governance Plugin — Architecture & Cross-Project Context + +## What This Is + +The `web4-governance` plugin is a **Tier 1 (Observational)** implementation of the +Web4 R6 framework, running inside the moltbot agent runtime. It creates verifiable +audit trails for every tool call an agent makes, without blocking or requiring +approval. + +This is the first live integration of Web4 governance into an agent runtime that +actually executes tools — not a simulation or demo. + +## Where It Sits in the Web4 Stack + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Hardbound (Tier 2) │ +│ Full Policy │ Trust Tensors │ ATP │ Hardware Binding │ +│ hardbound-core/src/policy.rs │ +│ hardbound/src/policy.ts │ +└───────────────────────────┬─────────────────────────────────┘ + │ upgrade path +┌───────────────────────────┴─────────────────────────────────┐ +│ web4-governance (Tier 1 + 1.5) ← YOU ARE HERE │ +│ R6 Audit │ Soft LCT │ Hash Chain │ Policy Engine │ CLI │ +│ moltbot/extensions/web4-governance/ │ +└───────────────────────────┬─────────────────────────────────┘ + │ hooks into +┌───────────────────────────┴─────────────────────────────────┐ +│ Moltbot Agent Runtime │ +│ pi-tools.hooks.ts (before_tool_call / after_tool_call) │ +│ moltbot/src/agents/pi-tools.ts │ +└─────────────────────────────────────────────────────────────┘ +``` + +### R6 Implementation Tiers (from web4-standard/core-spec/r6-implementation-guide.md) + +| Tier | Project | R6 Scope | Trust Model | Enforcement | +|------|---------|----------|-------------|-------------| +| **1 — Observational** | web4-governance (this plugin) | Lite: audit_level, session token, tool/category/target/hash, chain position | None (relying party decides) | Record-only | +| **1.5 — Policy** | web4-governance (this plugin) | Lite + configurable policy rules, allow/deny/warn, glob/regex matching | Rule-based (first-match-wins) | Block or warn (with dry-run mode) | +| **2 — Authorization** | hardbound-core (Rust) | Full: policy rules, actor LCT, team context, ATP, trust delta | T3 tensor (competence, reliability, integrity) | Approve/Reject/Escalate | +| **3 — Training** | HRM/SAGE | Training: exercise type, mode detection, meta-cognitive | T3 with developmental trajectory | Include/Exclude/Review | + +## What Was Built (PRs #1 and #2) + +### PR #1: Tool Call Hooks (`src/agents/pi-tools.hooks.ts`) +- Wired `before_tool_call` / `after_tool_call` typed plugin hooks into moltbot's tool execution pipeline +- `before_tool_call` can modify params or block execution (returns `{ block: true, blockReason }`) +- `after_tool_call` fires post-execution with result, error, and duration (fire-and-forget) +- This is the hook surface that enables both observation (Tier 1) and enforcement (Tier 2) + +### PR #2: Web4 Governance Plugin (`extensions/web4-governance/`) +- **R6 framework** (`src/r6.ts`): Creates structured R6 requests from tool calls. Classifies tools into categories (file_read, file_write, command, network, delegation, state). Hashes inputs and extracts targets. +- **Audit chain** (`src/audit.ts`): Hash-linked JSONL append log. Each record's `prevRecordHash` is the SHA-256 prefix of the previous line. Verifiable integrity. +- **Session state** (`src/session-state.ts`): Tracks action index, tool/category counts, last R6 ID per session. +- **Soft LCT** (`src/soft-lct.ts`): Software-bound identity token from `hostname:username` hash. Not hardware-bound — that's the Hardbound upgrade path. + +## Relationship to Hardbound + +### What's shared (protocol-compatible) +- R6 request structure (Tier 1 is a subset of Tier 2) +- Audit record format (Tier 1 records can be imported into Tier 2) +- Tool categories map to Hardbound `ActionType` enum +- Hash-linked provenance chain +- Session identity concept (Soft LCT → Hardware LCT upgrade path) + +### What Hardbound adds (Tier 2, proprietary) +- **PolicyEngine** (`policy.rs`): Evaluates R6 requests against rules, roles, trust thresholds, ATP balance. Returns Approve/Reject/Escalate/AutoApprove. +- **T3 Trust Tensors**: competence, reliability, integrity scoring with context weights +- **Coherence Metrics**: score + delta tracking with attestation +- **ATP Economics**: Resource allocation, daily limits, transfer caps +- **Hardware Binding**: TPM/SE-based LCT (P0 blocker, not yet implemented) +- **Governance Rules**: Role-based (developer, lead, admin, viewer, guest), action-type scoped, with prohibited requirements and auto-approve thresholds + +### The upgrade path +The R6 implementation guide documents a progressive adoption model: +1. **Start**: Install web4-governance plugin (observational audit trail) +2. **Grow**: Add policy evaluation in `before_tool_call` (this is the next step) +3. **Extend**: Connect to Hardbound for full T3/ATP/hardware-bound governance + +## Tier 1.5: Policy Engine (Implemented) + +The policy engine uses the `before_tool_call` hook to evaluate configurable rules +before each tool call. Rules match by tool name, category, and target pattern +(glob or regex). Decisions are allow, deny, or warn. Deny decisions block tool +execution when `enforce: true`; in dry-run mode (`enforce: false`), denials are +logged but not enforced. + +### What was built +- **Policy types** (`src/policy-types.ts`): `PolicyRule`, `PolicyMatch`, `PolicyConfig`, `PolicyEvaluation`, `PolicyDecision` +- **Matchers** (`src/matchers.ts`): Glob-to-regex conversion, list matching, target pattern matching, composite AND-logic rule matching +- **PolicyEngine** (`src/policy.ts`): Loads rules, sorts by priority (ascending), first-match-wins evaluation, `shouldBlock()` for enforcement +- **Integration** (`index.ts`): `before_tool_call` hook evaluates policy and blocks if deny + enforce; `after_tool_call` picks up stashed evaluation and writes constraints to R6 `rules.constraints` +- **CLI** (`index.ts`): `moltbot policy status`, `moltbot policy rules`, `moltbot policy test [target]` + +### Deferred to Phase 2 +- Rate limiting (needs windowed counters in session state) +- Config hot-reload +- T3/ATP integration (that's Tier 2 / Hardbound) + +### What this enables for Hardbound +The upgrade to Tier 2 is: +- Replace rule evaluation with PolicyEngine from hardbound-core +- Add T3 tensor snapshots to audit records +- Add coherence metrics +- Replace Soft LCT with hardware-bound LCT +- Add ATP tracking + +The plugin interface stays the same — just the policy evaluation gets richer. + +## Storage Layout + +``` +~/.web4/ +├── audit/ +│ └── .jsonl # Hash-linked audit records (append-only) +└── sessions/ + └── .json # Session metadata (overwritten on each action) +``` + +## Cross-Project References + +| File | Project | Relevance | +|------|---------|-----------| +| `web4-standard/core-spec/r6-implementation-guide.md` | web4 | Tier definitions, ID formats, upgrade path | +| `web4-standard/core-spec/r6-security-analysis.md` | web4 | Attack vectors and mitigations | +| `hardbound-core/src/policy.rs` | hardbound | Full policy engine (Rust) | +| `hardbound/src/policy.ts` | hardbound | TypeScript policy with rule builder | +| `hardbound/tests/policy.test.ts` | hardbound | Policy test patterns | +| `hardbound-core/src/r6.rs` | hardbound | Full R6 request (Rust) | +| `hardbound/MVP_IMPLEMENTATION.md` | hardbound | MVP status, bundle schema, lessons | +| `src/agents/pi-tools.hooks.ts` | moltbot | Hook wrapper (our PR #1) | +| `src/plugins/hooks.ts` | moltbot | Hook runner (runBeforeToolCall/runAfterToolCall) | diff --git a/extensions/web4-governance/README.md b/extensions/web4-governance/README.md new file mode 100644 index 000000000..be26ea8f3 --- /dev/null +++ b/extensions/web4-governance/README.md @@ -0,0 +1,343 @@ +# web4-governance + +R6 workflow formalism, audit trails, session identity, and policy-based pre-action gating for moltbot agent sessions. + +## Overview + +This plugin observes and optionally gates every tool call an agent makes: + +- **R6 audit records** capture intent, context, and outcome for each action +- **Hash-linked chain** provides tamper-evident provenance (SHA-256 chain) +- **Session identity** via software-bound Linked Context Tokens (Soft LCT) +- **Policy engine** evaluates rules before tool execution, with allow/deny/warn decisions + +## Installation + +The plugin is bundled with moltbot. Enable it in your moltbot config: + +```json +{ + "plugins": { + "web4-governance": {} + } +} +``` + +## Configuration + +All fields are optional. Defaults shown below. + +```json +{ + "plugins": { + "web4-governance": { + "auditLevel": "standard", + "showR6Status": true, + "storagePath": "~/.web4/", + "policy": { + "defaultPolicy": "allow", + "enforce": true, + "rules": [] + } + } + } +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `auditLevel` | `"minimal" \| "standard" \| "verbose"` | `"standard"` | Controls audit detail level. `verbose` logs every R6 to the console. | +| `showR6Status` | `boolean` | `true` | Show R6 chain status in session output. | +| `storagePath` | `string` | `~/.web4/` | Directory for audit logs and session state. | +| `policy` | `object` | see below | Policy engine configuration. | + +## Policy Engine + +The policy engine evaluates every tool call against a configurable set of rules before execution. Rules are matched in priority order (ascending); first match wins. + +### Policy Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `defaultPolicy` | `"allow" \| "deny" \| "warn"` | `"allow"` | Decision when no rule matches. | +| `enforce` | `boolean` | `true` | When `false`, deny decisions are logged but not enforced (dry-run mode). | +| `rules` | `PolicyRule[]` | `[]` | Ordered list of policy rules. | + +### Rule Schema + +```json +{ + "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 + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | yes | Unique rule identifier, used in audit constraints. | +| `name` | yes | Human-readable rule name. | +| `priority` | yes | Lower number = evaluated first. First match wins. | +| `decision` | yes | `"allow"`, `"deny"`, or `"warn"`. | +| `reason` | no | Reason string recorded in audit and shown on block. | +| `match` | yes | Match criteria (all specified fields are AND'd). | + +### Match Criteria + +All specified criteria within a rule must match (AND logic). Omitted criteria are ignored. + +| Field | Type | Description | +|-------|------|-------------| +| `tools` | `string[]` | Tool names: `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `Task`, `NotebookEdit`, `TodoWrite` | +| `categories` | `string[]` | Tool categories: `file_read`, `file_write`, `command`, `network`, `delegation`, `state`, `mcp`, `unknown` | +| `targetPatterns` | `string[]` | Patterns to match against the tool's target (file path, command, URL, etc.). Glob by default. | +| `targetPatternsAreRegex` | `boolean` | Set `true` to treat `targetPatterns` as regex instead of glob. Default: `false`. | + +### Target Extraction + +The target matched against `targetPatterns` is extracted from tool parameters: + +| Tool | Target source | +|------|---------------| +| Read, Write, Edit, NotebookEdit | `file_path` param | +| Glob, Grep | `path` or `pattern` param | +| Bash | `command` param (truncated to 80 chars) | +| WebFetch, WebSearch | `url` param | +| Task, TodoWrite | no target extracted | + +### Glob Patterns + +Glob matching supports `*` (any characters except `/`), `**` (any characters including `/`), and `?` (single character). Special regex characters are escaped. + +Examples: +- `**/.env*` matches `/project/.env`, `/project/.env.local` +- `/src/*.ts` matches `/src/index.ts` but not `/src/sub/index.ts` +- `/src/**/*.ts` matches any `.ts` file under `/src/` at any depth + +### Decisions + +| Decision | Behavior (enforce=true) | Behavior (enforce=false) | +|----------|------------------------|--------------------------| +| `allow` | Tool executes normally | Tool executes normally | +| `deny` | Tool is **blocked**, returns `[blocked] [web4-policy] ` | Logged as warning, tool executes | +| `warn` | Tool executes, warning logged | Tool executes, warning logged | + +### Audit Integration + +Policy decisions are recorded in the R6 `rules.constraints` field: + +```json +{ + "rules": { + "auditLevel": "standard", + "constraints": ["policy:deny", "rule:deny-destructive-commands"] + } +} +``` + +### Example: Full Policy Config + +```json +{ + "plugins": { + "web4-governance": { + "policy": { + "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": "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*"] + } + }, + { + "id": "warn-network", + "name": "Warn on network access", + "priority": 10, + "decision": "warn", + "match": { + "categories": ["network"] + } + } + ] + } + } + } +} +``` + +## CLI Commands + +### Audit Commands + +```bash +moltbot audit summary # Show active session stats +moltbot audit verify [sessionId] # Verify chain integrity +moltbot audit last [count] # Show last N audit records (default: 10) +``` + +#### `audit summary` + +Displays all active governance sessions with action counts, audit record counts, chain validity, and tool/category breakdowns. + +#### `audit verify [sessionId]` + +Verifies the hash-linked audit chain integrity. Checks that each record's `prevRecordHash` matches the SHA-256 hash of the previous record. Pass a session ID to verify a specific chain, or omit for all active sessions. + +#### `audit last [count]` + +Shows the most recent audit records across all active sessions. Each record shows timestamp, tool name, target, and result status. + +### Policy Commands + +```bash +moltbot policy status # Show policy engine status +moltbot policy rules # List all rules in evaluation order +moltbot policy test [target] # Dry-run a tool call against the policy +``` + +#### `policy status` + +Shows the current policy engine state: + +``` +Policy engine: + Rules: 3 + Default: allow + Enforce: true +``` + +#### `policy rules` + +Lists all configured rules in priority order with match criteria: + +``` +3 rules (priority order): + + [1] deny-destructive-commands -> deny + Block destructive shell commands + match: tools=[Bash] AND targets(regex)=[rm\s+-rf, mkfs\.] + reason: Destructive command blocked + + [5] deny-secrets -> deny + Block reading secret files + match: categories=[file_read] AND targets(glob)=[**/.env, **/.env.*, **/credentials.*, **/*secret*] + reason: Secret file access denied + + [10] warn-network -> warn + Warn on network access + match: categories=[network] + +Default: allow | Enforce: true +``` + +#### `policy test [target]` + +Dry-runs a tool call against the policy engine without executing anything. Shows what decision would be made: + +```bash +$ moltbot policy test Bash "rm -rf /tmp" +Tool: Bash +Category: command +Target: rm -rf /tmp +Decision: deny +Enforced: true +Reason: Destructive command blocked +Rule: deny-destructive-commands (priority 1) +Constraints: policy:deny, rule:deny-destructive-commands +``` + +```bash +$ moltbot policy test Read "/project/src/index.ts" +Tool: Read +Category: file_read +Target: /project/src/index.ts +Decision: allow +Enforced: true +Reason: Default policy: allow +Constraints: policy:allow, rule:default +``` + +## Storage Layout + +``` +~/.web4/ + audit/ + .jsonl # Hash-linked audit records (append-only) + sessions/ + .json # Session metadata (overwritten on each action) +``` + +## Architecture + +### Hooks + +The plugin uses two hook surfaces: + +- **`before_tool_call`** (sequential): Evaluates policy rules. Can block tool execution by returning `{ block: true, blockReason }`. Stashes the policy evaluation for the after-hook. +- **`after_tool_call`** (fire-and-forget): Creates the R6 audit record, writes policy constraints from the stashed evaluation, and appends to the hash-linked chain. + +Internal hooks handle session lifecycle (bootstrap, start, end) and command-level auditing. + +### R6 Request Structure + +Each tool call produces an R6 record with six fields: + +| Field | Content | +|-------|---------| +| **Rules** | Audit level + policy constraints | +| **Role** | Session ID, agent ID, action index, binding type | +| **Request** | Tool name, category, target, input hash | +| **Reference** | Session ID, previous R6 ID, chain position | +| **Resource** | Approval requirement flag | +| **Result** | Status (success/error/blocked), output hash, duration | + +### Session Identity + +Each session gets a Soft LCT (software-bound Linked Context Token) derived from `hostname:username`. Format: `web4:session::`. This is the upgrade path to hardware-bound identity in Tier 2. + +## Implementation Tiers + +| Tier | Scope | Status | +|------|-------|--------| +| 1 - Observational | R6 audit, hash chain, soft LCT, tool classification | Done | +| 1.5 - Policy | Configurable rules, before_tool_call gating, allow/deny/warn | Done | +| 2 - Authorization | T3 trust tensors, ATP economics, hardware LCT, full policy engine | Planned (Hardbound) | + +## Development + +```bash +# Run plugin tests +npx vitest run extensions/web4-governance/ + +# Type-check +pnpm build + +# Full test suite +pnpm test +``` diff --git a/extensions/web4-governance/clawdbot.plugin.json b/extensions/web4-governance/clawdbot.plugin.json index 076d256a8..244ee7727 100644 --- a/extensions/web4-governance/clawdbot.plugin.json +++ b/extensions/web4-governance/clawdbot.plugin.json @@ -16,6 +16,81 @@ "storagePath": { "type": "string", "description": "Override default ~/.web4/ storage path" + }, + "policy": { + "type": "object", + "description": "Policy engine configuration for pre-action gating", + "properties": { + "defaultPolicy": { + "type": "string", + "enum": ["allow", "deny", "warn"], + "default": "allow", + "description": "Default decision when no rule matches" + }, + "enforce": { + "type": "boolean", + "default": true, + "description": "When false, deny decisions are logged but not enforced (dry-run)" + }, + "rules": { + "type": "array", + "default": [], + "items": { + "type": "object", + "required": ["id", "name", "priority", "decision", "match"], + "properties": { + "id": { + "type": "string", + "description": "Unique rule identifier" + }, + "name": { + "type": "string", + "description": "Human-readable rule name" + }, + "priority": { + "type": "number", + "description": "Lower = evaluated first. First match wins." + }, + "decision": { + "type": "string", + "enum": ["allow", "deny", "warn"] + }, + "reason": { + "type": "string", + "description": "Human-readable reason for the decision" + }, + "match": { + "type": "object", + "properties": { + "tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Tool names to match (e.g. Bash, Write)" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": ["file_read", "file_write", "command", "network", "delegation", "state", "mcp", "unknown"] + }, + "description": "Tool categories to match" + }, + "targetPatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Patterns to match against the tool target (glob by default)" + }, + "targetPatternsAreRegex": { + "type": "boolean", + "default": false, + "description": "Treat targetPatterns as regex instead of glob" + } + } + } + } + } + } + } } } } diff --git a/extensions/web4-governance/index.ts b/extensions/web4-governance/index.ts index faa7ccccc..af3339840 100644 --- a/extensions/web4-governance/index.ts +++ b/extensions/web4-governance/index.ts @@ -11,14 +11,17 @@ 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 } from "./src/r6.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"; type PluginConfig = { auditLevel?: string; showR6Status?: boolean; storagePath?: string; + policy?: Partial; }; const plugin = { @@ -45,6 +48,17 @@ const plugin = { const sessions = new Map(); const sessionStore = new SessionStore(storagePath); + // Policy engine + const policyEngine = new PolicyEngine(config.policy); + 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(); + function getOrCreateSession(sessionKey: string): { state: SessionState; audit: AuditChain } { let entry = sessions.get(sessionKey); if (!entry) { @@ -135,13 +149,48 @@ const plugin = { // --- 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; - // Create R6 request directly (before_tool_call and after_tool_call - // receive separate event objects, so we can't pass data between them) + // 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, @@ -151,6 +200,12 @@ const plugin = { 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, @@ -229,6 +284,73 @@ const plugin = { { 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}`); + }); + + 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(", ")}]`); + } + 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("", "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(", ")}`); + }); + }, + { commands: ["policy"] }, + ); + logger.info(`[web4] Web4 Governance plugin loaded (audit: ${auditLevel})`); }, }; diff --git a/extensions/web4-governance/src/matchers.test.ts b/extensions/web4-governance/src/matchers.test.ts new file mode 100644 index 000000000..f07b25810 --- /dev/null +++ b/extensions/web4-governance/src/matchers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { globToRegex, matchesList, matchesTarget, matchesRule } from "./matchers.js"; + +describe("globToRegex", () => { + it("should match exact strings", () => { + const re = globToRegex("/src/index.ts"); + expect(re.test("/src/index.ts")).toBe(true); + expect(re.test("/src/other.ts")).toBe(false); + }); + + it("should support * for single-segment wildcards", () => { + const re = globToRegex("/src/*.ts"); + expect(re.test("/src/index.ts")).toBe(true); + expect(re.test("/src/foo.ts")).toBe(true); + expect(re.test("/src/sub/foo.ts")).toBe(false); + }); + + it("should support ** for multi-segment wildcards", () => { + const re = globToRegex("/src/**/*.ts"); + expect(re.test("/src/foo.ts")).toBe(true); + expect(re.test("/src/a/b/c.ts")).toBe(true); + expect(re.test("/other/foo.ts")).toBe(false); + }); + + it("should support ? for single character", () => { + const re = globToRegex("file?.ts"); + expect(re.test("file1.ts")).toBe(true); + expect(re.test("fileAB.ts")).toBe(false); + }); + + it("should escape regex special characters", () => { + const re = globToRegex("file.test.ts"); + expect(re.test("file.test.ts")).toBe(true); + expect(re.test("filextest.ts")).toBe(false); + }); +}); + +describe("matchesList", () => { + it("should return true for exact match", () => { + expect(matchesList("Bash", ["Bash", "Write"])).toBe(true); + }); + + it("should return false for no match", () => { + expect(matchesList("Read", ["Bash", "Write"])).toBe(false); + }); + + it("should be case-sensitive", () => { + expect(matchesList("bash", ["Bash"])).toBe(false); + }); +}); + +describe("matchesTarget", () => { + it("should match glob patterns", () => { + expect(matchesTarget("/src/foo.ts", ["/src/*.ts"], false)).toBe(true); + expect(matchesTarget("/src/foo.js", ["/src/*.ts"], false)).toBe(false); + }); + + it("should match regex patterns", () => { + expect(matchesTarget("rm -rf /", ["rm\\s+-rf"], true)).toBe(true); + expect(matchesTarget("ls -la", ["rm\\s+-rf"], true)).toBe(false); + }); + + it("should match if any pattern matches", () => { + expect(matchesTarget("rm -rf /", ["mkfs\\.", "rm\\s+-rf"], true)).toBe(true); + }); + + it("should return false for undefined target", () => { + expect(matchesTarget(undefined, ["*"], false)).toBe(false); + }); +}); + +describe("matchesRule", () => { + it("should match when all criteria match", () => { + expect( + matchesRule("Bash", "command", "rm -rf /", { + tools: ["Bash"], + categories: ["command"], + targetPatterns: ["rm\\s+-rf"], + targetPatternsAreRegex: true, + }), + ).toBe(true); + }); + + it("should fail when tool doesn't match", () => { + expect( + matchesRule("Read", "file_read", "/foo", { + tools: ["Bash"], + }), + ).toBe(false); + }); + + it("should fail when category doesn't match", () => { + expect( + matchesRule("Bash", "command", "ls", { + categories: ["network"], + }), + ).toBe(false); + }); + + it("should fail when target doesn't match", () => { + expect( + matchesRule("Bash", "command", "ls -la", { + targetPatterns: ["rm\\s+-rf"], + targetPatternsAreRegex: true, + }), + ).toBe(false); + }); + + it("should match when no criteria are specified (empty match)", () => { + expect(matchesRule("Bash", "command", "anything", {})).toBe(true); + }); + + it("should match with only tools specified", () => { + expect(matchesRule("Write", "file_write", "/foo", { tools: ["Write", "Edit"] })).toBe(true); + }); + + it("should match with only categories specified", () => { + expect(matchesRule("WebFetch", "network", "https://x.com", { categories: ["network"] })).toBe(true); + }); +}); diff --git a/extensions/web4-governance/src/matchers.ts b/extensions/web4-governance/src/matchers.ts new file mode 100644 index 000000000..55b024275 --- /dev/null +++ b/extensions/web4-governance/src/matchers.ts @@ -0,0 +1,86 @@ +/** + * Matchers - Glob and regex matching for policy rules. + * + * Used to match tool names, categories, and target strings + * against policy rule criteria. + */ + +import type { ToolCategory } from "./r6.js"; +import type { PolicyMatch } from "./policy-types.js"; + +/** + * Convert a glob pattern to a regex. + * Supports: * (any chars except /), ** (any chars including /), ? (single char) + */ +export function globToRegex(pattern: string): RegExp { + let re = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]!; + if (ch === "*") { + if (pattern[i + 1] === "*") { + re += ".*"; + i += 2; + // Skip trailing slash after ** + if (pattern[i] === "/") i++; + } else { + re += "[^/]*"; + i++; + } + } else if (ch === "?") { + re += "[^/]"; + i++; + } else if (".+^${}()|[]\\".includes(ch)) { + re += "\\" + ch; + i++; + } else { + re += ch; + i++; + } + } + return new RegExp("^" + re + "$"); +} + +/** Check if a value matches any entry in a string list (case-sensitive). */ +export function matchesList(value: string, list: string[]): boolean { + return list.includes(value); +} + +/** Check if a target string matches any of the given patterns. */ +export function matchesTarget( + target: string | undefined, + patterns: string[], + useRegex: boolean, +): boolean { + if (target === undefined) return false; + for (const pattern of patterns) { + if (useRegex) { + if (new RegExp(pattern).test(target)) return true; + } else { + if (globToRegex(pattern).test(target)) return true; + } + } + return false; +} + +/** + * Evaluate whether a tool call matches a PolicyMatch specification. + * All specified criteria are AND'd: if tools, categories, and targetPatterns + * are all specified, all must match. + */ +export function matchesRule( + toolName: string, + category: ToolCategory, + target: string | undefined, + match: PolicyMatch, +): boolean { + // Each specified criterion must match (AND logic) + if (match.tools && !matchesList(toolName, match.tools)) return false; + if (match.categories && !matchesList(category, match.categories)) return false; + if (match.targetPatterns) { + if (!matchesTarget(target, match.targetPatterns, match.targetPatternsAreRegex ?? false)) { + return false; + } + } + return true; +} diff --git a/extensions/web4-governance/src/policy-types.ts b/extensions/web4-governance/src/policy-types.ts new file mode 100644 index 000000000..ea9274f11 --- /dev/null +++ b/extensions/web4-governance/src/policy-types.ts @@ -0,0 +1,52 @@ +/** + * Policy Engine Types - Rule definitions and evaluation results. + * + * Rules are evaluated in priority order (ascending). First match wins. + * Match criteria within a rule are AND'd. + */ + +import type { ToolCategory } from "./r6.js"; + +export type PolicyDecision = "allow" | "deny" | "warn"; + +export type PolicyMatch = { + /** Tool names to match (e.g. ["Bash", "Write"]) */ + tools?: string[]; + /** Tool categories to match (e.g. ["network", "command"]) */ + categories?: ToolCategory[]; + /** Target patterns - glob by default, regex if targetPatternsAreRegex is true */ + targetPatterns?: string[]; + /** Treat targetPatterns as regex instead of glob */ + targetPatternsAreRegex?: boolean; +}; + +export type PolicyRule = { + id: string; + name: string; + /** Lower priority = evaluated first. First match wins. */ + priority: number; + decision: PolicyDecision; + /** Human-readable reason for the decision */ + reason?: string; + match: PolicyMatch; +}; + +export type PolicyConfig = { + /** Default decision when no rule matches */ + defaultPolicy: PolicyDecision; + /** When false, deny decisions are logged as warnings but not enforced (dry-run) */ + enforce: boolean; + rules: PolicyRule[]; +}; + +export type PolicyEvaluation = { + decision: PolicyDecision; + /** The rule that matched, or undefined if default policy applied */ + matchedRule?: PolicyRule; + /** Whether the decision was enforced (false in dry-run mode) */ + enforced: boolean; + /** Reason string for audit/logging */ + reason: string; + /** Constraints to record in R6 */ + constraints: string[]; +}; diff --git a/extensions/web4-governance/src/policy.test.ts b/extensions/web4-governance/src/policy.test.ts new file mode 100644 index 000000000..ac1a014df --- /dev/null +++ b/extensions/web4-governance/src/policy.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; +import { PolicyEngine } from "./policy.js"; +import type { PolicyConfig, PolicyRule } from "./policy-types.js"; + +function rule(overrides: Partial & Pick): 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); + }); + }); +}); diff --git a/extensions/web4-governance/src/policy.ts b/extensions/web4-governance/src/policy.ts new file mode 100644 index 000000000..9321d1f1e --- /dev/null +++ b/extensions/web4-governance/src/policy.ts @@ -0,0 +1,93 @@ +/** + * Policy Engine - Evaluate tool calls against configurable rules. + * + * Rules are sorted by priority (ascending). First match wins. + * Supports allow/deny/warn decisions with optional dry-run mode. + */ + +import type { ToolCategory } from "./r6.js"; +import type { + PolicyConfig, + PolicyDecision, + PolicyEvaluation, + PolicyRule, +} from "./policy-types.js"; +import { matchesRule } from "./matchers.js"; + +export const DEFAULT_POLICY_CONFIG: PolicyConfig = { + defaultPolicy: "allow", + enforce: true, + rules: [], +}; + +export class PolicyEngine { + private rules: PolicyRule[]; + private defaultPolicy: PolicyDecision; + private enforce: boolean; + + constructor(config: Partial = {}) { + 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); + } + + /** Evaluate a tool call against all rules. First match wins. */ + evaluate( + toolName: string, + category: ToolCategory, + 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}`], + }; + } + } + + // No rule matched — apply default + return { + decision: this.defaultPolicy, + enforced: true, + reason: `Default policy: ${this.defaultPolicy}`, + constraints: [`policy:${this.defaultPolicy}`, "rule:default"], + }; + } + + /** Check if a tool call should be blocked (deny + enforce). */ + shouldBlock( + toolName: string, + category: ToolCategory, + target: string | undefined, + ): { blocked: boolean; evaluation: PolicyEvaluation } { + const evaluation = this.evaluate(toolName, category, target); + const blocked = evaluation.decision === "deny" && evaluation.enforced; + return { blocked, evaluation }; + } + + get ruleCount(): number { + return this.rules.length; + } + + get isEnforcing(): boolean { + return this.enforce; + } + + get defaultDecision(): PolicyDecision { + return this.defaultPolicy; + } + + /** Get all rules in evaluation order (priority ascending). */ + get sortedRules(): readonly PolicyRule[] { + return this.rules; + } +}