feat(web4-governance): add PolicyEntity as first-class trust participant (#5)
Policy is now "society's law" - not just configuration, but a hash-tracked, witnessable entity in the trust network. Key changes: - PolicyEntity class with hash-identified entityId (policy:<name>:<version>:<hash>) - PolicyRegistry for registration and witnessing - Policy evaluation with rate limiting integration - Session witnesses policy at initialization - Policy witnesses tool decisions in after_tool_call - R6 records include policyEntityId in rules field - CLI command: `moltbot policy entities` to list registered policies - 26 new tests for PolicyEntity (177 total tests passing) The witnessing flow: 1. Session start → register policy entity → session witnesses policy 2. Tool call → policy evaluates decision → policy witnesses outcome 3. Bidirectional trust accumulation in the witnessing chain Co-authored-by: dp-web4 <dp@metalinxx.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d83c337d4
commit
93bab62444
@ -19,6 +19,7 @@ import type { PolicyConfig, PolicyEvaluation } from "./src/policy-types.js";
|
||||
import { RateLimiter } from "./src/rate-limiter.js";
|
||||
import { resolvePreset, listPresets, isPresetName } from "./src/presets.js";
|
||||
import { AuditReporter } from "./src/reporter.js";
|
||||
import { PolicyRegistry, type PolicyEntityId } from "./src/policy-entity.js";
|
||||
|
||||
type PluginConfig = {
|
||||
auditLevel?: string;
|
||||
@ -54,6 +55,9 @@ const plugin = {
|
||||
// Rate limiter (memory-only, shared across all sessions)
|
||||
const rateLimiter = new RateLimiter();
|
||||
|
||||
// Policy entity registry (policies as first-class trust participants)
|
||||
const policyRegistry = new PolicyRegistry();
|
||||
|
||||
// Resolve policy config: preset → merge with overrides
|
||||
let resolvedPolicyConfig: Partial<PolicyConfig> = {};
|
||||
if (config.policy) {
|
||||
@ -85,6 +89,20 @@ const plugin = {
|
||||
if (!entry) {
|
||||
const sessionId = sessionKey || randomUUID();
|
||||
const lct = createSoftLCT(sessionId);
|
||||
|
||||
// Register policy entity (policy as first-class trust participant)
|
||||
let policyEntityId: PolicyEntityId | undefined;
|
||||
const presetName = config.policy?.preset ?? "safety";
|
||||
if (isPresetName(presetName)) {
|
||||
const policyEntity = policyRegistry.registerPolicy({
|
||||
name: presetName,
|
||||
preset: presetName,
|
||||
});
|
||||
policyEntityId = policyEntity.entityId;
|
||||
// Session witnesses operating under this policy
|
||||
policyRegistry.witnessSession(policyEntity.entityId, sessionId);
|
||||
}
|
||||
|
||||
const state: SessionState = {
|
||||
sessionId,
|
||||
lct,
|
||||
@ -92,12 +110,14 @@ const plugin = {
|
||||
startedAt: new Date().toISOString(),
|
||||
toolCounts: {},
|
||||
categoryCounts: {},
|
||||
policyEntityId,
|
||||
};
|
||||
const audit = new AuditChain(storagePath, sessionId);
|
||||
sessionStore.save(state);
|
||||
entry = { state, audit };
|
||||
sessions.set(sessionKey, entry);
|
||||
logger.info(`[web4] Session ${lct.tokenId} initialized (${auditLevel} audit)`);
|
||||
const policyInfo = policyEntityId ? ` [policy:${presetName}]` : "";
|
||||
logger.info(`[web4] Session ${lct.tokenId} initialized (${auditLevel} audit)${policyInfo}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@ -149,6 +169,7 @@ const plugin = {
|
||||
state.actionIndex,
|
||||
state.lastR6Id,
|
||||
auditLevel,
|
||||
state.policyEntityId,
|
||||
);
|
||||
|
||||
// Record in audit chain with success (commands that reach hooks succeeded)
|
||||
@ -220,6 +241,7 @@ const plugin = {
|
||||
entry.state.actionIndex,
|
||||
entry.state.lastR6Id,
|
||||
auditLevel,
|
||||
entry.state.policyEntityId,
|
||||
);
|
||||
|
||||
// Write policy constraints to R6
|
||||
@ -227,6 +249,19 @@ const plugin = {
|
||||
r6.rules.constraints = policyEval.constraints;
|
||||
}
|
||||
|
||||
// Witness policy decision (policy witnesses the outcome)
|
||||
if (entry.state.policyEntityId) {
|
||||
const decision = policyEval?.decision ?? "allow";
|
||||
const success = !event.error;
|
||||
policyRegistry.witnessDecision(
|
||||
entry.state.policyEntityId as PolicyEntityId,
|
||||
entry.state.sessionId,
|
||||
event.toolName,
|
||||
decision,
|
||||
success
|
||||
);
|
||||
}
|
||||
|
||||
const result = {
|
||||
status: (event.error ? "error" : "success") as "success" | "error",
|
||||
outputHash: event.result ? hashOutput(event.result) : undefined,
|
||||
@ -467,6 +502,29 @@ const plugin = {
|
||||
}
|
||||
console.log(`Usage: { "policy": { "preset": "<name>" } }`);
|
||||
});
|
||||
|
||||
// --- Policy Entity CLI ---
|
||||
policy
|
||||
.command("entities")
|
||||
.description("List registered policy entities (policies as trust participants)")
|
||||
.action(() => {
|
||||
const entities = policyRegistry.listPolicies();
|
||||
if (entities.length === 0) {
|
||||
console.log("No policy entities registered.");
|
||||
return;
|
||||
}
|
||||
console.log(`${entities.length} policy entities:\n`);
|
||||
for (const e of entities) {
|
||||
console.log(` ${e.entityId}`);
|
||||
console.log(` name: ${e.name} | source: ${e.source}`);
|
||||
console.log(` hash: ${e.contentHash}`);
|
||||
console.log(` created: ${e.createdAt}`);
|
||||
const witnessedBy = policyRegistry.getWitnessedBy(e.entityId);
|
||||
const hasWitnessed = policyRegistry.getHasWitnessed(e.entityId);
|
||||
console.log(` witnessed by: ${witnessedBy.length} | has witnessed: ${hasWitnessed.length}`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ commands: ["policy"] },
|
||||
);
|
||||
|
||||
425
extensions/web4-governance/src/policy-entity.test.ts
Normal file
425
extensions/web4-governance/src/policy-entity.test.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
PolicyEntity,
|
||||
PolicyRegistry,
|
||||
computePolicyHash,
|
||||
type PolicyEntityId,
|
||||
} from "./policy-entity.js";
|
||||
import type { PolicyConfig, PolicyRule, PolicyMatch } from "./policy-types.js";
|
||||
import { RateLimiter } from "./rate-limiter.js";
|
||||
|
||||
describe("PolicyEntity", () => {
|
||||
let registry: PolicyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PolicyRegistry();
|
||||
});
|
||||
|
||||
describe("entity creation", () => {
|
||||
it("creates entity with preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
expect(entity.name).toBe("test");
|
||||
expect(entity.source).toBe("preset");
|
||||
expect(entity.entityId).toMatch(/^policy:test:\d+:[a-f0-9]+$/);
|
||||
expect(entity.contentHash).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("creates entity with custom config", () => {
|
||||
const config: PolicyConfig = {
|
||||
defaultPolicy: "allow",
|
||||
enforce: true,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
const entity = registry.registerPolicy({
|
||||
name: "custom",
|
||||
config,
|
||||
});
|
||||
|
||||
expect(entity.source).toBe("custom");
|
||||
expect(entity.config).toEqual(config);
|
||||
});
|
||||
|
||||
it("throws when neither config nor preset provided", () => {
|
||||
expect(() =>
|
||||
registry.registerPolicy({ name: "invalid" })
|
||||
).toThrow("Must provide either config or preset");
|
||||
});
|
||||
|
||||
it("throws when both config and preset provided", () => {
|
||||
expect(() =>
|
||||
registry.registerPolicy({
|
||||
name: "invalid",
|
||||
config: { defaultPolicy: "allow", enforce: true, rules: [] },
|
||||
preset: "safety",
|
||||
})
|
||||
).toThrow("Cannot provide both config and preset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hash uniqueness", () => {
|
||||
it("different configs produce different hashes", () => {
|
||||
const entity1 = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
const entity2 = registry.registerPolicy({
|
||||
name: "permissive",
|
||||
preset: "permissive",
|
||||
});
|
||||
|
||||
expect(entity1.contentHash).not.toBe(entity2.contentHash);
|
||||
expect(entity1.entityId).not.toBe(entity2.entityId);
|
||||
});
|
||||
|
||||
it("same config returns cached entity", () => {
|
||||
const entity1 = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
version: "v1",
|
||||
});
|
||||
const entity2 = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
expect(entity1).toBe(entity2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity ID format", () => {
|
||||
it("follows policy:<name>:<version>:<hash> format", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
const parts = entity.entityId.split(":");
|
||||
expect(parts).toHaveLength(4);
|
||||
expect(parts[0]).toBe("policy");
|
||||
expect(parts[1]).toBe("test");
|
||||
expect(parts[2]).toBe("v1");
|
||||
expect(parts[3]).toBe(entity.contentHash);
|
||||
});
|
||||
|
||||
it("auto-generates version as timestamp", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const parts = entity.entityId.split(":");
|
||||
const version = parts[2];
|
||||
expect(version).toHaveLength(14);
|
||||
expect(version).toMatch(/^\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialization", () => {
|
||||
it("toJSON returns entity data", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const json = entity.toJSON();
|
||||
expect(json.entityId).toBe(entity.entityId);
|
||||
expect(json.name).toBe("test");
|
||||
expect(json.contentHash).toBe(entity.contentHash);
|
||||
expect(json.config).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicyEntity evaluation", () => {
|
||||
let registry: PolicyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PolicyRegistry();
|
||||
});
|
||||
|
||||
it("allows by default with permissive preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "permissive",
|
||||
preset: "permissive",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("Read", "file_read", "/tmp/test.txt");
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBeUndefined();
|
||||
expect(result.reason).toContain("Default policy");
|
||||
});
|
||||
|
||||
it("denies destructive commands with safety preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("Bash", "command", "rm -rf /");
|
||||
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.matchedRule?.id).toBe("deny-destructive-commands");
|
||||
expect(result.enforced).toBe(true);
|
||||
});
|
||||
|
||||
it("denies secret file reads with safety preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("Read", "file_read", "/app/.env");
|
||||
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.matchedRule?.id).toBe("deny-secret-files");
|
||||
});
|
||||
|
||||
it("warns on network with safety preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("WebFetch", "network", "https://example.com");
|
||||
|
||||
expect(result.decision).toBe("warn");
|
||||
expect(result.matchedRule?.id).toBe("warn-network");
|
||||
});
|
||||
|
||||
it("denies by default with strict preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "strict",
|
||||
preset: "strict",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("Bash", "command", "ls");
|
||||
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.matchedRule).toBeUndefined();
|
||||
expect(result.reason).toContain("Default policy");
|
||||
});
|
||||
|
||||
it("allows read tools with strict preset", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "strict",
|
||||
preset: "strict",
|
||||
});
|
||||
|
||||
for (const tool of ["Read", "Glob", "Grep", "TodoWrite"]) {
|
||||
const result = entity.evaluate(tool, "file_read", "/tmp/test.txt");
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule?.id).toBe("allow-read-tools");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes constraints in evaluation", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const result = entity.evaluate("Bash", "command", "rm -rf /");
|
||||
|
||||
expect(result.constraints).toContain(`policy:${entity.entityId}`);
|
||||
expect(result.constraints).toContain("decision:deny");
|
||||
expect(result.constraints).toContain("rule:deny-destructive-commands");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicyEntity with rate limiting", () => {
|
||||
let registry: PolicyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PolicyRegistry();
|
||||
});
|
||||
|
||||
it("allows under rate limit threshold", () => {
|
||||
const config: PolicyConfig = {
|
||||
defaultPolicy: "allow",
|
||||
enforce: true,
|
||||
rules: [
|
||||
{
|
||||
id: "rate-bash",
|
||||
name: "Rate limit Bash",
|
||||
priority: 1,
|
||||
decision: "deny",
|
||||
match: {
|
||||
tools: ["Bash"],
|
||||
rateLimit: { maxCount: 5, windowMs: 60000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const entity = registry.registerPolicy({ name: "rate-test", config });
|
||||
const limiter = new RateLimiter();
|
||||
|
||||
// Under limit - rule doesn't fire, falls through to default
|
||||
const result = entity.evaluate("Bash", "command", "ls", limiter);
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it("denies when over rate limit threshold", () => {
|
||||
const config: PolicyConfig = {
|
||||
defaultPolicy: "allow",
|
||||
enforce: true,
|
||||
rules: [
|
||||
{
|
||||
id: "rate-bash",
|
||||
name: "Rate limit Bash",
|
||||
priority: 1,
|
||||
decision: "deny",
|
||||
match: {
|
||||
tools: ["Bash"],
|
||||
rateLimit: { maxCount: 2, windowMs: 60000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const entity = registry.registerPolicy({ name: "rate-test", config });
|
||||
const limiter = new RateLimiter();
|
||||
|
||||
// Record actions to exceed limit
|
||||
const key = "ratelimit:rate-bash:tool:Bash";
|
||||
limiter.record(key);
|
||||
limiter.record(key);
|
||||
|
||||
// Now at limit - rule fires
|
||||
const result = entity.evaluate("Bash", "command", "ls", limiter);
|
||||
expect(result.decision).toBe("deny");
|
||||
expect(result.matchedRule?.id).toBe("rate-bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicyRegistry", () => {
|
||||
let registry: PolicyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PolicyRegistry();
|
||||
});
|
||||
|
||||
describe("listing policies", () => {
|
||||
it("lists all registered policies", () => {
|
||||
registry.registerPolicy({ name: "safety", preset: "safety" });
|
||||
registry.registerPolicy({ name: "strict", preset: "strict" });
|
||||
|
||||
const policies = registry.listPolicies();
|
||||
|
||||
expect(policies).toHaveLength(2);
|
||||
const names = policies.map((p) => p.name);
|
||||
expect(names).toContain("safety");
|
||||
expect(names).toContain("strict");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieval", () => {
|
||||
it("gets policy by entity ID", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const found = registry.getPolicy(entity.entityId);
|
||||
|
||||
expect(found).toBe(entity);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown entity ID", () => {
|
||||
const found = registry.getPolicy("policy:unknown:v1:abc123" as PolicyEntityId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets policy by content hash", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "test",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
const found = registry.getPolicyByHash(entity.contentHash);
|
||||
|
||||
expect(found).toBe(entity);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicyRegistry witnessing", () => {
|
||||
let registry: PolicyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PolicyRegistry();
|
||||
});
|
||||
|
||||
it("records session witnessing policy", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
registry.witnessSession(entity.entityId, "session-123");
|
||||
|
||||
const witnessedBy = registry.getWitnessedBy(entity.entityId);
|
||||
expect(witnessedBy).toContain("session:session-123");
|
||||
});
|
||||
|
||||
it("records policy witnessing decision", () => {
|
||||
const entity = registry.registerPolicy({
|
||||
name: "safety",
|
||||
preset: "safety",
|
||||
});
|
||||
|
||||
registry.witnessDecision(
|
||||
entity.entityId,
|
||||
"session-123",
|
||||
"Read",
|
||||
"allow",
|
||||
true
|
||||
);
|
||||
|
||||
const hasWitnessed = registry.getHasWitnessed(entity.entityId);
|
||||
expect(hasWitnessed).toContain("session:session-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePolicyHash", () => {
|
||||
it("computes consistent hash for same config", () => {
|
||||
const config: PolicyConfig = {
|
||||
defaultPolicy: "allow",
|
||||
enforce: true,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
const hash1 = computePolicyHash(config);
|
||||
const hash2 = computePolicyHash(config);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("computes different hash for different configs", () => {
|
||||
const config1: PolicyConfig = {
|
||||
defaultPolicy: "allow",
|
||||
enforce: true,
|
||||
rules: [],
|
||||
};
|
||||
const config2: PolicyConfig = {
|
||||
defaultPolicy: "deny",
|
||||
enforce: true,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
const hash1 = computePolicyHash(config1);
|
||||
const hash2 = computePolicyHash(config2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
369
extensions/web4-governance/src/policy-entity.ts
Normal file
369
extensions/web4-governance/src/policy-entity.ts
Normal file
@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Policy Entity - Policy as a first-class participant in the trust network.
|
||||
*
|
||||
* Policy isn't just configuration - it's society's law. It has identity,
|
||||
* can be witnessed, and is hash-tracked in the audit chain.
|
||||
*
|
||||
* Key concepts:
|
||||
* - Policy is immutable once registered (changing = new entity)
|
||||
* - Sessions witness operating under a policy
|
||||
* - Policy witnesses agent decisions (allow/deny)
|
||||
* - R6 records reference the policyHash in effect
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import type {
|
||||
PolicyConfig,
|
||||
PolicyRule,
|
||||
PolicyMatch,
|
||||
PolicyDecision,
|
||||
PolicyEvaluation,
|
||||
} from "./policy-types.js";
|
||||
import { resolvePreset } from "./presets.js";
|
||||
import type { RateLimiter } from "./rate-limiter.js";
|
||||
|
||||
export type PolicyEntityId = `policy:${string}:${string}:${string}`;
|
||||
|
||||
export type PolicyEntityData = {
|
||||
entityId: PolicyEntityId;
|
||||
name: string;
|
||||
version: string;
|
||||
contentHash: string;
|
||||
createdAt: string;
|
||||
source: "preset" | "custom";
|
||||
config: PolicyConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* A policy as a first-class entity in the trust network.
|
||||
*
|
||||
* Properties:
|
||||
* - entityId: Unique identifier (policy:<name>:<version>:<hash>)
|
||||
* - contentHash: SHA-256 of the policy document (first 16 chars)
|
||||
* - config: The actual policy configuration
|
||||
*/
|
||||
export class PolicyEntity {
|
||||
readonly entityId: PolicyEntityId;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly contentHash: string;
|
||||
readonly createdAt: string;
|
||||
readonly source: "preset" | "custom";
|
||||
readonly config: PolicyConfig;
|
||||
|
||||
/** Rules sorted by priority (ascending) for evaluation */
|
||||
private sortedRules: PolicyRule[];
|
||||
|
||||
constructor(data: PolicyEntityData) {
|
||||
this.entityId = data.entityId;
|
||||
this.name = data.name;
|
||||
this.version = data.version;
|
||||
this.contentHash = data.contentHash;
|
||||
this.createdAt = data.createdAt;
|
||||
this.source = data.source;
|
||||
this.config = data.config;
|
||||
|
||||
// Sort rules by priority (lower = evaluated first)
|
||||
this.sortedRules = [...data.config.rules].sort(
|
||||
(a, b) => a.priority - b.priority
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a tool call against this policy.
|
||||
*/
|
||||
evaluate(
|
||||
toolName: string,
|
||||
category: string,
|
||||
target?: string,
|
||||
rateLimiter?: RateLimiter
|
||||
): PolicyEvaluation {
|
||||
for (const rule of this.sortedRules) {
|
||||
if (this.matchesRule(toolName, category, target, rule.match)) {
|
||||
// Check rate limit if specified
|
||||
if (rule.match.rateLimit && rateLimiter) {
|
||||
const key = this.rateLimitKey(rule, toolName, category);
|
||||
const result = rateLimiter.check(
|
||||
key,
|
||||
rule.match.rateLimit.maxCount,
|
||||
rule.match.rateLimit.windowMs
|
||||
);
|
||||
if (result.allowed) {
|
||||
continue; // Under limit, rule doesn't fire
|
||||
}
|
||||
}
|
||||
|
||||
const enforced = rule.decision !== "deny" || this.config.enforce;
|
||||
return {
|
||||
decision: rule.decision,
|
||||
matchedRule: rule,
|
||||
enforced,
|
||||
reason: rule.reason ?? `Matched rule: ${rule.name}`,
|
||||
constraints: [
|
||||
`policy:${this.entityId}`,
|
||||
`decision:${rule.decision}`,
|
||||
`rule:${rule.id}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No rule matched - default policy
|
||||
return {
|
||||
decision: this.config.defaultPolicy,
|
||||
matchedRule: undefined,
|
||||
enforced: true,
|
||||
reason: `Default policy: ${this.config.defaultPolicy}`,
|
||||
constraints: [
|
||||
`policy:${this.entityId}`,
|
||||
`decision:${this.config.defaultPolicy}`,
|
||||
"rule:default",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private matchesRule(
|
||||
toolName: string,
|
||||
category: string,
|
||||
target: string | undefined,
|
||||
match: PolicyMatch
|
||||
): boolean {
|
||||
// Tool match
|
||||
if (match.tools && !match.tools.includes(toolName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Category match
|
||||
if (match.categories && !match.categories.includes(category as any)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Target pattern match
|
||||
if (match.targetPatterns) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let matched = false;
|
||||
for (const pattern of match.targetPatterns) {
|
||||
if (match.targetPatternsAreRegex) {
|
||||
if (new RegExp(pattern).test(target)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Simple glob matching (convert * to .*)
|
||||
const regexPattern = pattern
|
||||
.replace(/\*\*/g, ".*")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, ".");
|
||||
if (new RegExp(`^${regexPattern}$`).test(target)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private rateLimitKey(
|
||||
rule: PolicyRule,
|
||||
toolName: string,
|
||||
category: string
|
||||
): string {
|
||||
if (rule.match.tools) {
|
||||
return `ratelimit:${rule.id}:tool:${toolName}`;
|
||||
}
|
||||
if (rule.match.categories) {
|
||||
return `ratelimit:${rule.id}:category:${category}`;
|
||||
}
|
||||
return `ratelimit:${rule.id}:global`;
|
||||
}
|
||||
|
||||
toJSON(): PolicyEntityData {
|
||||
return {
|
||||
entityId: this.entityId,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
contentHash: this.contentHash,
|
||||
createdAt: this.createdAt,
|
||||
source: this.source,
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of policy entities with hash-tracking.
|
||||
*
|
||||
* Policies are registered once and become immutable. Changing a policy
|
||||
* creates a new entity with a new hash.
|
||||
*/
|
||||
export class PolicyRegistry {
|
||||
/** In-memory cache of loaded policies */
|
||||
private cache = new Map<PolicyEntityId, PolicyEntity>();
|
||||
|
||||
/** Witnessing records: entity -> set of witnesses */
|
||||
private witnessedBy = new Map<string, Set<string>>();
|
||||
|
||||
/** Witnessing records: entity -> set of entities witnessed */
|
||||
private hasWitnessed = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* Register a policy and create its entity.
|
||||
*/
|
||||
registerPolicy(options: {
|
||||
name: string;
|
||||
config?: PolicyConfig;
|
||||
preset?: string;
|
||||
version?: string;
|
||||
}): PolicyEntity {
|
||||
const { name, config: providedConfig, preset, version } = options;
|
||||
|
||||
if (!providedConfig && !preset) {
|
||||
throw new Error("Must provide either config or preset");
|
||||
}
|
||||
if (providedConfig && preset) {
|
||||
throw new Error("Cannot provide both config and preset");
|
||||
}
|
||||
|
||||
// Resolve config
|
||||
let config: PolicyConfig;
|
||||
let source: "preset" | "custom";
|
||||
if (preset) {
|
||||
config = resolvePreset(preset);
|
||||
source = "preset";
|
||||
} else {
|
||||
config = providedConfig!;
|
||||
source = "custom";
|
||||
}
|
||||
|
||||
// Generate version if not provided
|
||||
const versionStr = version ?? new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
|
||||
|
||||
// Compute content hash
|
||||
const contentStr = JSON.stringify(config, Object.keys(config).sort());
|
||||
const contentHash = createHash("sha256")
|
||||
.update(contentStr)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
|
||||
// Build entity ID
|
||||
const entityId: PolicyEntityId = `policy:${name}:${versionStr}:${contentHash}`;
|
||||
|
||||
// Check cache
|
||||
if (this.cache.has(entityId)) {
|
||||
return this.cache.get(entityId)!;
|
||||
}
|
||||
|
||||
// Create entity
|
||||
const entity = new PolicyEntity({
|
||||
entityId,
|
||||
name,
|
||||
version: versionStr,
|
||||
contentHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
source,
|
||||
config,
|
||||
});
|
||||
|
||||
// Cache
|
||||
this.cache.set(entityId, entity);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a policy by entity ID.
|
||||
*/
|
||||
getPolicy(entityId: PolicyEntityId): PolicyEntity | undefined {
|
||||
return this.cache.get(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a policy by content hash.
|
||||
*/
|
||||
getPolicyByHash(contentHash: string): PolicyEntity | undefined {
|
||||
for (const entity of this.cache.values()) {
|
||||
if (entity.contentHash === contentHash) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered policies.
|
||||
*/
|
||||
listPolicies(): PolicyEntity[] {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a session is operating under this policy.
|
||||
*
|
||||
* Creates bidirectional witnessing:
|
||||
* - Session witnesses the policy (I operate under these rules)
|
||||
* - Policy witnesses the session (this session uses me)
|
||||
*/
|
||||
witnessSession(policyEntityId: PolicyEntityId, sessionId: string): void {
|
||||
const sessionEntity = `session:${sessionId}`;
|
||||
|
||||
// Policy is witnessed by session
|
||||
if (!this.witnessedBy.has(policyEntityId)) {
|
||||
this.witnessedBy.set(policyEntityId, new Set());
|
||||
}
|
||||
this.witnessedBy.get(policyEntityId)!.add(sessionEntity);
|
||||
|
||||
// Session has witnessed policy
|
||||
if (!this.hasWitnessed.has(sessionEntity)) {
|
||||
this.hasWitnessed.set(sessionEntity, new Set());
|
||||
}
|
||||
this.hasWitnessed.get(sessionEntity)!.add(policyEntityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a policy decision in the witnessing chain.
|
||||
*/
|
||||
witnessDecision(
|
||||
policyEntityId: PolicyEntityId,
|
||||
sessionId: string,
|
||||
_toolName: string,
|
||||
_decision: PolicyDecision,
|
||||
_success: boolean
|
||||
): void {
|
||||
const sessionEntity = `session:${sessionId}`;
|
||||
|
||||
// Policy has witnessed the session's action
|
||||
if (!this.hasWitnessed.has(policyEntityId)) {
|
||||
this.hasWitnessed.set(policyEntityId, new Set());
|
||||
}
|
||||
this.hasWitnessed.get(policyEntityId)!.add(sessionEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities that have witnessed a policy.
|
||||
*/
|
||||
getWitnessedBy(entityId: string): string[] {
|
||||
return [...(this.witnessedBy.get(entityId) ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities that a policy has witnessed.
|
||||
*/
|
||||
getHasWitnessed(entityId: string): string[] {
|
||||
return [...(this.hasWitnessed.get(entityId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a policy content hash.
|
||||
*/
|
||||
export function computePolicyHash(config: PolicyConfig): string {
|
||||
const contentStr = JSON.stringify(config, Object.keys(config).sort());
|
||||
return createHash("sha256").update(contentStr).digest("hex").slice(0, 16);
|
||||
}
|
||||
@ -23,6 +23,8 @@ export type R6Request = {
|
||||
export type R6Rules = {
|
||||
auditLevel: string;
|
||||
constraints: string[];
|
||||
/** Policy entity ID (policy as first-class trust participant) */
|
||||
policyEntityId?: string;
|
||||
};
|
||||
|
||||
export type R6Role = {
|
||||
@ -114,6 +116,7 @@ export function createR6Request(
|
||||
actionIndex: number,
|
||||
prevR6Id: string | undefined,
|
||||
auditLevel: string,
|
||||
policyEntityId?: string,
|
||||
): R6Request {
|
||||
return {
|
||||
id: `r6:${randomUUID().slice(0, 8)}`,
|
||||
@ -121,6 +124,7 @@ export function createR6Request(
|
||||
rules: {
|
||||
auditLevel,
|
||||
constraints: [],
|
||||
policyEntityId,
|
||||
},
|
||||
role: {
|
||||
sessionId,
|
||||
|
||||
@ -14,6 +14,8 @@ export type SessionState = {
|
||||
startedAt: string;
|
||||
toolCounts: Record<string, number>;
|
||||
categoryCounts: Record<string, number>;
|
||||
/** Policy entity ID (policy:<name>:<version>:<hash>) */
|
||||
policyEntityId?: string;
|
||||
};
|
||||
|
||||
export class SessionStore {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user