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 <noreply@anthropic.com>

* 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 <dp@metalinxx.io>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dennis palatov 2026-01-27 21:32:39 -08:00 committed by dp-web4
parent f599b4b349
commit 0fd6f72fde
9 changed files with 1299 additions and 3 deletions

View File

@ -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 <tool> [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/
│ └── <sessionId>.jsonl # Hash-linked audit records (append-only)
└── sessions/
└── <sessionId>.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) |

View File

@ -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] <reason>` | 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 <tool> [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 <tool> [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/
<sessionId>.jsonl # Hash-linked audit records (append-only)
sessions/
<sessionId>.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:<machineHash>:<sessionId>`. 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
```

View File

@ -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"
}
}
}
}
}
}
}
}
}
}

View File

@ -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<PolicyConfig>;
};
const plugin = {
@ -45,6 +48,17 @@ const plugin = {
const sessions = new Map<string, { state: SessionState; audit: AuditChain }>();
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<string, PolicyEvaluation>();
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("<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(", ")}`);
});
},
{ commands: ["policy"] },
);
logger.info(`[web4] Web4 Governance plugin loaded (audit: ${auditLevel})`);
},
};

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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[];
};

View File

@ -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<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);
});
});
});

View File

@ -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<PolicyConfig> = {}) {
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;
}
}