@crewhaus/tool-executor 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 +46 -0
- package/src/index.test.ts +115 -0
- package/src/index.ts +82 -0
- package/src/integration.test.ts +110 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-executor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Execute a single tool: validate → permission-check → invoke → format result",
|
|
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-catalog": "0.0.0",
|
|
17
|
+
"@crewhaus/tool-validate": "0.0.0",
|
|
18
|
+
"@crewhaus/tool-permission-matcher": "0.0.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.0.0",
|
|
20
|
+
"zod": "^3.23.8"
|
|
21
|
+
},
|
|
22
|
+
"license": "Apache-2.0",
|
|
23
|
+
"author": {
|
|
24
|
+
"name": "Max Meier",
|
|
25
|
+
"email": "max@studiomax.io",
|
|
26
|
+
"url": "https://studiomax.io"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
31
|
+
"directory": "packages/tool-executor"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-executor#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "restricted"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"NOTICE"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ToolPermissionError, executeTool } from "./index";
|
|
5
|
+
|
|
6
|
+
const schema = z.object({ command: z.string() });
|
|
7
|
+
|
|
8
|
+
function makeEchoTool(overrides?: Partial<RegisteredTool>): RegisteredTool {
|
|
9
|
+
return {
|
|
10
|
+
name: "Bash",
|
|
11
|
+
description: "Run a command",
|
|
12
|
+
inputSchema: schema as RegisteredTool["inputSchema"],
|
|
13
|
+
execute: async (input) => `ran: ${(input as { command: string }).command}`,
|
|
14
|
+
concurrencySafe: false,
|
|
15
|
+
readOnly: false,
|
|
16
|
+
destructive: false,
|
|
17
|
+
requiresSandbox: false,
|
|
18
|
+
classifyOutput: true,
|
|
19
|
+
scope: "internal",
|
|
20
|
+
requireJustification: false,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("executeTool — happy path", () => {
|
|
26
|
+
test("returns isError:false with content from execute", async () => {
|
|
27
|
+
const result = await executeTool(makeEchoTool(), { command: "ls" }, { toolUseId: "1" });
|
|
28
|
+
expect(result.isError).toBe(false);
|
|
29
|
+
expect(result.content).toBe("ran: ls");
|
|
30
|
+
expect(result.toolUseId).toBe("1");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("no allowedPatterns means all tools are permitted", async () => {
|
|
34
|
+
const result = await executeTool(makeEchoTool(), { command: "rm -rf /" }, { toolUseId: "2" });
|
|
35
|
+
expect(result.isError).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("matching allowedPattern permits the call", async () => {
|
|
39
|
+
const result = await executeTool(
|
|
40
|
+
makeEchoTool(),
|
|
41
|
+
{ command: "git status" },
|
|
42
|
+
{ toolUseId: "3", allowedPatterns: ["Bash(git *)"] },
|
|
43
|
+
);
|
|
44
|
+
expect(result.isError).toBe(false);
|
|
45
|
+
expect(result.content).toBe("ran: git status");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("executeTool — validation failure", () => {
|
|
50
|
+
test("returns isError:true when input fails schema", async () => {
|
|
51
|
+
const result = await executeTool(makeEchoTool(), { command: 99 }, { toolUseId: "4" });
|
|
52
|
+
expect(result.isError).toBe(true);
|
|
53
|
+
expect(result.content).toContain("Bash");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns isError:true for null input", async () => {
|
|
57
|
+
const result = await executeTool(makeEchoTool(), null, { toolUseId: "5" });
|
|
58
|
+
expect(result.isError).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("executeTool — permission denied", () => {
|
|
63
|
+
test("returns isError:true when no pattern matches", async () => {
|
|
64
|
+
const result = await executeTool(
|
|
65
|
+
makeEchoTool(),
|
|
66
|
+
{ command: "ls" },
|
|
67
|
+
{ toolUseId: "6", allowedPatterns: ["Bash(git *)"] },
|
|
68
|
+
);
|
|
69
|
+
expect(result.isError).toBe(true);
|
|
70
|
+
expect(result.content).toContain("not permitted");
|
|
71
|
+
expect(result.content).toContain("Bash");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns isError:true when patterns list is empty", async () => {
|
|
75
|
+
const result = await executeTool(
|
|
76
|
+
makeEchoTool(),
|
|
77
|
+
{ command: "ls" },
|
|
78
|
+
{ toolUseId: "7", allowedPatterns: [] },
|
|
79
|
+
);
|
|
80
|
+
expect(result.isError).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("executeTool — execute throws", () => {
|
|
85
|
+
test("returns isError:true with error message", async () => {
|
|
86
|
+
const failTool = makeEchoTool({
|
|
87
|
+
execute: async () => {
|
|
88
|
+
throw new Error("disk full");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const result = await executeTool(failTool, { command: "write" }, { toolUseId: "8" });
|
|
92
|
+
expect(result.isError).toBe(true);
|
|
93
|
+
expect(result.content).toBe("disk full");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("captures non-Error throws as strings", async () => {
|
|
97
|
+
const failTool = makeEchoTool({
|
|
98
|
+
execute: async () => {
|
|
99
|
+
throw "string error";
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const result = await executeTool(failTool, { command: "x" }, { toolUseId: "9" });
|
|
103
|
+
expect(result.isError).toBe(true);
|
|
104
|
+
expect(result.content).toBe("string error");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("ToolPermissionError", () => {
|
|
109
|
+
test("has code 'tool' and stable name", () => {
|
|
110
|
+
const err = new ToolPermissionError("Bash");
|
|
111
|
+
expect(err.code).toBe("tool");
|
|
112
|
+
expect(err.name).toBe("ToolPermissionError");
|
|
113
|
+
expect(err.toolName).toBe("Bash");
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
import type { RegisteredTool, ToolExecuteResult } from "@crewhaus/tool-catalog";
|
|
3
|
+
import { compilePattern, matchesPattern } from "@crewhaus/tool-permission-matcher";
|
|
4
|
+
import { validateToolInput } from "@crewhaus/tool-validate";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Section 14 — `content` widened from `string` to `string | ToolResultContent`
|
|
8
|
+
* so tools like `ReadImage` can return Anthropic image content blocks. Error
|
|
9
|
+
* paths still produce a string (validation message, permission refusal,
|
|
10
|
+
* thrown-error.message) — the union is only meaningful on the success path.
|
|
11
|
+
*/
|
|
12
|
+
export type ToolResult = {
|
|
13
|
+
readonly toolUseId: string;
|
|
14
|
+
readonly content: ToolExecuteResult;
|
|
15
|
+
readonly isError: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ExecutionContext = {
|
|
19
|
+
readonly toolUseId: string;
|
|
20
|
+
/** When present, the tool call must match at least one pattern. Absent = allow all. */
|
|
21
|
+
readonly allowedPatterns?: ReadonlyArray<string>;
|
|
22
|
+
/** Optional cooperative-cancellation signal forwarded to the tool. */
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
/**
|
|
25
|
+
* Section 13 — opaque runtime bridge forwarded into the tool's
|
|
26
|
+
* `ToolExecuteContext.bridge`. Framework-aware tools (the Task tool) cast
|
|
27
|
+
* it; ordinary tools ignore it.
|
|
28
|
+
*/
|
|
29
|
+
readonly bridge?: unknown;
|
|
30
|
+
/**
|
|
31
|
+
* Section 18 — runtime-core supplies this so streaming tools
|
|
32
|
+
* (`tool-code-execution`) can forward stdout/stderr chunks to the trace
|
|
33
|
+
* bus as `tool_stream_chunk` events.
|
|
34
|
+
*/
|
|
35
|
+
readonly onStreamChunk?: (stream: "stdout" | "stderr", chunk: string) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class ToolPermissionError extends CrewhausError {
|
|
39
|
+
override readonly name = "ToolPermissionError";
|
|
40
|
+
readonly toolName: string;
|
|
41
|
+
constructor(toolName: string) {
|
|
42
|
+
super("tool", `tool "${toolName}" is not permitted by the current permission set`);
|
|
43
|
+
this.toolName = toolName;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function executeTool(
|
|
48
|
+
tool: RegisteredTool,
|
|
49
|
+
rawInput: unknown,
|
|
50
|
+
context: ExecutionContext,
|
|
51
|
+
): Promise<ToolResult> {
|
|
52
|
+
const { toolUseId, allowedPatterns } = context;
|
|
53
|
+
|
|
54
|
+
const validation = validateToolInput(tool, rawInput);
|
|
55
|
+
if (!validation.ok) {
|
|
56
|
+
return { toolUseId, content: validation.error.message, isError: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (allowedPatterns !== undefined) {
|
|
60
|
+
const compiled = allowedPatterns.map(compilePattern);
|
|
61
|
+
const permitted = compiled.some((p) => matchesPattern(p, tool.name, rawInput));
|
|
62
|
+
if (!permitted) {
|
|
63
|
+
return {
|
|
64
|
+
toolUseId,
|
|
65
|
+
content: new ToolPermissionError(tool.name).message,
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const content = await tool.execute(validation.value, {
|
|
73
|
+
signal: context.signal,
|
|
74
|
+
...(context.bridge !== undefined ? { bridge: context.bridge } : {}),
|
|
75
|
+
...(context.onStreamChunk !== undefined ? { onStreamChunk: context.onStreamChunk } : {}),
|
|
76
|
+
});
|
|
77
|
+
return { toolUseId, content, isError: false };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return { toolUseId, content: msg, isError: true };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: wires ToolCatalog + buildTool + validateToolInput +
|
|
3
|
+
* matchesPattern + executeTool together with a real mock tool. No mocking of
|
|
4
|
+
* internal modules — each layer is exercised end-to-end.
|
|
5
|
+
*/
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
8
|
+
import { type RegisteredTool, ToolCatalog } from "@crewhaus/tool-catalog";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { executeTool } from "./index";
|
|
11
|
+
|
|
12
|
+
const greetSchema = z.object({ name: z.string() });
|
|
13
|
+
type GreetInput = z.infer<typeof greetSchema>;
|
|
14
|
+
|
|
15
|
+
const greetDef = {
|
|
16
|
+
name: "Greet",
|
|
17
|
+
description: "Returns a greeting",
|
|
18
|
+
inputSchema: greetSchema,
|
|
19
|
+
execute: async (input: GreetInput) => `Hello, ${input.name}!`,
|
|
20
|
+
readOnly: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const failDef = {
|
|
24
|
+
name: "Fail",
|
|
25
|
+
description: "Always throws",
|
|
26
|
+
inputSchema: greetSchema,
|
|
27
|
+
execute: async (_input: GreetInput): Promise<string> => {
|
|
28
|
+
throw new Error("intentional failure");
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("integration: full execution pipeline", () => {
|
|
33
|
+
let catalog: ToolCatalog;
|
|
34
|
+
|
|
35
|
+
// Type-narrowing lookup so the test body never needs `!`. Throws
|
|
36
|
+
// descriptively if a tool is missing — which would itself indicate a
|
|
37
|
+
// catalog bug worth surfacing.
|
|
38
|
+
function lookup(name: string): RegisteredTool {
|
|
39
|
+
const tool = catalog.get(name);
|
|
40
|
+
if (!tool) throw new Error(`expected tool "${name}" to be registered`);
|
|
41
|
+
return tool;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
catalog = new ToolCatalog();
|
|
46
|
+
catalog.register(buildTool(greetDef));
|
|
47
|
+
catalog.register(buildTool(failDef));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("catalog → buildTool → executeTool succeeds end-to-end", async () => {
|
|
51
|
+
const tool = lookup("Greet");
|
|
52
|
+
const result = await executeTool(tool, { name: "World" }, { toolUseId: "i1" });
|
|
53
|
+
expect(result.isError).toBe(false);
|
|
54
|
+
expect(result.content).toBe("Hello, World!");
|
|
55
|
+
expect(result.toolUseId).toBe("i1");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("buildTool applies fail-closed defaults in the catalog", () => {
|
|
59
|
+
const tool = lookup("Greet");
|
|
60
|
+
expect(tool.concurrencySafe).toBe(false);
|
|
61
|
+
expect(tool.destructive).toBe(false);
|
|
62
|
+
expect(tool.readOnly).toBe(true); // explicit
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("permission pattern allows matching tool call", async () => {
|
|
66
|
+
const tool = lookup("Greet");
|
|
67
|
+
const result = await executeTool(
|
|
68
|
+
tool,
|
|
69
|
+
{ name: "Max" },
|
|
70
|
+
{ toolUseId: "i2", allowedPatterns: ["Greet"] },
|
|
71
|
+
);
|
|
72
|
+
expect(result.isError).toBe(false);
|
|
73
|
+
expect(result.content).toBe("Hello, Max!");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("permission pattern denies non-matching tool call", async () => {
|
|
77
|
+
const tool = lookup("Greet");
|
|
78
|
+
const result = await executeTool(
|
|
79
|
+
tool,
|
|
80
|
+
{ name: "Max" },
|
|
81
|
+
{ toolUseId: "i3", allowedPatterns: ["Bash(git *)"] },
|
|
82
|
+
);
|
|
83
|
+
expect(result.isError).toBe(true);
|
|
84
|
+
expect(result.content).toContain("not permitted");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("tool that throws produces isError:true result", async () => {
|
|
88
|
+
const tool = lookup("Fail");
|
|
89
|
+
const result = await executeTool(tool, { name: "anyone" }, { toolUseId: "i4" });
|
|
90
|
+
expect(result.isError).toBe(true);
|
|
91
|
+
expect(result.content).toBe("intentional failure");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("invalid input is caught before execute is called", async () => {
|
|
95
|
+
const tool = lookup("Greet");
|
|
96
|
+
const result = await executeTool(tool, { name: 42 }, { toolUseId: "i5" });
|
|
97
|
+
expect(result.isError).toBe(true);
|
|
98
|
+
expect(result.content).toContain("Greet");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("wildcard permission pattern permits all tools", async () => {
|
|
102
|
+
const tool = lookup("Greet");
|
|
103
|
+
const result = await executeTool(
|
|
104
|
+
tool,
|
|
105
|
+
{ name: "Everyone" },
|
|
106
|
+
{ toolUseId: "i6", allowedPatterns: ["*"] },
|
|
107
|
+
);
|
|
108
|
+
expect(result.isError).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|