From 93bab62444cdf070f2270c9418d1c705b52c79a5 Mon Sep 17 00:00:00 2001 From: dennis palatov Date: Wed, 28 Jan 2026 11:36:06 -0800 Subject: [PATCH] feat(web4-governance): add PolicyEntity as first-class trust participant (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:::) - 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 Co-authored-by: Claude Opus 4.5 --- extensions/web4-governance/index.ts | 60 ++- .../web4-governance/src/policy-entity.test.ts | 425 ++++++++++++++++++ .../web4-governance/src/policy-entity.ts | 369 +++++++++++++++ extensions/web4-governance/src/r6.ts | 4 + .../web4-governance/src/session-state.ts | 2 + 5 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 extensions/web4-governance/src/policy-entity.test.ts create mode 100644 extensions/web4-governance/src/policy-entity.ts diff --git a/extensions/web4-governance/index.ts b/extensions/web4-governance/index.ts index 6d21b8413..10c348e20 100644 --- a/extensions/web4-governance/index.ts +++ b/extensions/web4-governance/index.ts @@ -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 = {}; 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": "" } }`); }); + + // --- 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"] }, ); diff --git a/extensions/web4-governance/src/policy-entity.test.ts b/extensions/web4-governance/src/policy-entity.test.ts new file mode 100644 index 000000000..8d16ea9db --- /dev/null +++ b/extensions/web4-governance/src/policy-entity.test.ts @@ -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::: 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); + }); +}); diff --git a/extensions/web4-governance/src/policy-entity.ts b/extensions/web4-governance/src/policy-entity.ts new file mode 100644 index 000000000..b6b7fcd75 --- /dev/null +++ b/extensions/web4-governance/src/policy-entity.ts @@ -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:::) + * - 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(); + + /** Witnessing records: entity -> set of witnesses */ + private witnessedBy = new Map>(); + + /** Witnessing records: entity -> set of entities witnessed */ + private hasWitnessed = new Map>(); + + /** + * 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); +} diff --git a/extensions/web4-governance/src/r6.ts b/extensions/web4-governance/src/r6.ts index de54892ba..070c2635d 100644 --- a/extensions/web4-governance/src/r6.ts +++ b/extensions/web4-governance/src/r6.ts @@ -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, diff --git a/extensions/web4-governance/src/session-state.ts b/extensions/web4-governance/src/session-state.ts index e7cb0b48f..3712e2953 100644 --- a/extensions/web4-governance/src/session-state.ts +++ b/extensions/web4-governance/src/session-state.ts @@ -14,6 +14,8 @@ export type SessionState = { startedAt: string; toolCounts: Record; categoryCounts: Record; + /** Policy entity ID (policy:::) */ + policyEntityId?: string; }; export class SessionStore {