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