@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 +9 -13
- package/src/index.test.ts +116 -0
- package/src/index.ts +26 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-result-store",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
16
|
-
"@crewhaus/
|
|
17
|
-
"@crewhaus/tool-
|
|
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@
|
|
23
|
-
"url": "https://
|
|
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": "
|
|
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
|
-
|
|
130
|
-
|
|
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
|
}
|