From 8b5c5a87ee6ea45c8d2b62971a2d92a93058626f Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 00:26:34 +0300 Subject: [PATCH 1/6] feat(security): add TypeScript audit chain verifier Companion to #916's Python audit logger. Provides the TypeScript plugin with read-side APIs for the tamper-evident audit chain: - verifyChain: validate hash integrity and prev_hash linkage - exportEntries: query entries by timestamp with optional limit - tailEntries: return the last N entries Uses canonical JSON serialization (sorted keys, compact separators) to match Python's json.dumps(sort_keys=True) for cross-language hash verification. 30 tests covering chain validation, tamper detection (modified data, broken links, deleted entries, spliced chains), query APIs, and cross-format verification against the Python entry format. --- .../skills/nemoclaw-user-reference/SKILL.md | 3 +- .../references/audit-verifier.md | 86 +++++ docs/reference/audit-verifier.md | 106 ++++++ nemoclaw/src/security/audit-verifier.test.ts | 333 ++++++++++++++++++ nemoclaw/src/security/audit-verifier.ts | 251 +++++++++++++ 5 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/nemoclaw-user-reference/references/audit-verifier.md create mode 100644 docs/reference/audit-verifier.md create mode 100644 nemoclaw/src/security/audit-verifier.test.ts create mode 100644 nemoclaw/src/security/audit-verifier.ts diff --git a/.agents/skills/nemoclaw-user-reference/SKILL.md b/.agents/skills/nemoclaw-user-reference/SKILL.md index a659e9871a..d927736ee4 100644 --- a/.agents/skills/nemoclaw-user-reference/SKILL.md +++ b/.agents/skills/nemoclaw-user-reference/SKILL.md @@ -1,6 +1,6 @@ --- name: "nemoclaw-user-reference" -description: "Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when looking up NemoClaw architecture, plugin structure, or blueprint design. Lists all slash commands and standalone NemoClaw CLI commands. Use when looking up a command, checking command syntax, or browsing the CLI reference. Documents baseline network policy, filesystem rules, and operator approval flow. Use when reviewing default network policies, understanding egress controls, or looking up the approval flow. Diagnoses and resolves common NemoClaw installation, onboarding, and runtime issues. Use when troubleshooting errors, debugging sandbox problems, or resolving setup failures." +description: "Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move OpenClaw into a controlled sandbox. Use when looking up NemoClaw architecture, plugin structure, or blueprint design. TypeScript audit chain verifier API reference covering verifyChain, exportEntries, and tailEntries. Use when looking up audit chain verification, querying audit entries by timestamp, or reading the last N entries from the audit log. Lists all slash commands and standalone NemoClaw CLI commands. Use when looking up a command, checking command syntax, or browsing the CLI reference. Documents baseline network policy, filesystem rules, and operator approval flow. Use when reviewing default network policies, understanding egress controls, or looking up the approval flow. Diagnoses and resolves common NemoClaw installation, onboarding, and runtime issues. Use when troubleshooting errors, debugging sandbox problems, or resolving setup failures." --- @@ -13,6 +13,7 @@ Describes how NemoClaw combines a CLI plugin with a versioned blueprint to move ## Reference - [NemoClaw Architecture: Plugin, Blueprint, and Sandbox Structure](references/architecture.md) +- [NemoClaw Audit Verifier: TypeScript API for Tamper-Evident Audit Chain](references/audit-verifier.md) - [NemoClaw CLI Commands Reference](references/commands.md) - [NemoClaw Network Policies: Baseline Rules and Operator Approval](references/network-policies.md) - [NemoClaw Troubleshooting Guide](references/troubleshooting.md) diff --git a/.agents/skills/nemoclaw-user-reference/references/audit-verifier.md b/.agents/skills/nemoclaw-user-reference/references/audit-verifier.md new file mode 100644 index 0000000000..009e39faba --- /dev/null +++ b/.agents/skills/nemoclaw-user-reference/references/audit-verifier.md @@ -0,0 +1,86 @@ + + +# NemoClaw Audit Verifier: TypeScript API for Tamper-Evident Audit Chain + +The audit verifier reads and validates the tamper-evident audit chain written by the Python orchestrator (`nemoclaw-blueprint/orchestrator/audit.py`). +It provides hash chain verification and query APIs for the TypeScript plugin. + +## How It Works + +The Python audit module writes SHA-256 hash-chained JSONL entries to `/var/log/nemoclaw/audit.jsonl`. +Each entry contains a `hash` field computed from the canonical JSON representation of the entry (without the `hash` field itself). +Each entry's `prev_hash` links to the previous entry's `hash`, forming a chain starting from `"genesis"`. + +The TypeScript verifier reads these entries and recomputes the hashes using the same canonical JSON serialization (sorted keys, compact separators) to confirm the chain is intact. + +## API + +The verifier exports the following functions and types from `nemoclaw/src/security/audit-verifier.ts`. + +### `verifyChain(path: string): VerifyResult` + +Verify the integrity of an audit chain file. +Returns `{ valid: true, entries: N }` if the chain is intact. +Returns `{ valid: false, entries: N, error: "..." }` if tampering is detected, with the number of valid entries before the break. +Returns `{ valid: true, entries: 0 }` for empty or nonexistent files. + +```typescript +import { verifyChain } from "./security/audit-verifier.js"; + +const result = verifyChain("/var/log/nemoclaw/audit.jsonl"); +if (!result.valid) { + console.error(`Chain broken after ${result.entries} entries: ${result.error}`); +} +``` + +### `exportEntries(path: string, since: number, limit?: number): AuditEntry[]` + +Export audit entries where `timestamp >= since` (Unix epoch seconds), up to `limit`. +Skips malformed lines. +Returns an empty array for nonexistent files. + +```typescript +import { exportEntries } from "./security/audit-verifier.js"; + +// Get entries from the last hour +const oneHourAgo = Date.now() / 1000 - 3600; +const recent = exportEntries("/var/log/nemoclaw/audit.jsonl", oneHourAgo); +``` + +### `tailEntries(path: string, n?: number): AuditEntry[]` + +Return the last `n` entries from an audit file. +Defaults to 50 when `n` is omitted. +Skips malformed lines. + +```typescript +import { tailEntries } from "./security/audit-verifier.js"; + +const last10 = tailEntries("/var/log/nemoclaw/audit.jsonl", 10); +``` + +### `AuditEntry` + +```typescript +interface AuditEntry { + readonly timestamp: number; + readonly prev_hash: string; + readonly event: unknown; + readonly hash: string; +} +``` + +### `VerifyResult` + +```typescript +interface VerifyResult { + readonly valid: boolean; + readonly entries: number; + readonly error?: string; +} +``` + +## Next Steps + +- See [Audit Logging](docs/security/audit-logging.md) for how the Python orchestrator writes and protects the audit chain. +- See NemoClaw Architecture: Plugin, Blueprint, and Sandbox Structure (see the `nemoclaw-user-reference` skill) for how the TypeScript plugin and Python blueprint interact. diff --git a/docs/reference/audit-verifier.md b/docs/reference/audit-verifier.md new file mode 100644 index 0000000000..cc3c3c2dca --- /dev/null +++ b/docs/reference/audit-verifier.md @@ -0,0 +1,106 @@ +--- +title: + page: "NemoClaw Audit Verifier: TypeScript API for Tamper-Evident Audit Chain" + nav: "Audit Verifier" +description: + main: "Reference for the TypeScript audit chain verifier that reads, validates, and queries SHA-256 hash-chained JSONL audit entries written by the Python orchestrator." + agent: "TypeScript audit chain verifier API reference covering verifyChain, exportEntries, and tailEntries. Use when looking up audit chain verification, querying audit entries by timestamp, or reading the last N entries from the audit log." +keywords: ["nemoclaw audit verifier", "audit chain", "hash chain verification", "tamper detection"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "security", "audit", "verification"] +content: + type: reference + difficulty: intermediate + audience: ["developer", "engineer", "security_engineer"] +status: published +--- + + + +# NemoClaw Audit Verifier: TypeScript API for Tamper-Evident Audit Chain + +The audit verifier reads and validates the tamper-evident audit chain written by the Python orchestrator (`nemoclaw-blueprint/orchestrator/audit.py`). +It provides hash chain verification and query APIs for the TypeScript plugin. + +## How It Works + +The Python audit module writes SHA-256 hash-chained JSONL entries to `/var/log/nemoclaw/audit.jsonl`. +Each entry contains a `hash` field computed from the canonical JSON representation of the entry (without the `hash` field itself). +Each entry's `prev_hash` links to the previous entry's `hash`, forming a chain starting from `"genesis"`. + +The TypeScript verifier reads these entries and recomputes the hashes using the same canonical JSON serialization (sorted keys, compact separators) to confirm the chain is intact. + +## API + +The verifier exports the following functions and types from `nemoclaw/src/security/audit-verifier.ts`. + +### `verifyChain(path: string): VerifyResult` + +Verify the integrity of an audit chain file. +Returns `{ valid: true, entries: N }` if the chain is intact. +Returns `{ valid: false, entries: N, error: "..." }` if tampering is detected, with the number of valid entries before the break. +Returns `{ valid: true, entries: 0 }` for empty or nonexistent files. + +```typescript +import { verifyChain } from "./security/audit-verifier.js"; + +const result = verifyChain("/var/log/nemoclaw/audit.jsonl"); +if (!result.valid) { + console.error(`Chain broken after ${result.entries} entries: ${result.error}`); +} +``` + +### `exportEntries(path: string, since: number, limit?: number): AuditEntry[]` + +Export audit entries where `timestamp >= since` (Unix epoch seconds), up to `limit`. +Skips malformed lines. +Returns an empty array for nonexistent files. + +```typescript +import { exportEntries } from "./security/audit-verifier.js"; + +// Get entries from the last hour +const oneHourAgo = Date.now() / 1000 - 3600; +const recent = exportEntries("/var/log/nemoclaw/audit.jsonl", oneHourAgo); +``` + +### `tailEntries(path: string, n?: number): AuditEntry[]` + +Return the last `n` entries from an audit file. +Defaults to 50 when `n` is omitted. +Skips malformed lines. + +```typescript +import { tailEntries } from "./security/audit-verifier.js"; + +const last10 = tailEntries("/var/log/nemoclaw/audit.jsonl", 10); +``` + +### `AuditEntry` + +```typescript +interface AuditEntry { + readonly timestamp: number; + readonly prev_hash: string; + readonly event: unknown; + readonly hash: string; +} +``` + +### `VerifyResult` + +```typescript +interface VerifyResult { + readonly valid: boolean; + readonly entries: number; + readonly error?: string; +} +``` + +## Next Steps + +- See [Audit Logging](../security/audit-logging.md) for how the Python orchestrator writes and protects the audit chain. +- See [NemoClaw Architecture: Plugin, Blueprint, and Sandbox Structure](architecture.md) for how the TypeScript plugin and Python blueprint interact. diff --git a/nemoclaw/src/security/audit-verifier.test.ts b/nemoclaw/src/security/audit-verifier.test.ts new file mode 100644 index 0000000000..2aae0e6e06 --- /dev/null +++ b/nemoclaw/src/security/audit-verifier.test.ts @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { verifyChain, exportEntries, tailEntries, type AuditEntry } from "./audit-verifier.js"; + +// ── Test helpers ───────────────────────────────────────────── + +/** Produce canonical JSON matching Python's json.dumps(separators=(",",":"), sort_keys=True). */ +function canonicalJson(obj: Record): string { + return stableStringify(obj); +} + +function stableStringify(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (typeof value === "object") { + const obj = value as Record; + const pairs = Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`); + return `{${pairs.join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(s: string): string { + return createHash("sha256").update(s, "utf-8").digest("hex"); +} + +/** Build an audit entry matching the Python format. */ +function makeEntry(event: unknown, prevHash: string, timestamp: number): AuditEntry { + const record: Record = { timestamp, prev_hash: prevHash, event }; + const hash = sha256(canonicalJson(record)); + return { timestamp, prev_hash: prevHash, event, hash }; +} + +/** Build a chain of N entries. */ +function makeChain(n: number, startTimestamp = 1700000000): AuditEntry[] { + const entries: AuditEntry[] = []; + let prevHash = "genesis"; + for (let i = 0; i < n; i++) { + const entry = makeEntry({ action: `event_${String(i)}`, seq: i }, prevHash, startTimestamp + i); + entries.push(entry); + prevHash = entry.hash; + } + return entries; +} + +function writeChain(path: string, entries: AuditEntry[]): void { + const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + writeFileSync(path, content, "utf-8"); +} + +// ── Test suite ─────────────────────────────────────────────── + +let tempDir: string; +let auditPath: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "audit-verifier-")); + auditPath = join(tempDir, "audit.jsonl"); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("verifyChain", () => { + it("returns valid for nonexistent file", () => { + const result = verifyChain(join(tempDir, "nonexistent.jsonl")); + expect(result).toEqual({ valid: true, entries: 0 }); + }); + + it("returns valid for empty file", () => { + writeFileSync(auditPath, "", "utf-8"); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 0 }); + }); + + it("returns valid for whitespace-only file", () => { + writeFileSync(auditPath, "\n\n \n", "utf-8"); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 0 }); + }); + + it("validates a single-entry chain", () => { + const entries = makeChain(1); + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 1 }); + }); + + it("validates a multi-entry chain", () => { + const entries = makeChain(10); + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 10 }); + }); + + it("validates a 100-entry chain", () => { + const entries = makeChain(100); + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 100 }); + }); + + it("detects modified event data", () => { + const entries = makeChain(5); + // Tamper with entry 3's event + const tampered = { ...entries[2], event: { action: "tampered", seq: 999 } }; + entries[2] = tampered; + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("hash mismatch at line 3"); + }); + + it("detects modified hash field", () => { + const entries = makeChain(3); + entries[1] = { ...entries[1], hash: "aaaa" + entries[1].hash.slice(4) }; + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.entries).toBe(1); + }); + + it("detects broken prev_hash link", () => { + const entries = makeChain(5); + entries[3] = { ...entries[3], prev_hash: "wrong_hash" }; + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("prev_hash mismatch at line 4"); + }); + + it("detects deleted entry (gap in chain)", () => { + const entries = makeChain(5); + // Remove entry at index 2 — entry 3 will have prev_hash pointing to entry 2 + entries.splice(2, 1); + writeChain(auditPath, entries); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("prev_hash mismatch"); + }); + + it("detects chain splice (entries from different chains)", () => { + const chain1 = makeChain(3, 1700000000); + const chain2 = makeChain(3, 1700001000); + // Splice: first 2 from chain1, then entry 2 from chain2 + const spliced = [chain1[0], chain1[1], chain2[2]]; + writeChain(auditPath, spliced); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("prev_hash mismatch"); + }); + + it("handles malformed JSON line gracefully", () => { + const entries = makeChain(3); + const lines = entries.map((e) => JSON.stringify(e)); + lines.splice(1, 0, "{ this is not valid json"); + writeFileSync(auditPath, lines.join("\n") + "\n", "utf-8"); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("malformed JSON at line 2"); + }); + + it("handles missing hash field", () => { + const record = { timestamp: 1700000000, prev_hash: "genesis", event: { test: true } }; + writeFileSync(auditPath, JSON.stringify(record) + "\n", "utf-8"); + const result = verifyChain(auditPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("missing hash field"); + }); + + it("returns error for empty path", () => { + const result = verifyChain(""); + expect(result.valid).toBe(false); + expect(result.error).toBe("path is required"); + }); +}); + +describe("exportEntries", () => { + it("returns empty array for nonexistent file", () => { + const entries = exportEntries(join(tempDir, "missing.jsonl"), 0); + expect(entries).toEqual([]); + }); + + it("returns all entries when since is 0", () => { + const chain = makeChain(5); + writeChain(auditPath, chain); + const entries = exportEntries(auditPath, 0); + expect(entries).toHaveLength(5); + }); + + it("filters by timestamp", () => { + const chain = makeChain(5, 1700000000); + writeChain(auditPath, chain); + // Entries have timestamps 1700000000..1700000004 + const entries = exportEntries(auditPath, 1700000003); + expect(entries).toHaveLength(2); + expect(entries[0].timestamp).toBe(1700000003); + expect(entries[1].timestamp).toBe(1700000004); + }); + + it("respects limit", () => { + const chain = makeChain(10); + writeChain(auditPath, chain); + const entries = exportEntries(auditPath, 0, 3); + expect(entries).toHaveLength(3); + }); + + it("returns all when limit is 0", () => { + const chain = makeChain(5); + writeChain(auditPath, chain); + const entries = exportEntries(auditPath, 0, 0); + expect(entries).toHaveLength(5); + }); + + it("skips malformed lines", () => { + const chain = makeChain(3); + const lines = chain.map((e) => JSON.stringify(e)); + lines.splice(1, 0, "not json"); + writeFileSync(auditPath, lines.join("\n") + "\n", "utf-8"); + const entries = exportEntries(auditPath, 0); + expect(entries).toHaveLength(3); + }); + + it("throws on empty path", () => { + expect(() => exportEntries("", 0)).toThrow("non-empty file path"); + }); + + it("throws on non-finite since", () => { + expect(() => exportEntries(auditPath, NaN)).toThrow("finite number"); + }); +}); + +describe("tailEntries", () => { + it("returns empty array for nonexistent file", () => { + const entries = tailEntries(join(tempDir, "missing.jsonl")); + expect(entries).toEqual([]); + }); + + it("returns last N entries", () => { + const chain = makeChain(10, 1700000000); + writeChain(auditPath, chain); + const entries = tailEntries(auditPath, 3); + expect(entries).toHaveLength(3); + expect(entries[0].timestamp).toBe(1700000007); + expect(entries[2].timestamp).toBe(1700000009); + }); + + it("defaults to 50 when n is omitted", () => { + const chain = makeChain(60, 1700000000); + writeChain(auditPath, chain); + const entries = tailEntries(auditPath); + expect(entries).toHaveLength(50); + expect(entries[0].timestamp).toBe(1700000010); + }); + + it("returns all entries when fewer than n", () => { + const chain = makeChain(3); + writeChain(auditPath, chain); + const entries = tailEntries(auditPath, 100); + expect(entries).toHaveLength(3); + }); + + it("skips malformed lines", () => { + const chain = makeChain(5); + const lines = chain.map((e) => JSON.stringify(e)); + lines.push("broken json line"); + writeFileSync(auditPath, lines.join("\n") + "\n", "utf-8"); + const entries = tailEntries(auditPath, 3); + expect(entries).toHaveLength(3); + }); + + it("throws on empty path", () => { + expect(() => tailEntries("")).toThrow("non-empty file path"); + }); +}); + +describe("cross-format verification", () => { + it("verifies entries constructed to match Python output format", () => { + // Simulate what Python's audit.py produces: + // json.dumps({"timestamp": 1700000000, "prev_hash": "genesis", "event": {"action": "gateway_start", "pid": 42}}, separators=(",",":"), sort_keys=True) + // = {"event":{"action":"gateway_start","pid":42},"prev_hash":"genesis","timestamp":1700000000} + const payload = + '{"event":{"action":"gateway_start","pid":42},"prev_hash":"genesis","timestamp":1700000000}'; + const hash = sha256(payload); + + const entry: AuditEntry = { + timestamp: 1700000000, + prev_hash: "genesis", + event: { action: "gateway_start", pid: 42 }, + hash, + }; + + writeFileSync(auditPath, JSON.stringify(entry) + "\n", "utf-8"); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 1 }); + }); + + it("verifies a two-entry chain matching Python format", () => { + const payload1 = + '{"event":{"action":"gateway_start"},"prev_hash":"genesis","timestamp":1700000000}'; + const hash1 = sha256(payload1); + const entry1: AuditEntry = { + timestamp: 1700000000, + prev_hash: "genesis", + event: { action: "gateway_start" }, + hash: hash1, + }; + + const payload2 = `{"event":{"action":"tool_call","tool":"write"},"prev_hash":"${hash1}","timestamp":1700000001}`; + const hash2 = sha256(payload2); + const entry2: AuditEntry = { + timestamp: 1700000001, + prev_hash: hash1, + event: { action: "tool_call", tool: "write" }, + hash: hash2, + }; + + writeChain(auditPath, [entry1, entry2]); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 2 }); + }); +}); diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts new file mode 100644 index 0000000000..7ecd144d49 --- /dev/null +++ b/nemoclaw/src/security/audit-verifier.ts @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * TypeScript verifier and query API for the tamper-evident audit chain. + * + * Reads JSONL audit files written by the Python orchestrator + * (`nemoclaw-blueprint/orchestrator/audit.py`) and provides: + * - `verifyChain` — validate hash integrity and prev_hash linkage + * - `exportEntries` — query entries by timestamp + * - `tailEntries` — return the last N entries + * + * Hash verification uses canonical JSON serialization (sorted keys, no + * whitespace) to match Python's `json.dumps(separators=(",",":"), sort_keys=True)`. + */ + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; + +// ── Public types ───────────────────────────────────────────── + +/** A single audit log entry as written by the Python audit module. */ +export interface AuditEntry { + readonly timestamp: number; + readonly prev_hash: string; + readonly event: unknown; + readonly hash: string; +} + +/** Result returned by `verifyChain`. */ +export interface VerifyResult { + readonly valid: boolean; + readonly entries: number; + readonly error?: string; +} + +// ── Standalone functions ───────────────────────────────────── + +/** + * Verify the integrity of an audit chain file. + * + * Reads every JSONL line, recomputes each entry hash using canonical JSON + * serialization, and confirms that `prev_hash` links form an unbroken + * chain starting from "genesis". + * + * Returns `{ valid: true, entries: 0 }` for empty or nonexistent files. + */ +export function verifyChain(path: string): VerifyResult { + if (!path || typeof path !== "string") { + return { valid: false, entries: 0, error: "path is required" }; + } + + if (!existsSync(path)) { + return { valid: true, entries: 0 }; + } + + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, entries: 0, error: `failed to read file: ${message}` }; + } + + const lines = content.split("\n").filter((line) => line.trim() !== ""); + if (lines.length === 0) { + return { valid: true, entries: 0 }; + } + + let prevHash = "genesis"; + let count = 0; + + for (let i = 0; i < lines.length; i++) { + let entry: AuditEntry; + try { + entry = JSON.parse(lines[i]) as AuditEntry; + } catch { + return { + valid: false, + entries: count, + error: `malformed JSON at line ${String(i + 1)}`, + }; + } + + if (typeof entry.hash !== "string") { + return { + valid: false, + entries: count, + error: `missing hash field at line ${String(i + 1)}`, + }; + } + + // Verify prev_hash links to the previous entry's hash + if (entry.prev_hash !== prevHash) { + return { + valid: false, + entries: count, + error: `prev_hash mismatch at line ${String(i + 1)}`, + }; + } + + // Reconstruct the payload without the hash field and recompute + const payload: Record = { + timestamp: entry.timestamp, + prev_hash: entry.prev_hash, + event: entry.event, + }; + + const expectedHash = computeHash(payload); + if (entry.hash !== expectedHash) { + return { + valid: false, + entries: count, + error: `hash mismatch at line ${String(i + 1)} (tampering detected)`, + }; + } + + prevHash = entry.hash; + count++; + } + + return { valid: true, entries: count }; +} + +/** + * Export audit entries with `timestamp >= since`, up to `limit`. + * + * If `limit` is 0 or omitted, all matching entries are returned. + * Skips malformed lines. + */ +export function exportEntries(path: string, since: number, limit?: number): AuditEntry[] { + if (!path || typeof path !== "string") { + throw new Error("exportEntries requires a non-empty file path"); + } + if (typeof since !== "number" || !Number.isFinite(since)) { + throw new Error("since must be a finite number"); + } + + if (!existsSync(path)) { + return []; + } + + const content = readFileSync(path, "utf-8"); + const lines = content.split("\n").filter((line) => line.trim() !== ""); + const result: AuditEntry[] = []; + const effectiveLimit = limit != null && limit > 0 ? limit : 0; + + for (const line of lines) { + let entry: AuditEntry; + try { + entry = JSON.parse(line) as AuditEntry; + } catch { + continue; + } + + if (entry.timestamp < since) { + continue; + } + + result.push(entry); + + if (effectiveLimit > 0 && result.length >= effectiveLimit) { + break; + } + } + + return result; +} + +/** + * Return the last `n` entries from an audit file. + * + * Defaults to 50 when `n` is omitted or non-positive. + * Skips malformed lines. + */ +export function tailEntries(path: string, n?: number): AuditEntry[] { + if (!path || typeof path !== "string") { + throw new Error("tailEntries requires a non-empty file path"); + } + + const effectiveN = n != null && n > 0 ? n : 50; + + if (!existsSync(path)) { + return []; + } + + const content = readFileSync(path, "utf-8"); + const lines = content.split("\n").filter((line) => line.trim() !== ""); + const entries: AuditEntry[] = []; + + for (const line of lines) { + try { + entries.push(JSON.parse(line) as AuditEntry); + } catch { + continue; + } + } + + if (entries.length <= effectiveN) { + return entries; + } + + return entries.slice(entries.length - effectiveN); +} + +// ── Internal helpers ───────────────────────────────────────── + +/** + * Compute the SHA-256 hash of a record using canonical JSON serialization. + * + * Matches Python's `json.dumps(record, separators=(",",":"), sort_keys=True)` + * by recursively sorting object keys and using compact separators. + */ +function computeHash(obj: Record): string { + const canonical = canonicalJsonStringify(obj); + return createHash("sha256").update(canonical, "utf-8").digest("hex"); +} + +/** + * Produce a canonical JSON string with sorted keys and no whitespace. + * Matches Python's `json.dumps(obj, separators=(",",":"), sort_keys=True)`. + */ +function canonicalJsonStringify(value: unknown): string { + if (value === null || value === undefined) { + return "null"; + } + + if (typeof value === "boolean" || typeof value === "number") { + return JSON.stringify(value); + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + const items = value.map((item) => canonicalJsonStringify(item)); + return `[${items.join(",")}]`; + } + + if (typeof value === "object") { + const obj = value as Record; + const sortedKeys = Object.keys(obj).sort(); + const pairs = sortedKeys.map( + (key) => `${JSON.stringify(key)}:${canonicalJsonStringify(obj[key])}`, + ); + return `{${pairs.join(",")}}`; + } + + return JSON.stringify(value); +} From 144feba81c216dcfa9d188fccadcfaa8d5d2b1bb Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 02:31:06 +0300 Subject: [PATCH 2/6] fix(security): add try/catch to readFileSync and improve docstrings Address CodeRabbit review: - exportEntries and tailEntries now wrap readFileSync in try/catch for consistent error handling with verifyChain - Add field-level JSDoc to AuditEntry and VerifyResult interfaces to meet the 80% docstring coverage threshold --- nemoclaw/src/security/audit-verifier.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts index 7ecd144d49..5e58e068ba 100644 --- a/nemoclaw/src/security/audit-verifier.ts +++ b/nemoclaw/src/security/audit-verifier.ts @@ -21,16 +21,23 @@ import { existsSync, readFileSync } from "node:fs"; /** A single audit log entry as written by the Python audit module. */ export interface AuditEntry { + /** Unix epoch timestamp (seconds with fractional milliseconds). */ readonly timestamp: number; + /** SHA-256 hash of the previous entry, or "genesis" for the first entry. */ readonly prev_hash: string; + /** Arbitrary event payload recorded by the orchestrator. */ readonly event: unknown; + /** SHA-256 hash of this entry's canonical JSON representation (excluding hash itself). */ readonly hash: string; } /** Result returned by `verifyChain`. */ export interface VerifyResult { + /** True if the entire chain is valid. */ readonly valid: boolean; + /** Number of valid entries verified before a break (or total if valid). */ readonly entries: number; + /** Human-readable description of the first chain break, if any. */ readonly error?: string; } @@ -140,7 +147,12 @@ export function exportEntries(path: string, since: number, limit?: number): Audi return []; } - const content = readFileSync(path, "utf-8"); + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch { + return []; + } const lines = content.split("\n").filter((line) => line.trim() !== ""); const result: AuditEntry[] = []; const effectiveLimit = limit != null && limit > 0 ? limit : 0; @@ -184,7 +196,12 @@ export function tailEntries(path: string, n?: number): AuditEntry[] { return []; } - const content = readFileSync(path, "utf-8"); + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch { + return []; + } const lines = content.split("\n").filter((line) => line.trim() !== ""); const entries: AuditEntry[] = []; From 4a3dca3c9855a9192f692d393d7f5ce8e73bfee6 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 16:33:51 +0300 Subject: [PATCH 3/6] fix(security): add runtime shape guards for parsed JSON entries Address CodeRabbit review: JSON.parse can return primitives or null. All three functions now validate parsed values are non-null objects with expected AuditEntry fields before use. Added isAuditEntry type guard shared by exportEntries and tailEntries. --- nemoclaw/src/security/audit-verifier.ts | 33 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts index 5e58e068ba..52a9b672c2 100644 --- a/nemoclaw/src/security/audit-verifier.ts +++ b/nemoclaw/src/security/audit-verifier.ts @@ -80,7 +80,15 @@ export function verifyChain(path: string): VerifyResult { for (let i = 0; i < lines.length; i++) { let entry: AuditEntry; try { - entry = JSON.parse(lines[i]) as AuditEntry; + const parsed: unknown = JSON.parse(lines[i]); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { + valid: false, + entries: count, + error: `expected object at line ${String(i + 1)}`, + }; + } + entry = parsed as AuditEntry; } catch { return { valid: false, @@ -160,7 +168,9 @@ export function exportEntries(path: string, since: number, limit?: number): Audi for (const line of lines) { let entry: AuditEntry; try { - entry = JSON.parse(line) as AuditEntry; + const parsed: unknown = JSON.parse(line); + if (!isAuditEntry(parsed)) continue; + entry = parsed; } catch { continue; } @@ -207,7 +217,10 @@ export function tailEntries(path: string, n?: number): AuditEntry[] { for (const line of lines) { try { - entries.push(JSON.parse(line) as AuditEntry); + const parsed: unknown = JSON.parse(line); + if (isAuditEntry(parsed)) { + entries.push(parsed); + } } catch { continue; } @@ -222,6 +235,20 @@ export function tailEntries(path: string, n?: number): AuditEntry[] { // ── Internal helpers ───────────────────────────────────────── +/** Runtime type guard: returns true if value looks like a valid AuditEntry. */ +function isAuditEntry(value: unknown): value is AuditEntry { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const obj = value as Record; + return ( + typeof obj["timestamp"] === "number" && + typeof obj["prev_hash"] === "string" && + typeof obj["hash"] === "string" && + "event" in obj + ); +} + /** * Compute the SHA-256 hash of a record using canonical JSON serialization. * From b76cb8efc266b71b2cc4e285a3101579dee9d040 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 17:22:38 +0300 Subject: [PATCH 4/6] fix(security): escape non-ASCII to match Python ensure_ascii=True Python's json.dumps uses ensure_ascii=True by default, encoding non-ASCII characters as \uXXXX. Node.js JSON.stringify emits raw UTF-8. This mismatch would cause hash drift on events containing non-ASCII text. Added escapeNonAscii helper that post-processes JSON.stringify output to match Python's behavior. Added test verifying cross- language hash parity with non-ASCII event data. --- nemoclaw/src/security/audit-verifier.test.ts | 18 ++++++++++++++++++ nemoclaw/src/security/audit-verifier.ts | 20 +++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/nemoclaw/src/security/audit-verifier.test.ts b/nemoclaw/src/security/audit-verifier.test.ts index 2aae0e6e06..6b9527639a 100644 --- a/nemoclaw/src/security/audit-verifier.test.ts +++ b/nemoclaw/src/security/audit-verifier.test.ts @@ -330,4 +330,22 @@ describe("cross-format verification", () => { const result = verifyChain(auditPath); expect(result).toEqual({ valid: true, entries: 2 }); }); + + it("verifies entries with non-ASCII event data (ensure_ascii parity)", () => { + // Python: json.dumps({"event":{"msg":"héllo"},"prev_hash":"genesis","timestamp":1700000000}, separators=(",",":"), sort_keys=True) + // With ensure_ascii=True (default): {"event":{"msg":"h\\u00e9llo"},"prev_hash":"genesis","timestamp":1700000000} + const payload = '{"event":{"msg":"h\\u00e9llo"},"prev_hash":"genesis","timestamp":1700000000}'; + const hash = sha256(payload); + + const entry: AuditEntry = { + timestamp: 1700000000, + prev_hash: "genesis", + event: { msg: "h\u00e9llo" }, + hash, + }; + + writeFileSync(auditPath, JSON.stringify(entry) + "\n", "utf-8"); + const result = verifyChain(auditPath); + expect(result).toEqual({ valid: true, entries: 1 }); + }); }); diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts index 52a9b672c2..afa49b3860 100644 --- a/nemoclaw/src/security/audit-verifier.ts +++ b/nemoclaw/src/security/audit-verifier.ts @@ -274,7 +274,7 @@ function canonicalJsonStringify(value: unknown): string { } if (typeof value === "string") { - return JSON.stringify(value); + return escapeNonAscii(JSON.stringify(value)); } if (Array.isArray(value)) { @@ -293,3 +293,21 @@ function canonicalJsonStringify(value: unknown): string { return JSON.stringify(value); } + +/** + * Escape non-ASCII characters to \\uXXXX sequences to match Python's + * `json.dumps(ensure_ascii=True)` default behavior. Characters above + * U+FFFF are encoded as surrogate pairs (\\uXXXX\\uXXXX). + */ +function escapeNonAscii(s: string): string { + let result = ""; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code > 0x7f) { + result += `\\u${code.toString(16).padStart(4, "0")}`; + } else { + result += s[i]; + } + } + return result; +} From 2a0392730631db374b961ca14e145081dafc97d0 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 18:39:34 +0300 Subject: [PATCH 5/6] fix(security): escape non-ASCII in object keys too The previous escapeNonAscii fix only covered string values. Object keys also pass through JSON.stringify without non-ASCII escaping, which would cause hash drift if a dict key contains non-ASCII. Now both keys and values use escapeNonAscii for full parity with Python's ensure_ascii=True. --- nemoclaw/src/security/audit-verifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts index afa49b3860..a230863385 100644 --- a/nemoclaw/src/security/audit-verifier.ts +++ b/nemoclaw/src/security/audit-verifier.ts @@ -286,7 +286,7 @@ function canonicalJsonStringify(value: unknown): string { const obj = value as Record; const sortedKeys = Object.keys(obj).sort(); const pairs = sortedKeys.map( - (key) => `${JSON.stringify(key)}:${canonicalJsonStringify(obj[key])}`, + (key) => `${escapeNonAscii(JSON.stringify(key))}:${canonicalJsonStringify(obj[key])}`, ); return `{${pairs.join(",")}}`; } From 5eb92265ce4395e38c1c7dd8983bae582dd349ae Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 19:40:48 +0300 Subject: [PATCH 6/6] fix(security): guard against unsafe integer precision loss Integers beyond Number.MAX_SAFE_INTEGER lose precision in JS but are preserved by Python, causing hash drift. Added a defensive check that throws early if such a value is encountered during canonical serialization rather than silently producing a wrong hash. --- nemoclaw/src/security/audit-verifier.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/nemoclaw/src/security/audit-verifier.ts b/nemoclaw/src/security/audit-verifier.ts index a230863385..80c074e360 100644 --- a/nemoclaw/src/security/audit-verifier.ts +++ b/nemoclaw/src/security/audit-verifier.ts @@ -269,7 +269,22 @@ function canonicalJsonStringify(value: unknown): string { return "null"; } - if (typeof value === "boolean" || typeof value === "number") { + if (typeof value === "boolean") { + return JSON.stringify(value); + } + + if (typeof value === "number") { + // Integers beyond Number.MAX_SAFE_INTEGER lose precision in JavaScript + // but are preserved exactly by Python, causing hash drift. The Python + // audit module only writes timestamps (floats) and small integers, so + // this guard is defensive — it surfaces the problem early if the entry + // format ever includes large numeric IDs. + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new Error( + `unsafe integer ${String(value)} exceeds Number.MAX_SAFE_INTEGER; ` + + "cross-language hash verification cannot guarantee correctness", + ); + } return JSON.stringify(value); }