@crewhaus/tool-bash 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,49 @@
1
+ {
2
+ "name": "@crewhaus/tool-bash",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Built-in Bash tool: spawns shell commands via Bun.spawn with a default 30s timeout",
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-bash"
35
+ },
36
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-bash#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,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { bash } from "./index";
3
+
4
+ describe("Bash tool metadata", () => {
5
+ test("name + flags", () => {
6
+ expect(bash.name).toBe("Bash");
7
+ expect(bash.destructive).toBe(true);
8
+ expect(bash.readOnly).toBe(false);
9
+ expect(bash.concurrencySafe).toBe(false);
10
+ });
11
+ });
12
+
13
+ describe("Bash tool execution", () => {
14
+ test("captures stdout and exit code 0 on success", async () => {
15
+ const out = await bash.execute({ command: "echo hello" });
16
+ expect(out).toContain("hello");
17
+ expect(out).toContain("[exit] 0");
18
+ expect(out).not.toContain("timed out");
19
+ });
20
+
21
+ test("reports non-zero exit code", async () => {
22
+ const out = await bash.execute({ command: "exit 7" });
23
+ expect(out).toContain("[exit] 7");
24
+ });
25
+
26
+ test("captures stderr separately from stdout", async () => {
27
+ const out = await bash.execute({ command: ">&2 echo oops" });
28
+ expect(out).toContain("[stderr]");
29
+ expect(out).toContain("oops");
30
+ });
31
+
32
+ test("captures both streams and shows them in order", async () => {
33
+ const out = await bash.execute({
34
+ command: "echo out; >&2 echo err; exit 1",
35
+ });
36
+ expect(out).toContain("out");
37
+ expect(out).toContain("[stderr]");
38
+ expect(out).toContain("err");
39
+ expect(out).toContain("[exit] 1");
40
+ });
41
+
42
+ test("kills the process and reports timeout when exceeded", async () => {
43
+ const start = Date.now();
44
+ const out = await bash.execute({ command: "sleep 10", timeout: 150 });
45
+ const elapsed = Date.now() - start;
46
+ expect(out).toContain("timed out after 150ms");
47
+ expect(elapsed).toBeLessThan(2000);
48
+ });
49
+
50
+ test("does not time out when command finishes within budget", async () => {
51
+ const out = await bash.execute({ command: "echo fast", timeout: 5000 });
52
+ expect(out).not.toContain("timed out");
53
+ expect(out).toContain("fast");
54
+ expect(out).toContain("[exit] 0");
55
+ });
56
+
57
+ test("rejects timeouts above the cap via schema", () => {
58
+ const result = bash.inputSchema.safeParse({
59
+ command: "echo",
60
+ timeout: 999_999_999,
61
+ });
62
+ expect(result.success).toBe(false);
63
+ });
64
+
65
+ test("rejects empty command via schema", () => {
66
+ const result = bash.inputSchema.safeParse({ command: "" });
67
+ expect(result.success).toBe(false);
68
+ });
69
+ });
package/src/index.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { buildTool } from "@crewhaus/tool-builder";
2
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * Built-in Bash tool. Spawns the command through `sh -c` via `Bun.spawn`,
7
+ * captures stdout + stderr, and enforces a default 30s timeout (max 10min)
8
+ * by SIGKILLing the subprocess when the deadline elapses. The returned
9
+ * string is human-readable and encodes both streams plus the exit code.
10
+ *
11
+ * Linux note: when `sh` forks a long-running grandchild (e.g. `sleep 10`),
12
+ * SIGKILL on the shell does not propagate to the orphan, which keeps the
13
+ * pipe write-end alive and prevents `text()` from ever EOFing. After the
14
+ * shell exits we therefore give each stream a fixed drain grace window
15
+ * before falling back to an empty string — the orphan still exits on its
16
+ * own, but the tool call returns within `timeoutMs + DRAIN_GRACE_MS`.
17
+ *
18
+ * Layer R4. Pairs with the `target-cli` codegen contract (`bash` export).
19
+ */
20
+
21
+ const DEFAULT_TIMEOUT_MS = 30_000;
22
+ const MAX_TIMEOUT_MS = 600_000;
23
+ const DRAIN_GRACE_MS = 500;
24
+
25
+ const bashSchema = z.object({
26
+ command: z.string().min(1),
27
+ timeout: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
28
+ });
29
+
30
+ type BashOutput = {
31
+ readonly stdout: string;
32
+ readonly stderr: string;
33
+ readonly exitCode: number;
34
+ readonly timedOut: boolean;
35
+ readonly timeoutMs: number;
36
+ };
37
+
38
+ function formatResult(out: BashOutput): string {
39
+ const parts: string[] = [];
40
+ if (out.stdout.length > 0) parts.push(out.stdout.replace(/\n+$/, ""));
41
+ if (out.stderr.length > 0) {
42
+ parts.push("[stderr]");
43
+ parts.push(out.stderr.replace(/\n+$/, ""));
44
+ }
45
+ const exitLine = out.timedOut
46
+ ? `[exit] ${out.exitCode} (timed out after ${out.timeoutMs}ms)`
47
+ : `[exit] ${out.exitCode}`;
48
+ parts.push(exitLine);
49
+ return parts.join("\n");
50
+ }
51
+
52
+ export const bash: RegisteredTool = buildTool({
53
+ name: "Bash",
54
+ description:
55
+ "Run a shell command via `sh -c`. Captures stdout and stderr; default timeout 30s, max 10min.",
56
+ inputSchema: bashSchema,
57
+ destructive: true,
58
+ execute: async (input, ctx) => {
59
+ const timeoutMs = input.timeout ?? DEFAULT_TIMEOUT_MS;
60
+ const proc = Bun.spawn(["sh", "-c", input.command], {
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ // When the orchestrator aborts the turn (Ctrl-C, recovery exhaustion),
64
+ // Bun forwards the signal as SIGTERM to the spawned shell.
65
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
66
+ });
67
+ let timedOut = false;
68
+ const timer = setTimeout(() => {
69
+ timedOut = true;
70
+ try {
71
+ proc.kill("SIGKILL");
72
+ } catch {
73
+ // Process already exited between the timer firing and the kill call.
74
+ }
75
+ }, timeoutMs);
76
+ try {
77
+ const stdoutText = new Response(proc.stdout).text();
78
+ const stderrText = new Response(proc.stderr).text();
79
+ const exitCode = await proc.exited;
80
+ const drainFallback = (): Promise<string> =>
81
+ new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
82
+ const [stdout, stderr] = await Promise.all([
83
+ Promise.race([stdoutText, drainFallback()]),
84
+ Promise.race([stderrText, drainFallback()]),
85
+ ]);
86
+ return formatResult({ stdout, stderr, exitCode, timedOut, timeoutMs });
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ },
91
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Integration test: wire ToolCatalog + buildTool + validateToolInput +
3
+ * matchesPattern + executeTool around the bash tool. Confirms the timeout
4
+ * path surfaces a non-error result whose content includes the timeout
5
+ * marker, and that permission patterns gate prefix-style command rules.
6
+ */
7
+ import { beforeEach, describe, expect, test } from "bun:test";
8
+ import { type RegisteredTool, ToolCatalog } from "@crewhaus/tool-catalog";
9
+ import { executeTool } from "@crewhaus/tool-executor";
10
+ import { bash } from "./index";
11
+
12
+ let catalog: ToolCatalog;
13
+
14
+ function lookup(name: string): RegisteredTool {
15
+ const tool = catalog.get(name);
16
+ if (!tool) throw new Error(`expected tool "${name}" to be registered`);
17
+ return tool;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ catalog = new ToolCatalog();
22
+ catalog.register(bash);
23
+ });
24
+
25
+ describe("integration: tool-bash through executeTool", () => {
26
+ test("happy path returns stdout and exit code", async () => {
27
+ const result = await executeTool(
28
+ lookup("Bash"),
29
+ { command: "echo integration" },
30
+ { toolUseId: "b1" },
31
+ );
32
+ expect(result.isError).toBe(false);
33
+ expect(result.content).toContain("integration");
34
+ expect(result.content).toContain("[exit] 0");
35
+ });
36
+
37
+ test("non-zero exit is reported via formatted content (not isError)", async () => {
38
+ const result = await executeTool(lookup("Bash"), { command: "exit 3" }, { toolUseId: "b2" });
39
+ expect(result.isError).toBe(false);
40
+ expect(result.content).toContain("[exit] 3");
41
+ });
42
+
43
+ test("timeout surfaces in content", async () => {
44
+ const result = await executeTool(
45
+ lookup("Bash"),
46
+ { command: "sleep 5", timeout: 120 },
47
+ { toolUseId: "b3" },
48
+ );
49
+ expect(result.isError).toBe(false);
50
+ expect(result.content).toContain("timed out");
51
+ });
52
+
53
+ test("invalid input is caught before execute (empty command)", async () => {
54
+ const result = await executeTool(lookup("Bash"), { command: "" }, { toolUseId: "b4" });
55
+ expect(result.isError).toBe(true);
56
+ expect(result.content).toContain("Bash");
57
+ });
58
+
59
+ test("permission pattern Bash gates the call", async () => {
60
+ const allowed = await executeTool(
61
+ lookup("Bash"),
62
+ { command: "echo ok" },
63
+ { toolUseId: "b5", allowedPatterns: ["Bash"] },
64
+ );
65
+ expect(allowed.isError).toBe(false);
66
+
67
+ const denied = await executeTool(
68
+ lookup("Bash"),
69
+ { command: "echo ok" },
70
+ { toolUseId: "b6", allowedPatterns: ["Read"] },
71
+ );
72
+ expect(denied.isError).toBe(true);
73
+ expect(denied.content).toContain("not permitted");
74
+ });
75
+ });