@crewhaus/tool-image 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 +49 -0
- package/src/index.test.ts +145 -0
- package/src/index.ts +144 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-image",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ReadImage tool — validates magic bytes and returns Anthropic base64 image content blocks",
|
|
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
|
+
"@crewhaus/tool-builder": "0.0.0",
|
|
17
|
+
"@crewhaus/tool-catalog": "0.0.0",
|
|
18
|
+
"zod": "^3.23.8"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@crewhaus/tool-executor": "0.0.0",
|
|
22
|
+
"@crewhaus/tool-permission-matcher": "0.0.0",
|
|
23
|
+
"@crewhaus/tool-validate": "0.0.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "Max Meier",
|
|
28
|
+
"email": "max@studiomax.io",
|
|
29
|
+
"url": "https://studiomax.io"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
34
|
+
"directory": "packages/tool-image"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-image#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "restricted"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"NOTICE"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ToolPermissionError, detectMediaType, readImage } from "./index";
|
|
6
|
+
|
|
7
|
+
// 1×1 transparent PNG — minimal valid PNG. Hand-encoded to avoid pulling in
|
|
8
|
+
// a generator dep just for tests.
|
|
9
|
+
const TINY_PNG = Buffer.from([
|
|
10
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
11
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
12
|
+
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
13
|
+
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
14
|
+
0x42, 0x60, 0x82,
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const TINY_JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
|
|
18
|
+
const TINY_GIF = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
|
19
|
+
const TINY_WEBP = Buffer.from([
|
|
20
|
+
0x52, 0x49, 0x46, 0x46, 0x1a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
|
|
21
|
+
]);
|
|
22
|
+
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]); // %PDF-1.4
|
|
23
|
+
|
|
24
|
+
let tmp: string;
|
|
25
|
+
let originalCwd: string;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
originalCwd = process.cwd();
|
|
28
|
+
tmp = mkdtempSync(join(tmpdir(), "tool-image-"));
|
|
29
|
+
process.chdir(tmp);
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
process.chdir(originalCwd);
|
|
33
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("ReadImage — registered tool metadata", () => {
|
|
37
|
+
test("name + flags", () => {
|
|
38
|
+
expect(readImage.name).toBe("ReadImage");
|
|
39
|
+
expect(readImage.readOnly).toBe(true);
|
|
40
|
+
expect(readImage.destructive).toBe(false);
|
|
41
|
+
expect(readImage.concurrencySafe).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("detectMediaType — magic bytes", () => {
|
|
46
|
+
test("recognises PNG", () => {
|
|
47
|
+
expect(detectMediaType(new Uint8Array(TINY_PNG))).toBe("image/png");
|
|
48
|
+
});
|
|
49
|
+
test("recognises JPEG", () => {
|
|
50
|
+
expect(detectMediaType(new Uint8Array(TINY_JPEG_HEADER))).toBe("image/jpeg");
|
|
51
|
+
});
|
|
52
|
+
test("recognises GIF", () => {
|
|
53
|
+
expect(detectMediaType(new Uint8Array(TINY_GIF))).toBe("image/gif");
|
|
54
|
+
});
|
|
55
|
+
test("recognises WebP", () => {
|
|
56
|
+
expect(detectMediaType(new Uint8Array(TINY_WEBP))).toBe("image/webp");
|
|
57
|
+
});
|
|
58
|
+
test("returns null for an unknown signature", () => {
|
|
59
|
+
expect(detectMediaType(new Uint8Array(PDF_MAGIC))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
test("returns null for too-short input", () => {
|
|
62
|
+
expect(detectMediaType(new Uint8Array([0x89, 0x50]))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("ReadImage — happy path", () => {
|
|
67
|
+
test("returns an Anthropic image content block for a PNG", async () => {
|
|
68
|
+
writeFileSync(join(tmp, "tiny.png"), TINY_PNG);
|
|
69
|
+
const result = await readImage.execute({ path: "./tiny.png" });
|
|
70
|
+
expect(Array.isArray(result)).toBe(true);
|
|
71
|
+
if (typeof result === "string") throw new Error("expected content array");
|
|
72
|
+
expect(result.length).toBe(1);
|
|
73
|
+
const block = result[0];
|
|
74
|
+
if (block?.type !== "image") throw new Error("expected image block");
|
|
75
|
+
expect(block.source.type).toBe("base64");
|
|
76
|
+
expect(block.source.media_type).toBe("image/png");
|
|
77
|
+
// base64 round-trips back to the original bytes.
|
|
78
|
+
expect(Buffer.from(block.source.data, "base64").equals(TINY_PNG)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("works without leading ./", async () => {
|
|
82
|
+
writeFileSync(join(tmp, "tiny.png"), TINY_PNG);
|
|
83
|
+
const result = await readImage.execute({ path: "tiny.png" });
|
|
84
|
+
if (typeof result === "string") throw new Error("expected content array");
|
|
85
|
+
const block = result[0];
|
|
86
|
+
if (block?.type !== "image") throw new Error("expected image block");
|
|
87
|
+
expect(block.source.media_type).toBe("image/png");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("T8 — path traversal", () => {
|
|
92
|
+
test("rejects ../../etc/passwd", async () => {
|
|
93
|
+
await expect(readImage.execute({ path: "../../etc/passwd" })).rejects.toBeInstanceOf(
|
|
94
|
+
ToolPermissionError,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects an absolute path outside cwd", async () => {
|
|
99
|
+
await expect(readImage.execute({ path: "/etc/passwd" })).rejects.toBeInstanceOf(
|
|
100
|
+
ToolPermissionError,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("rejects a path that resolves outside cwd via ..", async () => {
|
|
105
|
+
await expect(readImage.execute({ path: "subdir/../../escape.png" })).rejects.toBeInstanceOf(
|
|
106
|
+
ToolPermissionError,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("T8 — magic-byte spoof", () => {
|
|
112
|
+
test("rejects a PDF renamed to .png", async () => {
|
|
113
|
+
writeFileSync(join(tmp, "evil.png"), PDF_MAGIC);
|
|
114
|
+
await expect(readImage.execute({ path: "./evil.png" })).rejects.toThrow(
|
|
115
|
+
/unrecognized image format/,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects a tiny text file with a .jpg extension", async () => {
|
|
120
|
+
writeFileSync(join(tmp, "fake.jpg"), Buffer.from("hello world"));
|
|
121
|
+
await expect(readImage.execute({ path: "./fake.jpg" })).rejects.toThrow(
|
|
122
|
+
/unrecognized image format/,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("T8 — oversize cap", () => {
|
|
128
|
+
test("rejects a file over 5 MB", async () => {
|
|
129
|
+
// Build a "PNG" larger than 5 MB by padding the valid header with zero bytes.
|
|
130
|
+
// Magic-bytes check happens AFTER the size check, so this test specifically
|
|
131
|
+
// exercises the size cap.
|
|
132
|
+
const big = Buffer.alloc(6 * 1024 * 1024);
|
|
133
|
+
TINY_PNG.copy(big, 0);
|
|
134
|
+
writeFileSync(join(tmp, "huge.png"), big);
|
|
135
|
+
await expect(readImage.execute({ path: "./huge.png" })).rejects.toThrow(/exceeds/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("T8 — missing file", () => {
|
|
140
|
+
test("rejects a path that does not exist", async () => {
|
|
141
|
+
await expect(readImage.execute({ path: "./nope.png" })).rejects.toBeInstanceOf(
|
|
142
|
+
ToolPermissionError,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
3
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
4
|
+
import type { RegisteredTool, ToolResultContent } from "@crewhaus/tool-catalog";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Section 14 — `ReadImage(path)`. Loads an image from the workspace and
|
|
9
|
+
* returns an Anthropic `image` content block (base64) so the model can
|
|
10
|
+
* actually see the image, not a base64 string of it.
|
|
11
|
+
*
|
|
12
|
+
* Defenses:
|
|
13
|
+
* - Path resolved against `process.cwd()` and rejected if it escapes.
|
|
14
|
+
* Mirrors `tool-fs`'s `resolveSafe` — duplicated here rather than
|
|
15
|
+
* extracting to a shared util because there are only two consumers.
|
|
16
|
+
* - Magic-byte validation on the first 12 bytes — rejects extension
|
|
17
|
+
* spoofing (e.g. `evil.png` whose actual content is a PDF).
|
|
18
|
+
* - 5 MB per-image cap.
|
|
19
|
+
*
|
|
20
|
+
* Deferred (Section 14.5 / 15): a 20-images-per-turn cap. Today there is
|
|
21
|
+
* no per-turn state shared between tools and runtime — `ToolExecuteContext`
|
|
22
|
+
* exposes `signal` + opaque `bridge` only. When `RunContext.turnMetrics`
|
|
23
|
+
* lands we will gate further calls on `imageCount >= 20`.
|
|
24
|
+
*
|
|
25
|
+
* Layer R4. Pairs with the `target-cli` codegen contract — `BUILTIN_TOOL_MAP`
|
|
26
|
+
* has `readImage: { package: "@crewhaus/tool-image", export: "readImage" }`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
export class ToolPermissionError extends CrewhausError {
|
|
32
|
+
override readonly name = "ToolPermissionError";
|
|
33
|
+
readonly toolName: string;
|
|
34
|
+
readonly path: string;
|
|
35
|
+
constructor(toolName: string, attemptedPath: string, reason?: string) {
|
|
36
|
+
super(
|
|
37
|
+
"tool",
|
|
38
|
+
`tool "${toolName}" rejected path "${attemptedPath}"${reason ? `: ${reason}` : ": resolved location escapes the workspace root"}`,
|
|
39
|
+
);
|
|
40
|
+
this.toolName = toolName;
|
|
41
|
+
this.path = attemptedPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveSafe(toolName: string, rel: string, root: string = process.cwd()): string {
|
|
46
|
+
const rootResolved = path.resolve(root);
|
|
47
|
+
const abs = path.resolve(rootResolved, rel);
|
|
48
|
+
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
|
49
|
+
throw new ToolPermissionError(toolName, rel);
|
|
50
|
+
}
|
|
51
|
+
return abs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ImageMediaType = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sniff the first 12 bytes for a known image magic-byte signature. Returns
|
|
58
|
+
* the canonical media type or `null` for anything else. Tools using this
|
|
59
|
+
* MUST treat `null` as a hard reject — never trust the file extension.
|
|
60
|
+
*/
|
|
61
|
+
export function detectMediaType(bytes: Uint8Array): ImageMediaType | null {
|
|
62
|
+
if (bytes.length < 4) return null;
|
|
63
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
64
|
+
if (
|
|
65
|
+
bytes.length >= 8 &&
|
|
66
|
+
bytes[0] === 0x89 &&
|
|
67
|
+
bytes[1] === 0x50 &&
|
|
68
|
+
bytes[2] === 0x4e &&
|
|
69
|
+
bytes[3] === 0x47 &&
|
|
70
|
+
bytes[4] === 0x0d &&
|
|
71
|
+
bytes[5] === 0x0a &&
|
|
72
|
+
bytes[6] === 0x1a &&
|
|
73
|
+
bytes[7] === 0x0a
|
|
74
|
+
) {
|
|
75
|
+
return "image/png";
|
|
76
|
+
}
|
|
77
|
+
// JPEG: FF D8 FF
|
|
78
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
79
|
+
return "image/jpeg";
|
|
80
|
+
}
|
|
81
|
+
// GIF: 47 49 46 38 (GIF87a / GIF89a)
|
|
82
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
83
|
+
return "image/gif";
|
|
84
|
+
}
|
|
85
|
+
// WebP: "RIFF" .... "WEBP"
|
|
86
|
+
if (
|
|
87
|
+
bytes.length >= 12 &&
|
|
88
|
+
bytes[0] === 0x52 &&
|
|
89
|
+
bytes[1] === 0x49 &&
|
|
90
|
+
bytes[2] === 0x46 &&
|
|
91
|
+
bytes[3] === 0x46 &&
|
|
92
|
+
bytes[8] === 0x57 &&
|
|
93
|
+
bytes[9] === 0x45 &&
|
|
94
|
+
bytes[10] === 0x42 &&
|
|
95
|
+
bytes[11] === 0x50
|
|
96
|
+
) {
|
|
97
|
+
return "image/webp";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const readImageSchema = z.object({ path: z.string().min(1) });
|
|
103
|
+
|
|
104
|
+
export const readImage: RegisteredTool = buildTool({
|
|
105
|
+
name: "ReadImage",
|
|
106
|
+
description:
|
|
107
|
+
"Read an image file (PNG, JPEG, GIF, or WebP) from the workspace and return it as a base64 image block the model can see. Path is resolved relative to the project root; escaping the root is rejected. Per-image limit: 5 MB.",
|
|
108
|
+
inputSchema: readImageSchema,
|
|
109
|
+
readOnly: true,
|
|
110
|
+
// Not concurrency-safe today: see deferred per-turn image counter above —
|
|
111
|
+
// when that lands, parallel calls would race on the counter. Mark serial
|
|
112
|
+
// until the runtime exposes a shared turn store.
|
|
113
|
+
execute: async (input): Promise<string | ToolResultContent> => {
|
|
114
|
+
const abs = resolveSafe("ReadImage", input.path);
|
|
115
|
+
const file = Bun.file(abs);
|
|
116
|
+
if (!(await file.exists())) {
|
|
117
|
+
throw new ToolPermissionError("ReadImage", input.path, "file not found");
|
|
118
|
+
}
|
|
119
|
+
const size = file.size;
|
|
120
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
121
|
+
throw new ToolPermissionError(
|
|
122
|
+
"ReadImage",
|
|
123
|
+
input.path,
|
|
124
|
+
`image is ${size} bytes — exceeds the ${MAX_IMAGE_BYTES}-byte cap`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const buf = new Uint8Array(await file.arrayBuffer());
|
|
128
|
+
const mediaType = detectMediaType(buf);
|
|
129
|
+
if (mediaType === null) {
|
|
130
|
+
throw new ToolPermissionError(
|
|
131
|
+
"ReadImage",
|
|
132
|
+
input.path,
|
|
133
|
+
"unrecognized image format (only PNG, JPEG, GIF, WebP supported)",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const data = Buffer.from(buf).toString("base64");
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
type: "image",
|
|
140
|
+
source: { type: "base64", media_type: mediaType, data },
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
},
|
|
144
|
+
});
|