@crewhaus/event-log 0.1.1 → 0.1.3
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 +7 -11
- package/src/index.test.ts +90 -0
- package/src/index.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/event-log",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.3",
|
|
16
|
+
"@crewhaus/tenancy": "0.1.3"
|
|
16
17
|
},
|
|
17
18
|
"license": "Apache-2.0",
|
|
18
19
|
"author": {
|
|
19
20
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
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": "
|
|
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 =
|
|
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
|
|