Add web4-governance plugin: R6 audit trails and session identity

Adds a governance extension that creates verifiable audit trails for
agent tool usage using the R6 framework (Rules+Role+Request+Reference+
Resource→Result). Uses the typed after_tool_call hooks wired in #1
to capture every tool invocation with provenance, timing, and
hash-linked chain integrity.

Includes:
- R6 request framework with tool classification
- Hash-linked JSONL audit chain with verification
- Software-bound session identity tokens (Soft LCT)
- Session state tracking with tool/category counts
- CLI commands: audit summary, audit verify, audit last
- 39 unit tests across all modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dp-web4 2026-01-27 20:24:29 -08:00
parent 0ea444d35d
commit fd457ac3f4
11 changed files with 1021 additions and 0 deletions

View 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"
}
}
}
}

View 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;

View 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"
}
}

View 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([]);
});
});

View 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);
}
}

View 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);
});
});

View 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,
},
};
}

View 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");
});
});

View 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);
}
}

View 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);
});
});

View 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",
};
}