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:
parent
05511453fa
commit
9e96541b4c
134
extensions/web4-governance/ARCHITECTURE.md
Normal file
134
extensions/web4-governance/ARCHITECTURE.md
Normal 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) |
|
||||
343
extensions/web4-governance/README.md
Normal file
343
extensions/web4-governance/README.md
Normal 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
|
||||
```
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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})`);
|
||||
},
|
||||
};
|
||||
|
||||
120
extensions/web4-governance/src/matchers.test.ts
Normal file
120
extensions/web4-governance/src/matchers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
extensions/web4-governance/src/matchers.ts
Normal file
86
extensions/web4-governance/src/matchers.ts
Normal 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;
|
||||
}
|
||||
52
extensions/web4-governance/src/policy-types.ts
Normal file
52
extensions/web4-governance/src/policy-types.ts
Normal 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[];
|
||||
};
|
||||
271
extensions/web4-governance/src/policy.test.ts
Normal file
271
extensions/web4-governance/src/policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
extensions/web4-governance/src/policy.ts
Normal file
93
extensions/web4-governance/src/policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user