@crewhaus/audit-log 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/audit-log",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Per-tenant hash-chained JSONL audit trail for the managed-daemon target",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/audit-log"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/audit-log#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type AuditLog, GENESIS_HASH, openAuditLog, verify } from "./index";
6
+
7
+ let tmp: string;
8
+
9
+ beforeEach(() => {
10
+ tmp = mkdtempSync(join(tmpdir(), "audit-log-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tmp, { recursive: true, force: true });
15
+ });
16
+
17
+ const fixedNow = (start = 1_700_000_000_000): (() => number) => {
18
+ let t = start;
19
+ return () => {
20
+ t += 1;
21
+ return t;
22
+ };
23
+ };
24
+
25
+ const FIXED_DAY = "2026-05-08";
26
+
27
+ async function makeLog(): Promise<AuditLog> {
28
+ return openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY });
29
+ }
30
+
31
+ describe("append + read", () => {
32
+ test("first record uses GENESIS as prevHash", async () => {
33
+ const log = await makeLog();
34
+ const r = await log.append({ kind: "gateway_request", payload: { p: 1 } });
35
+ expect(r.prevHash).toBe(GENESIS_HASH);
36
+ expect(r.hash).toMatch(/^[0-9a-f]{64}$/);
37
+ });
38
+
39
+ test("subsequent records chain to the previous hash", async () => {
40
+ const log = await makeLog();
41
+ const a = await log.append({ kind: "model_call", payload: 1 });
42
+ const b = await log.append({ kind: "model_call", payload: 2 });
43
+ expect(b.prevHash).toBe(a.hash);
44
+ });
45
+
46
+ test("read yields records in append order", async () => {
47
+ const log = await makeLog();
48
+ await log.append({ kind: "model_call", payload: "a" });
49
+ await log.append({ kind: "model_call", payload: "b" });
50
+ const out: unknown[] = [];
51
+ for await (const r of log.read()) out.push(r.payload);
52
+ expect(out).toEqual(["a", "b"]);
53
+ });
54
+ });
55
+
56
+ describe("verify (T4)", () => {
57
+ test("clean chain reports ok=true", async () => {
58
+ const log = await makeLog();
59
+ for (let i = 0; i < 10; i += 1) {
60
+ await log.append({ kind: "policy_decision", payload: { i } });
61
+ }
62
+ const r = await verify(tmp);
63
+ expect(r.ok).toBe(true);
64
+ if (r.ok) expect(r.recordsChecked).toBe(10);
65
+ });
66
+
67
+ test("empty rootDir reports ok=true with 0 records", async () => {
68
+ const r = await verify(join(tmp, "missing"));
69
+ expect(r.ok).toBe(true);
70
+ if (r.ok) expect(r.recordsChecked).toBe(0);
71
+ });
72
+
73
+ test("tampering with one byte of payload breaks the chain on that line", async () => {
74
+ const log = await makeLog();
75
+ await log.append({ kind: "model_call", payload: "first" });
76
+ await log.append({ kind: "model_call", payload: "second" });
77
+ await log.append({ kind: "model_call", payload: "third" });
78
+
79
+ // Read the file and rewrite line 2 with a payload byte flipped.
80
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
81
+ const lines = readFileSync(file, "utf8")
82
+ .split("\n")
83
+ .filter((l) => l !== "");
84
+ const second = JSON.parse(lines[1] as string);
85
+ // Tamper the payload but leave hash + prevHash intact — the chain
86
+ // verification recomputes the body hash and notices the divergence.
87
+ second.payload = "tampered";
88
+ lines[1] = JSON.stringify(second);
89
+ writeFileSync(file, `${lines.join("\n")}\n`);
90
+
91
+ const r = await verify(tmp);
92
+ expect(r.ok).toBe(false);
93
+ if (!r.ok) {
94
+ expect(r.line).toBe(2);
95
+ expect(r.reason).toMatch(/hash mismatch/);
96
+ }
97
+ });
98
+
99
+ test("tampering with prevHash to point at a sibling reports prevHash mismatch", async () => {
100
+ const log = await makeLog();
101
+ await log.append({ kind: "model_call", payload: 1 });
102
+ await log.append({ kind: "model_call", payload: 2 });
103
+
104
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
105
+ const lines = readFileSync(file, "utf8")
106
+ .split("\n")
107
+ .filter((l) => l !== "");
108
+ const second = JSON.parse(lines[1] as string);
109
+ // Replace prevHash with a different (but well-formed) hash.
110
+ second.prevHash = "0".repeat(64);
111
+ lines[1] = JSON.stringify(second);
112
+ writeFileSync(file, `${lines.join("\n")}\n`);
113
+
114
+ const r = await verify(tmp);
115
+ expect(r.ok).toBe(false);
116
+ if (!r.ok) {
117
+ expect(r.reason).toMatch(/prevHash mismatch/);
118
+ }
119
+ });
120
+
121
+ test("malformed JSON line reports the line number", async () => {
122
+ const log = await makeLog();
123
+ await log.append({ kind: "model_call", payload: 1 });
124
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
125
+ writeFileSync(file, `${readFileSync(file, "utf8")}{not-json\n`);
126
+ const r = await verify(tmp);
127
+ expect(r.ok).toBe(false);
128
+ if (!r.ok) {
129
+ expect(r.line).toBe(2);
130
+ expect(r.reason).toMatch(/malformed JSON/);
131
+ }
132
+ });
133
+ });
134
+
135
+ describe("file mode (T8)", () => {
136
+ test("audit log files are created with owner-only permissions", async () => {
137
+ const log = await makeLog();
138
+ await log.append({ kind: "policy_decision", payload: {} });
139
+ const file = join(tmp, `${FIXED_DAY}.jsonl`);
140
+ const stat = await import("node:fs").then((m) => m.statSync(file));
141
+ // Mask off file-type bits and check the lower 9 (rwx for u/g/o).
142
+ const mode = stat.mode & 0o777;
143
+ expect(mode).toBe(0o600);
144
+ });
145
+ });
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Catalog R17 `audit-log` — per-tenant hash-chained append-only JSONL.
3
+ *
4
+ * Daily file rotation: `<auditRoot>/<YYYY-MM-DD>.jsonl`. Every line is
5
+ * a self-describing record with a SHA-256 hash that includes the
6
+ * previous line's hash, forming a tamper-evident chain:
7
+ *
8
+ * {
9
+ * ts: <ms epoch>, version: 1, kind: "policy_decision" | …,
10
+ * payload: <opaque JSON>,
11
+ * prevHash: <hex of prior line, or "GENESIS">,
12
+ * hash: <SHA-256(prevHash || JSON.stringify({ts,version,kind,payload}))>
13
+ * }
14
+ *
15
+ * `verify(rootDir)` walks the chain and reports the first broken link
16
+ * (line number + reason). The chain is per-day; the previous day's
17
+ * tail seeds the next day's `prevHash` via a one-line index file.
18
+ *
19
+ * Files are created with mode 0o600 (owner-only) so the audit trail
20
+ * cannot be read by other users on the host. Append uses
21
+ * `appendFileSync` which is atomic per line on POSIX when the line
22
+ * fits in `PIPE_BUF`, mirroring `event-log`'s contract.
23
+ *
24
+ * Layer R17. Pairs with `tenancy` (R17) and `gateway-server` (R16).
25
+ */
26
+
27
+ import { createHash } from "node:crypto";
28
+ import {
29
+ appendFileSync,
30
+ createReadStream,
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ readdirSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { join } from "node:path";
38
+ import { createInterface } from "node:readline";
39
+ import { CrewhausError, RuntimeError } from "@crewhaus/errors";
40
+
41
+ export const GENESIS_HASH = "GENESIS";
42
+
43
+ export type AuditKind =
44
+ | "policy_decision"
45
+ | "model_call"
46
+ | "tool_classification"
47
+ | "gateway_request"
48
+ | "session_fork"
49
+ | "tenancy_context"
50
+ // Section 27 — secrets-manager rotation + access events
51
+ | "secrets_rotation"
52
+ | "secrets_access"
53
+ // Section 28 — deployment-controller actions
54
+ | "deployment_action"
55
+ // Pillar 3 sink-side fabric — egress-classifier emits one event per
56
+ // external-tool invocation. Payload shape (informally; opaque JSON):
57
+ // { sinkId, sinkScope, verdict, originsFound, matchCount, originStack }
58
+ // The raw outbound payload is NEVER stored verbatim — only the lineage
59
+ // summary. The egress-classifier's `summarizeEgress(result)` produces
60
+ // the human-readable form that lands in the `payload_summary` field.
61
+ | "egress_decision"
62
+ // Pillar 3 intent gate — permission-engine emits one event per
63
+ // justification-evaluated tool call. Payload shape (opaque JSON):
64
+ // { toolName, justification, verdict: "allow"|"deny", reason, judgeModel }
65
+ // Stored verbatim because the justification IS the audit artifact;
66
+ // redacting it would defeat the purpose.
67
+ | "permission_justification_evaluated";
68
+
69
+ export type AuditRecord = {
70
+ readonly ts: number;
71
+ readonly version: 1;
72
+ readonly kind: AuditKind;
73
+ readonly payload: unknown;
74
+ readonly prevHash: string;
75
+ readonly hash: string;
76
+ };
77
+
78
+ export type AppendInput = Pick<AuditRecord, "kind" | "payload">;
79
+
80
+ export class AuditLogError extends CrewhausError {
81
+ override readonly name = "AuditLogError";
82
+ constructor(message: string, cause?: unknown) {
83
+ super("config", message, cause);
84
+ }
85
+ }
86
+
87
+ export type OpenAuditLogOptions = {
88
+ readonly rootDir: string;
89
+ readonly now?: () => number;
90
+ readonly day?: () => string;
91
+ };
92
+
93
+ export interface AuditLog {
94
+ append(input: AppendInput): Promise<AuditRecord>;
95
+ read(opts?: { readonly day?: string }): AsyncIterable<AuditRecord>;
96
+ }
97
+
98
+ function hashBody(
99
+ body: { ts: number; version: 1; kind: AuditKind; payload: unknown },
100
+ prevHash: string,
101
+ ): string {
102
+ return createHash("sha256")
103
+ .update(prevHash)
104
+ .update("|")
105
+ .update(JSON.stringify(body))
106
+ .digest("hex");
107
+ }
108
+
109
+ function todayStr(): string {
110
+ return new Date().toISOString().slice(0, 10);
111
+ }
112
+
113
+ function indexPath(rootDir: string): string {
114
+ return join(rootDir, "_chain-tail.json");
115
+ }
116
+
117
+ type ChainTail = { readonly day: string; readonly hash: string };
118
+
119
+ function readChainTail(rootDir: string): ChainTail | undefined {
120
+ const p = indexPath(rootDir);
121
+ if (!existsSync(p)) return undefined;
122
+ return JSON.parse(readFileSync(p, "utf8")) as ChainTail;
123
+ }
124
+
125
+ function writeChainTail(rootDir: string, day: string, hash: string): void {
126
+ const p = indexPath(rootDir);
127
+ writeFileSync(p, JSON.stringify({ day, hash }), { mode: 0o600 });
128
+ }
129
+
130
+ export async function openAuditLog(opts: OpenAuditLogOptions): Promise<AuditLog> {
131
+ if (typeof opts.rootDir !== "string" || opts.rootDir === "") {
132
+ throw new AuditLogError("rootDir is required");
133
+ }
134
+ mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
135
+ const now = opts.now ?? ((): number => Date.now());
136
+ const day = opts.day ?? todayStr;
137
+
138
+ return {
139
+ async append(input: AppendInput): Promise<AuditRecord> {
140
+ const tail = readChainTail(opts.rootDir);
141
+ const prevHash = tail?.hash ?? GENESIS_HASH;
142
+ const body = { ts: now(), version: 1 as const, kind: input.kind, payload: input.payload };
143
+ const hash = hashBody(body, prevHash);
144
+ const record: AuditRecord = { ...body, prevHash, hash };
145
+ const file = join(opts.rootDir, `${day()}.jsonl`);
146
+ appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
147
+ writeChainTail(opts.rootDir, day(), hash);
148
+ return record;
149
+ },
150
+ read(readOpts: { day?: string } = {}): AsyncIterable<AuditRecord> {
151
+ return readDay(opts.rootDir, readOpts.day ?? day());
152
+ },
153
+ };
154
+ }
155
+
156
+ async function* readDay(rootDir: string, day: string): AsyncIterable<AuditRecord> {
157
+ const file = join(rootDir, `${day}.jsonl`);
158
+ if (!existsSync(file)) return;
159
+ const stream = createReadStream(file, { encoding: "utf8" });
160
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
161
+ let lineNumber = 0;
162
+ try {
163
+ for await (const raw of rl) {
164
+ lineNumber += 1;
165
+ if (raw === "") continue;
166
+ let parsed: AuditRecord;
167
+ try {
168
+ parsed = JSON.parse(raw) as AuditRecord;
169
+ } catch (err) {
170
+ throw new RuntimeError(`audit-log: malformed JSON on line ${lineNumber} of ${file}`, err);
171
+ }
172
+ yield parsed;
173
+ }
174
+ } finally {
175
+ rl.close();
176
+ stream.close();
177
+ }
178
+ }
179
+
180
+ export type VerifyResult =
181
+ | { readonly ok: true; readonly recordsChecked: number }
182
+ | {
183
+ readonly ok: false;
184
+ readonly recordsChecked: number;
185
+ readonly file: string;
186
+ readonly line: number;
187
+ readonly reason: string;
188
+ };
189
+
190
+ /**
191
+ * Walk every `<rootDir>/*.jsonl` chain, verifying each record's
192
+ * `hash` against `SHA-256(prevHash || canonical-body)` and that
193
+ * `prevHash` matches the previous record's `hash`. Returns the first
194
+ * broken link (file + line number + reason) or `{ ok: true }` if the
195
+ * full chain is intact.
196
+ */
197
+ export async function verify(rootDir: string): Promise<VerifyResult> {
198
+ if (!existsSync(rootDir)) return { ok: true, recordsChecked: 0 };
199
+ const files = readdirSync(rootDir)
200
+ .filter((f) => f.endsWith(".jsonl"))
201
+ .sort();
202
+ let prevHash = GENESIS_HASH;
203
+ let recordsChecked = 0;
204
+ for (const f of files) {
205
+ const file = join(rootDir, f);
206
+ const stream = createReadStream(file, { encoding: "utf8" });
207
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
208
+ let lineNumber = 0;
209
+ try {
210
+ for await (const raw of rl) {
211
+ lineNumber += 1;
212
+ if (raw === "") continue;
213
+ let r: AuditRecord;
214
+ try {
215
+ r = JSON.parse(raw) as AuditRecord;
216
+ } catch (err) {
217
+ stream.close();
218
+ rl.close();
219
+ return {
220
+ ok: false,
221
+ recordsChecked,
222
+ file,
223
+ line: lineNumber,
224
+ reason: `malformed JSON: ${(err as Error).message}`,
225
+ };
226
+ }
227
+ if (r.prevHash !== prevHash) {
228
+ stream.close();
229
+ rl.close();
230
+ return {
231
+ ok: false,
232
+ recordsChecked,
233
+ file,
234
+ line: lineNumber,
235
+ reason: `prevHash mismatch — expected "${prevHash}", got "${r.prevHash}"`,
236
+ };
237
+ }
238
+ const body = { ts: r.ts, version: r.version, kind: r.kind, payload: r.payload };
239
+ const expected = hashBody(body, r.prevHash);
240
+ if (r.hash !== expected) {
241
+ stream.close();
242
+ rl.close();
243
+ return {
244
+ ok: false,
245
+ recordsChecked,
246
+ file,
247
+ line: lineNumber,
248
+ reason: `hash mismatch — expected "${expected}", got "${r.hash}"`,
249
+ };
250
+ }
251
+ prevHash = r.hash;
252
+ recordsChecked += 1;
253
+ }
254
+ } finally {
255
+ rl.close();
256
+ stream.close();
257
+ }
258
+ }
259
+ return { ok: true, recordsChecked };
260
+ }