@crewhaus/tool-catalog 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 ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/tool-catalog",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "ToolDefinition interface, RegisteredTool, and ToolCatalog registry",
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
+ "zod": "^3.23.8"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/tool-catalog"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-catalog#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,98 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ import { z } from "zod";
4
+ import { type RegisteredTool, ToolCatalog, ToolCatalogError, defaultCatalog } from "./index";
5
+
6
+ function makeTool(name: string): RegisteredTool {
7
+ return {
8
+ name,
9
+ description: `${name} tool`,
10
+ inputSchema: z.object({ value: z.string() }) as RegisteredTool["inputSchema"],
11
+ execute: async (_input) => "ok",
12
+ concurrencySafe: false,
13
+ readOnly: false,
14
+ destructive: false,
15
+ requiresSandbox: false,
16
+ classifyOutput: true,
17
+ scope: "internal",
18
+ requireJustification: false,
19
+ };
20
+ }
21
+
22
+ describe("ToolCatalog", () => {
23
+ let catalog: ToolCatalog;
24
+
25
+ beforeEach(() => {
26
+ catalog = new ToolCatalog();
27
+ });
28
+
29
+ test("register and get round-trip", () => {
30
+ const tool = makeTool("Bash");
31
+ catalog.register(tool);
32
+ expect(catalog.get("Bash")).toBe(tool);
33
+ });
34
+
35
+ test("has returns true after register, false before", () => {
36
+ expect(catalog.has("Read")).toBe(false);
37
+ catalog.register(makeTool("Read"));
38
+ expect(catalog.has("Read")).toBe(true);
39
+ });
40
+
41
+ test("list returns tools in insertion order", () => {
42
+ const bash = makeTool("Bash");
43
+ const read = makeTool("Read");
44
+ const write = makeTool("Write");
45
+ catalog.register(bash);
46
+ catalog.register(read);
47
+ catalog.register(write);
48
+ expect(catalog.list()).toEqual([bash, read, write]);
49
+ });
50
+
51
+ test("list returns empty array when catalog is empty", () => {
52
+ expect(catalog.list()).toEqual([]);
53
+ });
54
+
55
+ test("get returns undefined for unknown tool", () => {
56
+ expect(catalog.get("Unknown")).toBeUndefined();
57
+ });
58
+
59
+ test("duplicate name throws ToolCatalogError", () => {
60
+ catalog.register(makeTool("Bash"));
61
+ expect(() => catalog.register(makeTool("Bash"))).toThrow(ToolCatalogError);
62
+ expect(() => catalog.register(makeTool("Bash"))).toThrow(/already registered/);
63
+ });
64
+
65
+ test("ToolCatalogError is instanceof CrewhausError", () => {
66
+ expect(new ToolCatalogError("x")).toBeInstanceOf(CrewhausError);
67
+ });
68
+
69
+ test("ToolCatalogError has code 'tool'", () => {
70
+ expect(new ToolCatalogError("x").code).toBe("tool");
71
+ });
72
+ });
73
+
74
+ describe("defaultCatalog", () => {
75
+ test("is a ToolCatalog instance", () => {
76
+ expect(defaultCatalog).toBeInstanceOf(ToolCatalog);
77
+ });
78
+ });
79
+
80
+ describe("RegisteredTool jsonSchema field", () => {
81
+ test("optional jsonSchema is preserved when set", () => {
82
+ const catalog = new ToolCatalog();
83
+ const tool: RegisteredTool = {
84
+ ...makeTool("Mcp"),
85
+ jsonSchema: { type: "object", properties: { x: { type: "number" } } },
86
+ };
87
+ catalog.register(tool);
88
+ const got = catalog.get("Mcp");
89
+ expect(got?.jsonSchema).toEqual({ type: "object", properties: { x: { type: "number" } } });
90
+ });
91
+
92
+ test("absent jsonSchema is undefined", () => {
93
+ const catalog = new ToolCatalog();
94
+ const tool = makeTool("Plain");
95
+ catalog.register(tool);
96
+ expect(catalog.get("Plain")?.jsonSchema).toBeUndefined();
97
+ });
98
+ });
package/src/index.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import type { ZodType } from "zod";
3
+
4
+ /**
5
+ * Per-call context passed as the second argument to `execute`. Tools that
6
+ * support cooperative cancellation (e.g. tool-bash forwarding to Bun.spawn)
7
+ * read `signal` from here; tools that don't care can ignore it entirely.
8
+ *
9
+ * `bridge` (Section 13) is an opaque payload runtime-core stuffs in once per
10
+ * run. Framework-aware tools — today only the `Task` tool — cast it back to
11
+ * the typed `RuntimeBridge` from `@crewhaus/agent-context-isolation`.
12
+ * Ordinary tools ignore it.
13
+ *
14
+ * Section 18 — `onStreamChunk` is invoked by streaming tools (e.g.
15
+ * tool-code-execution piping container stdout/stderr) so runtime-core can
16
+ * publish `tool_stream_chunk` trace events. The callback is fire-and-forget
17
+ * — tools must not block on it. Optional; tools that don't stream skip it.
18
+ */
19
+ export interface ToolExecuteContext {
20
+ readonly signal?: AbortSignal;
21
+ readonly bridge?: unknown;
22
+ readonly onStreamChunk?: (stream: "stdout" | "stderr", chunk: string) => void;
23
+ }
24
+
25
+ /**
26
+ * Section 14 — non-string tool result content. Mirrors the subset of
27
+ * Anthropic's `ToolResultBlockParam.content` we use today: text + base64
28
+ * image blocks. `runtime-core` forwards arrays of these verbatim into the
29
+ * API's `tool_result.content` field, so the model sees them as image
30
+ * inputs rather than as base64 text.
31
+ *
32
+ * Tools that don't return rich content (the majority — fs, bash, todo,
33
+ * mcp, channel, task) keep returning `string` and never construct these.
34
+ */
35
+ export interface ToolResultTextBlock {
36
+ readonly type: "text";
37
+ readonly text: string;
38
+ }
39
+
40
+ export interface ToolResultImageBlock {
41
+ readonly type: "image";
42
+ readonly source: {
43
+ readonly type: "base64";
44
+ readonly media_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
45
+ readonly data: string;
46
+ };
47
+ }
48
+
49
+ export type ToolResultContentBlock = ToolResultTextBlock | ToolResultImageBlock;
50
+ export type ToolResultContent = ReadonlyArray<ToolResultContentBlock>;
51
+ export type ToolExecuteResult = string | ToolResultContent;
52
+
53
+ /**
54
+ * Pillar 3 sink-side fabric — where a tool's effect lands.
55
+ *
56
+ * - `"internal"`: the tool reads/writes process-local state only
57
+ * (filesystem, sandboxed code execution, memory, todo, code-graph
58
+ * index). Egress classifier skips internal tools.
59
+ * - `"external"`: the tool transmits data to a sink the runtime cannot
60
+ * re-classify after the fact — a URL fetched, a channel message sent,
61
+ * a federation outbound payload, an MCP tool invocation, an EVM tx
62
+ * broadcast, an image upload. Every such call routes through
63
+ * `egress-classifier` first.
64
+ *
65
+ * Default at normalization is `"internal"` (fails closed). Tools that
66
+ * cross a process or network boundary MUST set `"external"` explicitly
67
+ * in their `ToolDefinition`.
68
+ */
69
+ export type ToolScope = "internal" | "external";
70
+
71
+ export interface ToolDefinition<TInput = unknown> {
72
+ name: string;
73
+ description: string;
74
+ inputSchema: ZodType<TInput>;
75
+ execute: (input: TInput, ctx?: ToolExecuteContext) => Promise<ToolExecuteResult>;
76
+ concurrencySafe?: boolean;
77
+ readOnly?: boolean;
78
+ destructive?: boolean;
79
+ /**
80
+ * Section 18 — declares that the tool MUST run inside a sandbox. The
81
+ * permission engine refuses to grant `allow` in default mode unless an
82
+ * `alwaysAllow` rule matches AND a real sandbox backend is available
83
+ * (see `permission-engine.evaluate`). Tool implementations are
84
+ * responsible for actually using the sandbox; the flag is the policy
85
+ * declaration that the floor enforces.
86
+ */
87
+ requiresSandbox?: boolean;
88
+ /**
89
+ * Section 18 — when explicitly false, the post-tool prompt-injection
90
+ * classifier in runtime-core is skipped for this tool. Default is true
91
+ * (run the classifier on every output). Set to false ONLY for tools whose
92
+ * output is structurally guaranteed not to be attacker-controlled (e.g.
93
+ * the in-process `Task` sub-agent tool wrapper).
94
+ */
95
+ classifyOutput?: boolean;
96
+ /**
97
+ * Pillar 3 sink-side — see `ToolScope`. Default `"internal"` at
98
+ * normalization. Set `"external"` for any tool whose effect leaves the
99
+ * process boundary unmonitored.
100
+ */
101
+ scope?: ToolScope;
102
+ /**
103
+ * Pillar 3 intent gate — when true, runtime-core demands the model
104
+ * supply a `justification` string in the tool's input alongside the
105
+ * declared schema, and `permission-engine` evaluates the justification
106
+ * against the session's stated goal via an LLM-as-judge. Failures emit
107
+ * `permission_justification_evaluated` audit events and deny the call.
108
+ *
109
+ * Default at normalization is `false`. Recommended `true` for any tool
110
+ * with destructive or external side effects (evm-tx, message-channel,
111
+ * federation outbound). Independent of `scope` — a tool can be
112
+ * `internal` and still require justification (e.g. a destructive fs
113
+ * delete), and a tool can be `external` without requiring justification
114
+ * (e.g. a read-only public-data fetch).
115
+ */
116
+ requireJustification?: boolean;
117
+ /**
118
+ * Authoritative JSON Schema for the tool's input. When set, runtime-core
119
+ * forwards this verbatim to the model instead of running
120
+ * `zodToJsonSchema(inputSchema)`. Used by tools whose canonical schema is
121
+ * already JSON Schema (e.g. MCP tools), where the Zod round-trip would be
122
+ * lossy. The `inputSchema` slot is still required for the validator path
123
+ * (typically `z.unknown()` for MCP).
124
+ */
125
+ jsonSchema?: unknown;
126
+ }
127
+
128
+ /** Normalized form stored in the catalog. All flags are required booleans and
129
+ * execute is type-erased so the registry can hold a homogeneous map. */
130
+ export interface RegisteredTool {
131
+ name: string;
132
+ description: string;
133
+ inputSchema: ZodType<unknown>;
134
+ execute: (input: unknown, ctx?: ToolExecuteContext) => Promise<ToolExecuteResult>;
135
+ concurrencySafe: boolean;
136
+ readOnly: boolean;
137
+ destructive: boolean;
138
+ /** Section 18 — fails closed (false) when omitted by `buildTool`. */
139
+ requiresSandbox: boolean;
140
+ /** Section 18 — defaults to true so post-tool classification runs. */
141
+ classifyOutput: boolean;
142
+ /**
143
+ * Pillar 3 sink-side — fails closed (`"internal"`) when omitted. Tools
144
+ * that cross a process or network boundary MUST set `"external"`
145
+ * explicitly in their `ToolDefinition`.
146
+ */
147
+ scope: ToolScope;
148
+ /**
149
+ * Pillar 3 intent gate — fails closed (false) when omitted. See
150
+ * `ToolDefinition.requireJustification`.
151
+ */
152
+ requireJustification: boolean;
153
+ /** See ToolDefinition.jsonSchema. Optional; runtime-core falls back to
154
+ * zodToJsonSchema(inputSchema) when absent. */
155
+ jsonSchema?: unknown;
156
+ }
157
+
158
+ export class ToolCatalogError extends CrewhausError {
159
+ override readonly name = "ToolCatalogError";
160
+ constructor(message: string, cause?: unknown) {
161
+ super("tool", message, cause);
162
+ }
163
+ }
164
+
165
+ export class ToolCatalog {
166
+ private readonly _tools = new Map<string, RegisteredTool>();
167
+
168
+ register(tool: RegisteredTool): void {
169
+ if (this._tools.has(tool.name)) {
170
+ throw new ToolCatalogError(`tool "${tool.name}" is already registered`);
171
+ }
172
+ this._tools.set(tool.name, tool);
173
+ }
174
+
175
+ get(name: string): RegisteredTool | undefined {
176
+ return this._tools.get(name);
177
+ }
178
+
179
+ has(name: string): boolean {
180
+ return this._tools.has(name);
181
+ }
182
+
183
+ list(): ReadonlyArray<RegisteredTool> {
184
+ return [...this._tools.values()];
185
+ }
186
+ }
187
+
188
+ export const defaultCatalog = new ToolCatalog();