@crewhaus/event-log 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/event-log",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Append-only JSONL transcript log per session",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,14 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/tenancy": "0.1.2"
16
17
  },
17
18
  "license": "Apache-2.0",
18
19
  "author": {
19
20
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",
@@ -30,12 +31,7 @@
30
31
  "url": "https://github.com/crewhaus/factory/issues"
31
32
  },
32
33
  "publishConfig": {
33
- "access": "restricted"
34
+ "access": "public"
34
35
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
37
  }
package/src/index.test.ts CHANGED
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
5
6
  import { type Event, openEventLog } from "./index";
6
7
 
7
8
  const TMP_ROOTS: string[] = [];
@@ -170,3 +171,92 @@ describe("event-log — T7 load", () => {
170
171
  expect(readMs).toBeLessThan(5_000);
171
172
  }, 30_000);
172
173
  });
174
+
175
+ describe("event-log — cross-tenant fencing (CWE-1230)", () => {
176
+ test("inside tenantA, a log rooted under tenantB fails closed", async () => {
177
+ const tenantsRoot = newTempRoot();
178
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
179
+ const tenantB = buildTenant("tenant-b", { tenantsRoot });
180
+ // Opening a log rooted under tenantB while tenantA is active resolves a
181
+ // path outside tenantA's sessionRoot, so it fails closed.
182
+ await expect(
183
+ withTenant(tenantA, () => openEventLog(TEST_ID, { rootDir: tenantB.sessionRoot })),
184
+ ).rejects.toThrow(TenancyError);
185
+ });
186
+
187
+ test("inside tenantA, a log rooted under tenantA round-trips", async () => {
188
+ const tenantsRoot = newTempRoot();
189
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
190
+ await withTenant(tenantA, async () => {
191
+ const log = await openEventLog(TEST_ID, { rootDir: tenantA.sessionRoot });
192
+ await log.append({ kind: "user_message", payload: { ok: true } });
193
+ const all = await collect(log.read());
194
+ expect(all.length).toBe(1);
195
+ });
196
+ });
197
+
198
+ test("no active tenant — behaviour is unchanged (no fencing)", async () => {
199
+ const rootDir = newTempRoot();
200
+ const log = await openEventLog(TEST_ID, { rootDir });
201
+ await log.append({ kind: "user_message", payload: { ok: true } });
202
+ const all = await collect(log.read());
203
+ expect(all.length).toBe(1);
204
+ });
205
+ });
206
+
207
+ describe("event-log — security invariants", () => {
208
+ // The header documents owner-only (0o600) append semantics, mirroring the
209
+ // claude-code sessionStorage precedent. Pin it down so a regression that
210
+ // drops `{ mode: 0o600 }` (widening the transcript to group/other) fails.
211
+ test("the JSONL file is created without group/other access", async () => {
212
+ const rootDir = newTempRoot();
213
+ const log = await openEventLog(TEST_ID, { rootDir });
214
+ await log.append({ kind: "user_message", payload: { secret: "transcript" } });
215
+ const mode = statSync(join(rootDir, `${TEST_ID}.jsonl`)).mode & 0o777;
216
+ // No bits set for group (0o070) or other (0o007).
217
+ expect(mode & 0o077).toBe(0);
218
+ });
219
+
220
+ // A hostile/corrupt log line carrying a `__proto__` key must round-trip as
221
+ // plain data and must NOT pollute Object.prototype (CWE-1321). readEvents
222
+ // only `JSON.parse`s + yields; it never merges into an existing object, so
223
+ // there is no pollution sink — this guards against a future refactor adding
224
+ // one.
225
+ test("a __proto__ payload does not pollute Object.prototype", async () => {
226
+ const rootDir = newTempRoot();
227
+ const log = await openEventLog(TEST_ID, { rootDir });
228
+ await log.append({ kind: "user_message", payload: {} });
229
+ writeFileSync(
230
+ join(rootDir, `${TEST_ID}.jsonl`),
231
+ `${JSON.stringify({
232
+ ts: 1,
233
+ version: 1,
234
+ kind: "user_message",
235
+ payload: JSON.parse('{"__proto__":{"polluted":true}}'),
236
+ })}\n`,
237
+ );
238
+ const all = await collect(log.read());
239
+ expect(all.length).toBe(1);
240
+ // The global prototype must remain unpolluted for everyone else.
241
+ expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
242
+ expect((Object.prototype as Record<string, unknown>)["polluted"]).toBeUndefined();
243
+ });
244
+
245
+ // The sessionId becomes part of the on-disk path; only `sess_<16 hex>` is
246
+ // accepted, so traversal / absolute-path / NUL injection in the id can never
247
+ // reach the filesystem (CWE-22).
248
+ test("rejects traversal, separators, and NUL in the session id", async () => {
249
+ const rootDir = newTempRoot();
250
+ for (const bad of [
251
+ "sess_../../etc/passwd",
252
+ "sess_0123456789abcde/", // 15 hex + slash
253
+ "sess_0123456789abcdeg", // non-hex char
254
+ "sess_0123456789ABCDEF", // uppercase hex not allowed
255
+ "sess_0123456789abcdef0", // 17 hex (too long)
256
+ "sess_000000000000",
257
+ "../escape",
258
+ ]) {
259
+ await expect(openEventLog(bad, { rootDir })).rejects.toThrow(/invalid sessionId/);
260
+ }
261
+ });
262
+ });
package/src/index.ts CHANGED
@@ -25,9 +25,10 @@
25
25
  * `AI-Harness-Systems.md` §append-only event history.
26
26
  */
27
27
  import { appendFileSync, createReadStream, existsSync, mkdirSync } from "node:fs";
28
- import { join } from "node:path";
28
+ import { join, resolve } from "node:path";
29
29
  import { createInterface } from "node:readline";
30
30
  import { RuntimeError } from "@crewhaus/errors";
31
+ import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
31
32
 
32
33
  export const DEFAULT_ROOT_DIR = ".crewhaus/sessions";
33
34
  const ID_REGEX = /^sess_[0-9a-f]{16}$/;
@@ -98,11 +99,22 @@ export async function openEventLog(
98
99
  validateId(sessionId);
99
100
  const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
100
101
  const now = opts.now ?? (() => Date.now());
101
- const fullPath = join(rootDir, `${sessionId}.jsonl`);
102
+ const fullPath = resolve(rootDir, `${sessionId}.jsonl`);
102
103
  mkdirSync(rootDir, { recursive: true });
103
104
 
105
+ // When a tenant context is active, fail closed on a resolved path that
106
+ // escapes the tenant's sessionRoot (CWE-1230). Outside a tenant scope (the
107
+ // common CLI case) this is a no-op so non-tenant behaviour is unchanged.
108
+ function fence(): void {
109
+ if (currentTenantContext() !== undefined) {
110
+ assertSamePath(fullPath, requireTenant().sessionRoot);
111
+ }
112
+ }
113
+ fence();
114
+
104
115
  return {
105
116
  async append(event: AppendEvent): Promise<void> {
117
+ fence();
106
118
  const wire: Event = {
107
119
  ts: now(),
108
120
  version: 1,
@@ -114,6 +126,7 @@ export async function openEventLog(
114
126
  },
115
127
 
116
128
  read(readOpts: { since?: number; until?: number } = {}): AsyncIterable<Event> {
129
+ fence();
117
130
  return readEvents(fullPath, readOpts);
118
131
  },
119
132