diff --git a/extensions/web4-governance/clawdbot.plugin.json b/extensions/web4-governance/clawdbot.plugin.json new file mode 100644 index 000000000..076d256a8 --- /dev/null +++ b/extensions/web4-governance/clawdbot.plugin.json @@ -0,0 +1,22 @@ +{ + "id": "web4-governance", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "auditLevel": { + "type": "string", + "enum": ["minimal", "standard", "verbose"], + "default": "standard" + }, + "showR6Status": { + "type": "boolean", + "default": true + }, + "storagePath": { + "type": "string", + "description": "Override default ~/.web4/ storage path" + } + } + } +} diff --git a/extensions/web4-governance/index.ts b/extensions/web4-governance/index.ts new file mode 100644 index 000000000..faa7ccccc --- /dev/null +++ b/extensions/web4-governance/index.ts @@ -0,0 +1,236 @@ +/** + * Web4 Governance Plugin for Moltbot + * + * Adds R6 workflow formalism, audit trails, and session identity + * to moltbot agent sessions. Uses internal hooks for session lifecycle + * and typed after_tool_call hooks for tool-level audit. + */ + +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +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 { AuditChain } from "./src/audit.js"; +import { SessionStore, type SessionState } from "./src/session-state.js"; + +type PluginConfig = { + auditLevel?: string; + showR6Status?: boolean; + storagePath?: string; +}; + +const plugin = { + id: "web4-governance", + name: "Web4 Governance", + description: "R6 workflow formalism, audit trails, and trust-native session identity", + configSchema: { + type: "object" as const, + additionalProperties: false, + properties: { + auditLevel: { type: "string", enum: ["minimal", "standard", "verbose"], default: "standard" }, + showR6Status: { type: "boolean", default: true }, + storagePath: { type: "string" }, + }, + }, + + register(api: MoltbotPluginApi) { + const config = (api.pluginConfig ?? {}) as PluginConfig; + const storagePath = config.storagePath ?? join(homedir(), ".web4"); + const auditLevel = config.auditLevel ?? "standard"; + const logger = api.logger; + + // Per-session state (keyed by sessionKey) + const sessions = new Map(); + const sessionStore = new SessionStore(storagePath); + + function getOrCreateSession(sessionKey: string): { state: SessionState; audit: AuditChain } { + let entry = sessions.get(sessionKey); + if (!entry) { + const sessionId = sessionKey || randomUUID(); + const lct = createSoftLCT(sessionId); + const state: SessionState = { + sessionId, + lct, + actionIndex: 0, + startedAt: new Date().toISOString(), + toolCounts: {}, + categoryCounts: {}, + }; + 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)`); + } + return entry; + } + + // --- Internal Hooks (these actually fire in current moltbot) --- + + // Hook into agent bootstrap - fires when agent session starts + api.registerHook(["agent", "agent:bootstrap"], async (event) => { + const sessionKey = event.sessionKey || "default"; + const entry = getOrCreateSession(sessionKey); + logger.info(`[web4] Governance active: ${entry.state.lct.tokenId} (session: ${sessionKey})`); + }, { name: "web4-agent-bootstrap", description: "Initialize Web4 governance session on agent bootstrap" }); + + // Hook into session events + api.registerHook(["session"], async (event) => { + const sessionKey = event.sessionKey || "default"; + if (event.action === "new" || event.action === "start") { + const entry = getOrCreateSession(sessionKey); + logger.info(`[web4] Session ${event.action}: ${entry.state.lct.tokenId}`); + } else if (event.action === "end" || event.action === "stop" || event.action === "reset") { + const entry = sessions.get(sessionKey); + if (entry) { + const verification = entry.audit.verify(); + logger.info( + `[web4] Session ${event.action}: ${entry.state.actionIndex} actions, ` + + `chain ${verification.valid ? "VALID" : "INVALID"} (${verification.recordCount} records)`, + ); + sessionStore.save(entry.state); + sessions.delete(sessionKey); + } + } + }, { name: "web4-session-lifecycle", description: "Track Web4 session lifecycle events" }); + + // Hook into command events - captures all agent commands + api.registerHook(["command"], async (event) => { + const sessionKey = event.sessionKey || "default"; + const entry = getOrCreateSession(sessionKey); + const { state } = entry; + + // Create R6 request for the command + const toolName = String(event.context.command ?? event.action ?? "unknown"); + const params = (event.context ?? {}) as Record; + + const r6 = createR6Request( + state.sessionId, + undefined, + toolName, + params, + state.actionIndex, + state.lastR6Id, + auditLevel, + ); + + // Record in audit chain with success (commands that reach hooks succeeded) + const result = { + status: "success" as const, + outputHash: hashOutput(event.context), + }; + r6.result = result; + entry.audit.record(r6, result); + + // Update session state + const category = classifyTool(toolName); + sessionStore.incrementAction(state, toolName, category, r6.id); + + if (auditLevel === "verbose") { + logger.info(`[web4] R6 ${r6.id}: ${toolName} [${category}] → ${r6.request.target ?? "(no target)"}`); + } + }, { name: "web4-command-audit", description: "Record R6 audit entries for agent commands" }); + + // --- Typed Tool Hooks (wired via pi-tools.hooks.ts) --- + + 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) + const r6 = createR6Request( + entry.state.sessionId, + ctx.agentId, + event.toolName, + event.params, + entry.state.actionIndex, + entry.state.lastR6Id, + auditLevel, + ); + const result = { + status: (event.error ? "error" : "success") as "success" | "error", + outputHash: event.result ? hashOutput(event.result) : undefined, + errorMessage: event.error, + durationMs: event.durationMs, + }; + r6.result = result; + entry.audit.record(r6, result); + sessionStore.incrementAction(entry.state, event.toolName, classifyTool(event.toolName), r6.id); + + if (auditLevel === "verbose") { + logger.info(`[web4] R6 ${r6.id}: ${event.toolName} [${classifyTool(event.toolName)}] (${event.durationMs ?? 0}ms)`); + } + }); + + // --- CLI Commands --- + + api.registerCli( + ({ program }) => { + const audit = program.command("audit").description("Web4 governance audit trail"); + + audit + .command("summary") + .description("Show session audit summary") + .action(() => { + for (const [sid, entry] of sessions) { + const v = entry.audit.verify(); + console.log(`Session: ${entry.state.lct.tokenId}`); + console.log(` Actions: ${entry.state.actionIndex}`); + console.log(` Audit records: ${v.recordCount}`); + console.log(` Chain valid: ${v.valid}`); + console.log(` Tools: ${JSON.stringify(entry.state.toolCounts)}`); + console.log(` Categories: ${JSON.stringify(entry.state.categoryCounts)}`); + } + if (sessions.size === 0) { + console.log("No active governance sessions."); + } + }); + + audit + .command("verify") + .description("Verify audit chain integrity") + .argument("[sessionId]", "Session ID to verify") + .action((sessionId?: string) => { + if (sessionId) { + const chain = new AuditChain(storagePath, sessionId); + const result = chain.verify(); + console.log(`Chain valid: ${result.valid}`); + console.log(`Records: ${result.recordCount}`); + if (result.errors.length > 0) { + console.log("Errors:"); + for (const e of result.errors) console.log(` - ${e}`); + } + } else { + for (const [, entry] of sessions) { + const result = entry.audit.verify(); + console.log(`${entry.state.sessionId}: ${result.valid ? "VALID" : "INVALID"} (${result.recordCount} records)`); + } + } + }); + + audit + .command("last") + .description("Show last N audit records") + .argument("[count]", "Number of records", "10") + .action((countStr: string) => { + const count = parseInt(countStr, 10) || 10; + for (const [, entry] of sessions) { + const records = entry.audit.getLast(count); + for (const r of records) { + console.log(`${r.timestamp} ${r.tool} → ${r.target ?? "?"} [${r.result.status}]`); + } + } + }); + }, + { commands: ["audit"] }, + ); + + logger.info(`[web4] Web4 Governance plugin loaded (audit: ${auditLevel})`); + }, +}; + +export default plugin; diff --git a/extensions/web4-governance/package.json b/extensions/web4-governance/package.json new file mode 100644 index 000000000..812f8b7de --- /dev/null +++ b/extensions/web4-governance/package.json @@ -0,0 +1,12 @@ +{ + "name": "@moltbot/web4-governance", + "version": "2026.1.27", + "type": "module", + "description": "Web4 governance with R6 workflow, audit trails, and trust tensors", + "moltbot": { + "extensions": ["./index.ts"] + }, + "peerDependencies": { + "moltbot": ">=2026.1.26" + } +} diff --git a/extensions/web4-governance/src/audit.test.ts b/extensions/web4-governance/src/audit.test.ts new file mode 100644 index 000000000..5c240384b --- /dev/null +++ b/extensions/web4-governance/src/audit.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { AuditChain } from "./audit.js"; +import { createR6Request } from "./r6.js"; + +const TEST_DIR = join(import.meta.dirname ?? ".", ".test-audit-tmp"); + +function cleanup() { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +} + +function makeR6(actionIndex: number, toolName = "Read") { + return createR6Request("test-session", "agent-1", toolName, { file_path: "/foo" }, actionIndex, undefined, "standard"); +} + +describe("AuditChain", () => { + afterEach(cleanup); + + it("should create audit directory on construction", () => { + cleanup(); + new AuditChain(TEST_DIR, "s1"); + expect(existsSync(join(TEST_DIR, "audit"))).toBe(true); + }); + + it("should record an audit entry and write to JSONL", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "s1"); + const r6 = makeR6(0); + const record = chain.record(r6, { status: "success", outputHash: "abc123" }); + + expect(record.recordId).toMatch(/^audit:/); + expect(record.r6RequestId).toBe(r6.id); + expect(record.tool).toBe("Read"); + expect(record.result.status).toBe("success"); + expect(record.provenance.prevRecordHash).toBe("genesis"); + expect(chain.count).toBe(1); + + const filePath = join(TEST_DIR, "audit", "s1.jsonl"); + expect(existsSync(filePath)).toBe(true); + const content = readFileSync(filePath, "utf-8").trim(); + const parsed = JSON.parse(content); + expect(parsed.recordId).toBe(record.recordId); + }); + + it("should chain records with hash links", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "s1"); + chain.record(makeR6(0), { status: "success" }); + const second = chain.record(makeR6(1), { status: "success" }); + + expect(second.provenance.prevRecordHash).not.toBe("genesis"); + expect(second.provenance.prevRecordHash).toMatch(/^[a-f0-9]{16}$/); + expect(chain.count).toBe(2); + }); + + it("should verify a valid chain", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "s1"); + chain.record(makeR6(0), { status: "success" }); + chain.record(makeR6(1), { status: "success" }); + chain.record(makeR6(2), { status: "error", errorMessage: "boom" }); + + const result = chain.verify(); + expect(result.valid).toBe(true); + expect(result.recordCount).toBe(3); + expect(result.errors).toHaveLength(0); + }); + + it("should verify an empty/nonexistent chain as valid", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "nonexistent"); + const result = chain.verify(); + expect(result.valid).toBe(true); + expect(result.recordCount).toBe(0); + }); + + it("should resume chain state from existing file", () => { + cleanup(); + const chain1 = new AuditChain(TEST_DIR, "s1"); + chain1.record(makeR6(0), { status: "success" }); + chain1.record(makeR6(1), { status: "success" }); + + // Recreate from same file + const chain2 = new AuditChain(TEST_DIR, "s1"); + expect(chain2.count).toBe(2); + chain2.record(makeR6(2), { status: "success" }); + + const result = chain2.verify(); + expect(result.valid).toBe(true); + expect(result.recordCount).toBe(3); + }); + + it("should retrieve last N records", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "s1"); + for (let i = 0; i < 5; i++) { + chain.record(makeR6(i, i % 2 === 0 ? "Read" : "Write"), { status: "success" }); + } + + const last3 = chain.getLast(3); + expect(last3).toHaveLength(3); + expect(last3[0]!.provenance.actionIndex).toBe(2); + expect(last3[2]!.provenance.actionIndex).toBe(4); + }); + + it("should return empty array for getLast on nonexistent file", () => { + cleanup(); + const chain = new AuditChain(TEST_DIR, "nope"); + expect(chain.getLast(10)).toEqual([]); + }); +}); diff --git a/extensions/web4-governance/src/audit.ts b/extensions/web4-governance/src/audit.ts new file mode 100644 index 000000000..e2125b9fa --- /dev/null +++ b/extensions/web4-governance/src/audit.ts @@ -0,0 +1,137 @@ +/** + * Audit Trail - Hash-linked chain of action records. + * + * Each audit record links to its R6 request and the previous record, + * creating a verifiable chain of provenance. + */ + +import { createHash } from "node:crypto"; +import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { R6Request } from "./r6.js"; + +export type AuditRecord = { + recordId: string; + r6RequestId: string; + timestamp: string; + tool: string; + category: string; + target?: string; + result: { + status: "success" | "error" | "blocked"; + outputHash?: string; + errorMessage?: string; + durationMs?: number; + }; + provenance: { + sessionId: string; + actionIndex: number; + prevRecordHash: string; + }; +}; + +export class AuditChain { + private storagePath: string; + private sessionId: string; + private prevHash: string = "genesis"; + private recordCount: number = 0; + + constructor(storagePath: string, sessionId: string) { + this.storagePath = storagePath; + this.sessionId = sessionId; + mkdirSync(join(this.storagePath, "audit"), { recursive: true }); + this.loadExisting(); + } + + private get filePath(): string { + return join(this.storagePath, "audit", `${this.sessionId}.jsonl`); + } + + private loadExisting(): void { + if (!existsSync(this.filePath)) return; + try { + const content = readFileSync(this.filePath, "utf-8").trim(); + if (!content) return; + const lines = content.split("\n"); + this.recordCount = lines.length; + const lastLine = lines[lines.length - 1]; + if (lastLine) { + this.prevHash = createHash("sha256").update(lastLine).digest("hex").slice(0, 16); + } + } catch { + // Start fresh on error + } + } + + record(r6: R6Request, result: AuditRecord["result"]): AuditRecord { + const record: AuditRecord = { + recordId: `audit:${r6.id.slice(3)}`, + r6RequestId: r6.id, + timestamp: new Date().toISOString(), + tool: r6.request.toolName, + category: r6.request.category, + target: r6.request.target, + result, + provenance: { + sessionId: this.sessionId, + actionIndex: r6.role.actionIndex, + prevRecordHash: this.prevHash, + }, + }; + + const line = JSON.stringify(record); + appendFileSync(this.filePath, line + "\n"); + this.prevHash = createHash("sha256").update(line).digest("hex").slice(0, 16); + this.recordCount++; + + return record; + } + + verify(): { valid: boolean; recordCount: number; errors: string[] } { + const errors: string[] = []; + if (!existsSync(this.filePath)) { + return { valid: true, recordCount: 0, errors: [] }; + } + + const content = readFileSync(this.filePath, "utf-8").trim(); + if (!content) return { valid: true, recordCount: 0, errors: [] }; + + const lines = content.split("\n"); + let prevHash = "genesis"; + + for (let i = 0; i < lines.length; i++) { + try { + const record: AuditRecord = JSON.parse(lines[i]!); + if (record.provenance.prevRecordHash !== prevHash) { + errors.push(`Record ${i}: hash mismatch (expected ${prevHash}, got ${record.provenance.prevRecordHash})`); + } + prevHash = createHash("sha256").update(lines[i]!).digest("hex").slice(0, 16); + } catch (e) { + errors.push(`Record ${i}: parse error`); + } + } + + return { valid: errors.length === 0, recordCount: lines.length, errors }; + } + + get count(): number { + return this.recordCount; + } + + getLast(n: number): AuditRecord[] { + if (!existsSync(this.filePath)) return []; + const content = readFileSync(this.filePath, "utf-8").trim(); + if (!content) return []; + const lines = content.split("\n"); + return lines + .slice(-n) + .map((line) => { + try { + return JSON.parse(line) as AuditRecord; + } catch { + return null; + } + }) + .filter((r): r is AuditRecord => r !== null); + } +} diff --git a/extensions/web4-governance/src/r6.test.ts b/extensions/web4-governance/src/r6.test.ts new file mode 100644 index 000000000..2ca67f21c --- /dev/null +++ b/extensions/web4-governance/src/r6.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + classifyTool, + hashInput, + hashOutput, + extractTarget, + createR6Request, +} from "./r6.js"; + +describe("classifyTool", () => { + it("should classify file read tools", () => { + expect(classifyTool("Read")).toBe("file_read"); + expect(classifyTool("Glob")).toBe("file_read"); + expect(classifyTool("Grep")).toBe("file_read"); + }); + + it("should classify file write tools", () => { + expect(classifyTool("Write")).toBe("file_write"); + expect(classifyTool("Edit")).toBe("file_write"); + expect(classifyTool("NotebookEdit")).toBe("file_write"); + }); + + it("should classify command tools", () => { + expect(classifyTool("Bash")).toBe("command"); + }); + + it("should classify network tools", () => { + expect(classifyTool("WebFetch")).toBe("network"); + expect(classifyTool("WebSearch")).toBe("network"); + }); + + it("should classify delegation tools", () => { + expect(classifyTool("Task")).toBe("delegation"); + }); + + it("should classify state tools", () => { + expect(classifyTool("TodoWrite")).toBe("state"); + }); + + it("should return unknown for unrecognized tools", () => { + expect(classifyTool("CustomTool")).toBe("unknown"); + expect(classifyTool("")).toBe("unknown"); + }); +}); + +describe("hashInput", () => { + it("should return a 16-char hex string", () => { + const h = hashInput({ foo: "bar" }); + expect(h).toMatch(/^[a-f0-9]{16}$/); + }); + + it("should produce deterministic hashes", () => { + expect(hashInput({ a: 1 })).toBe(hashInput({ a: 1 })); + }); + + it("should produce different hashes for different input", () => { + expect(hashInput({ a: 1 })).not.toBe(hashInput({ a: 2 })); + }); +}); + +describe("hashOutput", () => { + it("should hash string output directly", () => { + const h = hashOutput("hello"); + expect(h).toMatch(/^[a-f0-9]{16}$/); + }); + + it("should JSON-stringify non-string output", () => { + const h = hashOutput({ result: true }); + expect(h).toMatch(/^[a-f0-9]{16}$/); + }); + + it("should produce deterministic hashes", () => { + expect(hashOutput("test")).toBe(hashOutput("test")); + }); +}); + +describe("extractTarget", () => { + it("should extract file_path", () => { + expect(extractTarget("Read", { file_path: "/foo/bar.ts" })).toBe("/foo/bar.ts"); + }); + + it("should extract path", () => { + expect(extractTarget("Glob", { path: "/src" })).toBe("/src"); + }); + + it("should extract pattern", () => { + expect(extractTarget("Grep", { pattern: "TODO" })).toBe("TODO"); + }); + + it("should extract and truncate long commands", () => { + const longCmd = "a".repeat(100); + const result = extractTarget("Bash", { command: longCmd }); + expect(result).toHaveLength(83); // 80 + "..." + expect(result!.endsWith("...")).toBe(true); + }); + + it("should extract short commands without truncation", () => { + expect(extractTarget("Bash", { command: "ls -la" })).toBe("ls -la"); + }); + + it("should extract url", () => { + expect(extractTarget("WebFetch", { url: "https://example.com" })).toBe("https://example.com"); + }); + + it("should return undefined when no target found", () => { + expect(extractTarget("Task", { prompt: "do something" })).toBeUndefined(); + }); +}); + +describe("createR6Request", () => { + it("should create a well-formed R6 request", () => { + const r6 = createR6Request("sess-1", "agent-1", "Read", { file_path: "/foo" }, 0, undefined, "standard"); + + expect(r6.id).toMatch(/^r6:[a-f0-9]{8}$/); + expect(r6.timestamp).toBeTruthy(); + expect(r6.rules).toEqual({ auditLevel: "standard", constraints: [] }); + expect(r6.role).toEqual({ + sessionId: "sess-1", + agentId: "agent-1", + actionIndex: 0, + bindingType: "soft-lct", + }); + expect(r6.request.toolName).toBe("Read"); + expect(r6.request.category).toBe("file_read"); + expect(r6.request.target).toBe("/foo"); + expect(r6.request.inputHash).toMatch(/^[a-f0-9]{16}$/); + expect(r6.reference).toEqual({ + sessionId: "sess-1", + prevR6Id: undefined, + chainPosition: 0, + }); + expect(r6.resource).toEqual({ approvalRequired: false }); + expect(r6.result).toBeUndefined(); + }); + + it("should link to previous R6 request", () => { + const r6 = createR6Request("s", undefined, "Bash", { command: "ls" }, 5, "r6:prev0001", "minimal"); + expect(r6.reference.prevR6Id).toBe("r6:prev0001"); + expect(r6.reference.chainPosition).toBe(5); + }); +}); diff --git a/extensions/web4-governance/src/r6.ts b/extensions/web4-governance/src/r6.ts new file mode 100644 index 000000000..de54892ba --- /dev/null +++ b/extensions/web4-governance/src/r6.ts @@ -0,0 +1,146 @@ +/** + * R6 Framework - Intent → Action → Result + * + * R6 = Rules + Role + Request + Reference + Resource → Result + * + * Every tool call gets a structured R6 record that captures intent, + * context, and outcome for audit and trust evaluation. + */ + +import { createHash, randomUUID } from "node:crypto"; + +export type R6Request = { + id: string; + timestamp: string; + rules: R6Rules; + role: R6Role; + request: R6RequestDetail; + reference: R6Reference; + resource: R6Resource; + result?: R6Result; +}; + +export type R6Rules = { + auditLevel: string; + constraints: string[]; +}; + +export type R6Role = { + sessionId: string; + agentId?: string; + actionIndex: number; + bindingType: "soft-lct"; +}; + +export type R6RequestDetail = { + toolName: string; + category: ToolCategory; + target?: string; + inputHash: string; +}; + +export type R6Reference = { + sessionId: string; + prevR6Id?: string; + chainPosition: number; +}; + +export type R6Resource = { + estimatedTokens?: number; + approvalRequired: boolean; +}; + +export type R6Result = { + status: "success" | "error" | "blocked"; + outputHash?: string; + errorMessage?: string; + durationMs?: number; +}; + +export type ToolCategory = + | "file_read" + | "file_write" + | "command" + | "network" + | "delegation" + | "state" + | "mcp" + | "unknown"; + +const TOOL_CATEGORIES: Record = { + Read: "file_read", + Glob: "file_read", + Grep: "file_read", + Write: "file_write", + Edit: "file_write", + NotebookEdit: "file_write", + Bash: "command", + WebFetch: "network", + WebSearch: "network", + Task: "delegation", + TodoWrite: "state", +}; + +export function classifyTool(toolName: string): ToolCategory { + return TOOL_CATEGORIES[toolName] ?? "unknown"; +} + +export function hashInput(input: Record): string { + return createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16); +} + +export function hashOutput(output: unknown): string { + const str = typeof output === "string" ? output : JSON.stringify(output); + return createHash("sha256").update(str).digest("hex").slice(0, 16); +} + +export function extractTarget(toolName: string, params: Record): string | undefined { + if (params.file_path) return String(params.file_path); + if (params.path) return String(params.path); + if (params.pattern) return String(params.pattern); + if (params.command) { + const cmd = String(params.command); + return cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd; + } + if (params.url) return String(params.url); + return undefined; +} + +export function createR6Request( + sessionId: string, + agentId: string | undefined, + toolName: string, + params: Record, + actionIndex: number, + prevR6Id: string | undefined, + auditLevel: string, +): R6Request { + return { + id: `r6:${randomUUID().slice(0, 8)}`, + timestamp: new Date().toISOString(), + rules: { + auditLevel, + constraints: [], + }, + role: { + sessionId, + agentId, + actionIndex, + bindingType: "soft-lct", + }, + request: { + toolName, + category: classifyTool(toolName), + target: extractTarget(toolName, params), + inputHash: hashInput(params), + }, + reference: { + sessionId, + prevR6Id, + chainPosition: actionIndex, + }, + resource: { + approvalRequired: false, + }, + }; +} diff --git a/extensions/web4-governance/src/session-state.test.ts b/extensions/web4-governance/src/session-state.test.ts new file mode 100644 index 000000000..d83ee8fd4 --- /dev/null +++ b/extensions/web4-governance/src/session-state.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { SessionStore, type SessionState } from "./session-state.js"; +import { createSoftLCT } from "./soft-lct.js"; + +const TEST_DIR = join(import.meta.dirname ?? ".", ".test-session-tmp"); + +function cleanup() { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +} + +function makeState(sessionId = "test-sess"): SessionState { + return { + sessionId, + lct: createSoftLCT(sessionId), + actionIndex: 0, + startedAt: new Date().toISOString(), + toolCounts: {}, + categoryCounts: {}, + }; +} + +describe("SessionStore", () => { + afterEach(cleanup); + + it("should create sessions directory on construction", () => { + cleanup(); + new SessionStore(TEST_DIR); + expect(existsSync(join(TEST_DIR, "sessions"))).toBe(true); + }); + + it("should save and load a session state", () => { + cleanup(); + const store = new SessionStore(TEST_DIR); + const state = makeState("s1"); + store.save(state); + + const loaded = store.load("s1"); + expect(loaded).not.toBeNull(); + expect(loaded!.sessionId).toBe("s1"); + expect(loaded!.lct.bindingType).toBe("software"); + }); + + it("should return null for nonexistent session", () => { + cleanup(); + const store = new SessionStore(TEST_DIR); + expect(store.load("nope")).toBeNull(); + }); + + it("should persist as JSON file", () => { + cleanup(); + const store = new SessionStore(TEST_DIR); + store.save(makeState("s1")); + + const filePath = join(TEST_DIR, "sessions", "s1.json"); + expect(existsSync(filePath)).toBe(true); + const raw = JSON.parse(readFileSync(filePath, "utf-8")); + expect(raw.sessionId).toBe("s1"); + }); + + it("should increment action and persist", () => { + cleanup(); + const store = new SessionStore(TEST_DIR); + const state = makeState("s1"); + store.save(state); + + store.incrementAction(state, "Read", "file_read", "r6:001"); + expect(state.actionIndex).toBe(1); + expect(state.lastR6Id).toBe("r6:001"); + expect(state.toolCounts["Read"]).toBe(1); + expect(state.categoryCounts["file_read"]).toBe(1); + + store.incrementAction(state, "Read", "file_read", "r6:002"); + expect(state.actionIndex).toBe(2); + expect(state.toolCounts["Read"]).toBe(2); + + store.incrementAction(state, "Write", "file_write", "r6:003"); + expect(state.toolCounts["Write"]).toBe(1); + expect(state.categoryCounts["file_write"]).toBe(1); + + // Verify persisted + const loaded = store.load("s1"); + expect(loaded!.actionIndex).toBe(3); + expect(loaded!.lastR6Id).toBe("r6:003"); + }); +}); diff --git a/extensions/web4-governance/src/session-state.ts b/extensions/web4-governance/src/session-state.ts new file mode 100644 index 000000000..e7cb0b48f --- /dev/null +++ b/extensions/web4-governance/src/session-state.ts @@ -0,0 +1,52 @@ +/** + * Session State - Tracks R6 chain state for a session. + */ + +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { SoftLCTToken } from "./soft-lct.js"; + +export type SessionState = { + sessionId: string; + lct: SoftLCTToken; + actionIndex: number; + lastR6Id?: string; + startedAt: string; + toolCounts: Record; + categoryCounts: Record; +}; + +export class SessionStore { + private storagePath: string; + + constructor(storagePath: string) { + this.storagePath = storagePath; + mkdirSync(join(this.storagePath, "sessions"), { recursive: true }); + } + + private filePath(sessionId: string): string { + return join(this.storagePath, "sessions", `${sessionId}.json`); + } + + save(state: SessionState): void { + writeFileSync(this.filePath(state.sessionId), JSON.stringify(state, null, 2)); + } + + load(sessionId: string): SessionState | null { + const path = this.filePath(sessionId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as SessionState; + } catch { + return null; + } + } + + incrementAction(state: SessionState, toolName: string, category: string, r6Id: string): void { + state.actionIndex++; + state.lastR6Id = r6Id; + state.toolCounts[toolName] = (state.toolCounts[toolName] ?? 0) + 1; + state.categoryCounts[category] = (state.categoryCounts[category] ?? 0) + 1; + this.save(state); + } +} diff --git a/extensions/web4-governance/src/soft-lct.test.ts b/extensions/web4-governance/src/soft-lct.test.ts new file mode 100644 index 000000000..0200c9f57 --- /dev/null +++ b/extensions/web4-governance/src/soft-lct.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSoftLCT } from "./soft-lct.js"; + +vi.mock("node:os", () => ({ + hostname: () => "test-host", + userInfo: () => ({ username: "test-user" }), +})); + +describe("createSoftLCT", () => { + it("should return a token with the correct structure", () => { + const token = createSoftLCT("session-123"); + expect(token).toMatchObject({ + sessionId: "session-123", + bindingType: "software", + }); + expect(token.tokenId).toMatch(/^web4:session:[a-f0-9]{8}:session-/); + expect(token.machineHash).toMatch(/^[a-f0-9]{8}$/); + expect(token.createdAt).toBeTruthy(); + }); + + it("should produce a deterministic machineHash for same host+user", () => { + const a = createSoftLCT("s1"); + const b = createSoftLCT("s2"); + expect(a.machineHash).toBe(b.machineHash); + }); + + it("should truncate sessionId to first 8 chars in tokenId", () => { + const token = createSoftLCT("abcdefghijklmnop"); + expect(token.tokenId).toContain(":abcdefgh"); + expect(token.tokenId).not.toContain("ijklmnop"); + }); + + it("should set createdAt to a valid ISO timestamp", () => { + const token = createSoftLCT("s1"); + expect(new Date(token.createdAt).toISOString()).toBe(token.createdAt); + }); +}); diff --git a/extensions/web4-governance/src/soft-lct.ts b/extensions/web4-governance/src/soft-lct.ts new file mode 100644 index 000000000..e60f45bec --- /dev/null +++ b/extensions/web4-governance/src/soft-lct.ts @@ -0,0 +1,35 @@ +/** + * Soft LCT - Software-bound Linked Context Token. + * + * Generates a session identity token from machine + user context. + * NOT hardware-bound (no TPM/SE). Trust interpretation is up to + * the relying party. + */ + +import { createHash } from "node:crypto"; +import { hostname, userInfo } from "node:os"; + +export type SoftLCTToken = { + tokenId: string; + sessionId: string; + machineHash: string; + createdAt: string; + bindingType: "software"; +}; + +export function createSoftLCT(sessionId: string): SoftLCTToken { + const machine = hostname(); + const user = userInfo().username; + const machineHash = createHash("sha256") + .update(`${machine}:${user}`) + .digest("hex") + .slice(0, 8); + + return { + tokenId: `web4:session:${machineHash}:${sessionId.slice(0, 8)}`, + sessionId, + machineHash, + createdAt: new Date().toISOString(), + bindingType: "software", + }; +}