@crewhaus/tool-result-store 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/tool-result-store",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Persist large tool results to disk and return a preview the model can re-read",
6
6
  "main": "src/index.ts",
@@ -12,15 +12,16 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0",
16
- "@crewhaus/tool-catalog": "0.0.0",
17
- "@crewhaus/tool-executor": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/tenancy": "0.1.2",
17
+ "@crewhaus/tool-catalog": "0.1.2",
18
+ "@crewhaus/tool-executor": "0.1.2"
18
19
  },
19
20
  "license": "Apache-2.0",
20
21
  "author": {
21
22
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
23
+ "email": "max@crewhaus.ai",
24
+ "url": "https://crewhaus.ai"
24
25
  },
25
26
  "repository": {
26
27
  "type": "git",
@@ -32,12 +33,7 @@
32
33
  "url": "https://github.com/crewhaus/factory/issues"
33
34
  },
34
35
  "publishConfig": {
35
- "access": "restricted"
36
+ "access": "public"
36
37
  },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "LICENSE",
41
- "NOTICE"
42
- ]
38
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
43
39
  }
package/src/index.test.ts CHANGED
@@ -2,10 +2,13 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { RuntimeError } from "@crewhaus/errors";
6
+ import { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
5
7
  import type { ToolResult } from "@crewhaus/tool-executor";
6
8
  import {
7
9
  DEFAULT_PREVIEW_LINES,
8
10
  DEFAULT_THRESHOLD_BYTES,
11
+ assertUnderRoot,
9
12
  resolveStoragePath,
10
13
  storeAndPreview,
11
14
  } from "./index";
@@ -53,6 +56,60 @@ describe("storeAndPreview — under threshold", () => {
53
56
  });
54
57
  });
55
58
 
59
+ describe("storeAndPreview — non-string content", () => {
60
+ test("image content-block array bypasses persistence and is forwarded verbatim", async () => {
61
+ const rootDir = newTempRoot();
62
+ // A large base64 payload that would blow past the byte threshold if it
63
+ // were a string — confirms the array short-circuits before any sizing.
64
+ const blocks: ToolResult["content"] = [
65
+ {
66
+ type: "image",
67
+ source: {
68
+ type: "base64",
69
+ media_type: "image/png",
70
+ data: "Q".repeat(DEFAULT_THRESHOLD_BYTES + 10),
71
+ },
72
+ },
73
+ ];
74
+ const out = await storeAndPreview(
75
+ { toolUseId: "tu_img", content: blocks, isError: false },
76
+ {
77
+ runId: "run_img",
78
+ toolUseId: "tu_img",
79
+ rootDir,
80
+ },
81
+ );
82
+ expect(out.persisted).toBe(false);
83
+ expect(out.fullPath).toBeNull();
84
+ // Same reference forwarded unchanged — no copy, no preview wrapping.
85
+ expect(out.previewContent).toBe(blocks);
86
+ // Nothing was written under the run directory.
87
+ expect(() => statSync(join(rootDir, "run_img"))).toThrow();
88
+ });
89
+
90
+ test("mixed text + image blocks are also forwarded as-is", async () => {
91
+ const rootDir = newTempRoot();
92
+ const blocks: ToolResult["content"] = [
93
+ { type: "text", text: "caption" },
94
+ {
95
+ type: "image",
96
+ source: { type: "base64", media_type: "image/jpeg", data: "AAAA" },
97
+ },
98
+ ];
99
+ const out = await storeAndPreview(
100
+ { toolUseId: "tu_mix", content: blocks, isError: false },
101
+ {
102
+ runId: "run_mix",
103
+ toolUseId: "tu_mix",
104
+ rootDir,
105
+ },
106
+ );
107
+ expect(out.persisted).toBe(false);
108
+ expect(out.fullPath).toBeNull();
109
+ expect(out.previewContent).toEqual(blocks);
110
+ });
111
+ });
112
+
56
113
  describe("storeAndPreview — over threshold", () => {
57
114
  test("large content is persisted; preview shows first N lines + marker", async () => {
58
115
  const rootDir = newTempRoot();
@@ -180,4 +237,63 @@ describe("storeAndPreview — path traversal guard", () => {
180
237
  const path = resolveStoragePath("run_x", "tu_y", "/tmp/cr-test");
181
238
  expect(path).toContain("/tmp/cr-test/run_x/tu_y.txt");
182
239
  });
240
+
241
+ test("assertUnderRoot accepts a path strictly under root", () => {
242
+ expect(() => assertUnderRoot("/tmp/cr-root/run/file.txt", "/tmp/cr-root")).not.toThrow();
243
+ });
244
+
245
+ test("assertUnderRoot rejects a path that escapes root (defence-in-depth throw)", () => {
246
+ // Exercises the boundary check directly: a resolved path that does NOT
247
+ // sit under root must throw, even though rejectUnsafeSegment normally
248
+ // makes this unreachable from resolveStoragePath.
249
+ expect(() => assertUnderRoot("/etc/passwd", "/tmp/cr-root")).toThrow(RuntimeError);
250
+ expect(() => assertUnderRoot("/tmp/cr-root-sibling/x", "/tmp/cr-root")).toThrow(
251
+ /escapes rootDir/,
252
+ );
253
+ });
254
+ });
255
+
256
+ describe("storeAndPreview — cross-tenant fencing (CWE-1230)", () => {
257
+ const big = "x".repeat(DEFAULT_THRESHOLD_BYTES + 10);
258
+
259
+ test("inside tenantA, a store rooted under tenantB fails closed", async () => {
260
+ const tenantsRoot = newTempRoot();
261
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
262
+ const tenantB = buildTenant("tenant-b", { tenantsRoot });
263
+ // Persisting under tenantB's toolResultRoot while tenantA is active
264
+ // resolves a path outside tenantA's root, so it fails closed.
265
+ await withTenant(tenantA, async () => {
266
+ await expect(
267
+ storeAndPreview(makeResult(big), {
268
+ runId: "run_x",
269
+ toolUseId: "tu_x",
270
+ rootDir: tenantB.toolResultRoot,
271
+ }),
272
+ ).rejects.toThrow(TenancyError);
273
+ });
274
+ });
275
+
276
+ test("inside tenantA, a store rooted under tenantA persists", async () => {
277
+ const tenantsRoot = newTempRoot();
278
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
279
+ await withTenant(tenantA, async () => {
280
+ const out = await storeAndPreview(makeResult(big), {
281
+ runId: "run_x",
282
+ toolUseId: "tu_x",
283
+ rootDir: tenantA.toolResultRoot,
284
+ });
285
+ expect(out.persisted).toBe(true);
286
+ expect(out.fullPath).toContain(tenantA.toolResultRoot);
287
+ });
288
+ });
289
+
290
+ test("no active tenant — behaviour is unchanged (no fencing)", async () => {
291
+ const rootDir = newTempRoot();
292
+ const out = await storeAndPreview(makeResult(big), {
293
+ runId: "run_x",
294
+ toolUseId: "tu_x",
295
+ rootDir,
296
+ });
297
+ expect(out.persisted).toBe(true);
298
+ });
183
299
  });
package/src/index.ts CHANGED
@@ -26,9 +26,19 @@
26
26
  import { mkdir, writeFile } from "node:fs/promises";
27
27
  import { dirname, join, resolve, sep } from "node:path";
28
28
  import { RuntimeError } from "@crewhaus/errors";
29
+ import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
29
30
  import type { ToolExecuteResult } from "@crewhaus/tool-catalog";
30
31
  import type { ToolResult } from "@crewhaus/tool-executor";
31
32
 
33
+ // When a tenant context is active, fail closed on a resolved storage path that
34
+ // escapes the tenant's toolResultRoot (CWE-1230). Outside a tenant scope (the
35
+ // common CLI case) this is a no-op so non-tenant behaviour is unchanged.
36
+ function fence(absPath: string): void {
37
+ if (currentTenantContext() !== undefined) {
38
+ assertSamePath(absPath, requireTenant().toolResultRoot);
39
+ }
40
+ }
41
+
32
42
  export type StoreOptions = {
33
43
  readonly runId: string;
34
44
  readonly toolUseId: string;
@@ -79,6 +89,7 @@ export async function storeAndPreview(
79
89
  rejectUnsafeSegment("toolUseId", opts.toolUseId);
80
90
 
81
91
  const fullPath = join(rootDir, opts.runId, `${opts.toolUseId}.txt`);
92
+ fence(resolve(fullPath));
82
93
  await mkdir(dirname(fullPath), { recursive: true });
83
94
  try {
84
95
  await writeFile(fullPath, result.content, { flag: "wx" });
@@ -112,6 +123,19 @@ function rejectUnsafeSegment(label: string, value: string): void {
112
123
  }
113
124
  }
114
125
 
126
+ /**
127
+ * Defence-in-depth boundary check: assert that a resolved absolute path lives
128
+ * strictly under `root`. Redundant with `rejectUnsafeSegment` on the normal
129
+ * code path (which already strips separators and `..`), but kept — and exported
130
+ * so it can be exercised directly — so a future caller that resolves a path
131
+ * some other way can't silently escape the storage root.
132
+ */
133
+ export function assertUnderRoot(abs: string, root: string): void {
134
+ if (!abs.startsWith(`${root}${sep}`)) {
135
+ throw new RuntimeError("tool-result-store: resolved path escapes rootDir");
136
+ }
137
+ }
138
+
115
139
  /**
116
140
  * Used by tests to confirm the resolved storage location for a given
117
141
  * runId/toolUseId pair. Performs the same traversal rejection as
@@ -126,9 +150,7 @@ export function resolveStoragePath(
126
150
  rejectUnsafeSegment("toolUseId", toolUseId);
127
151
  const abs = resolve(rootDir, runId, `${toolUseId}.txt`);
128
152
  // Sanity: the resolved path must still live under rootDir.
129
- const root = resolve(rootDir);
130
- if (!abs.startsWith(`${root}${sep}`)) {
131
- throw new RuntimeError("tool-result-store: resolved path escapes rootDir");
132
- }
153
+ assertUnderRoot(abs, resolve(rootDir));
154
+ fence(abs);
133
155
  return abs;
134
156
  }