Merge pull request #2 from dp-web4/feat/web4-governance-plugin
Add web4-governance plugin: R6 audit trails and session identity
This commit is contained in:
commit
05511453fa
22
extensions/web4-governance/clawdbot.plugin.json
Normal file
22
extensions/web4-governance/clawdbot.plugin.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
236
extensions/web4-governance/index.ts
Normal file
236
extensions/web4-governance/index.ts
Normal file
@ -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<string, { state: SessionState; audit: AuditChain }>();
|
||||
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<string, unknown>;
|
||||
|
||||
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;
|
||||
12
extensions/web4-governance/package.json
Normal file
12
extensions/web4-governance/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
114
extensions/web4-governance/src/audit.test.ts
Normal file
114
extensions/web4-governance/src/audit.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
137
extensions/web4-governance/src/audit.ts
Normal file
137
extensions/web4-governance/src/audit.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
141
extensions/web4-governance/src/r6.test.ts
Normal file
141
extensions/web4-governance/src/r6.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
146
extensions/web4-governance/src/r6.ts
Normal file
146
extensions/web4-governance/src/r6.ts
Normal file
@ -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<string, ToolCategory> = {
|
||||
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, unknown>): 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, unknown>): 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<string, unknown>,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
89
extensions/web4-governance/src/session-state.test.ts
Normal file
89
extensions/web4-governance/src/session-state.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
52
extensions/web4-governance/src/session-state.ts
Normal file
52
extensions/web4-governance/src/session-state.ts
Normal file
@ -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<string, number>;
|
||||
categoryCounts: Record<string, number>;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
37
extensions/web4-governance/src/soft-lct.test.ts
Normal file
37
extensions/web4-governance/src/soft-lct.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
35
extensions/web4-governance/src/soft-lct.ts
Normal file
35
extensions/web4-governance/src/soft-lct.ts
Normal file
@ -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",
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user