@crewhaus/tool-bash 0.1.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-bash",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Built-in Bash tool: spawns shell commands via Bun.spawn with a default 30s timeout",
6
6
  "main": "src/index.ts",
@@ -12,21 +12,21 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1",
16
- "@crewhaus/tool-builder": "0.1.1",
17
- "@crewhaus/tool-catalog": "0.1.1",
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/tool-builder": "0.1.2",
17
+ "@crewhaus/tool-catalog": "0.1.2",
18
18
  "zod": "^3.23.8"
19
19
  },
20
20
  "devDependencies": {
21
- "@crewhaus/tool-executor": "0.1.1",
22
- "@crewhaus/tool-permission-matcher": "0.1.1",
23
- "@crewhaus/tool-validate": "0.1.1"
21
+ "@crewhaus/tool-executor": "0.1.2",
22
+ "@crewhaus/tool-permission-matcher": "0.1.2",
23
+ "@crewhaus/tool-validate": "0.1.2"
24
24
  },
25
25
  "license": "Apache-2.0",
26
26
  "author": {
27
27
  "name": "Max Meier",
28
- "email": "max@studiomax.io",
29
- "url": "https://studiomax.io"
28
+ "email": "max@crewhaus.ai",
29
+ "url": "https://crewhaus.ai"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
@@ -38,12 +38,7 @@
38
38
  "url": "https://github.com/crewhaus/factory/issues"
39
39
  },
40
40
  "publishConfig": {
41
- "access": "restricted"
42
- },
43
- "files": [
44
- "src",
45
- "README.md",
46
- "LICENSE",
47
- "NOTICE"
48
- ]
41
+ "access": "public"
42
+ },
43
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
49
44
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Isolated coverage for the stream **drain-fallback** path in the Bash tool.
3
+ *
4
+ * On Linux, when the spawned `sh` forks a long-running grandchild and is
5
+ * SIGKILLed, the orphan keeps the pipe's write-end open, so
6
+ * `new Response(proc.stdout).text()` never EOFs. The tool guards against this
7
+ * by racing each stream read against a fixed `DRAIN_GRACE_MS` fallback that
8
+ * resolves to `""`. That fallback closure (`() => resolve("")`) cannot be
9
+ * reached with a real, well-behaved subprocess — both pipes EOF immediately —
10
+ * so we exercise it here with a fully mocked `Bun.spawn` whose streams never
11
+ * close, plus a synchronous `setTimeout` so there is zero wall-clock delay and
12
+ * no leaked timer handle.
13
+ *
14
+ * Everything (spawn + timers) is mocked and restored in `afterEach`, and this
15
+ * lives in its own file so the stubs never leak into the real-subprocess
16
+ * suites in `index.test.ts` / `integration.test.ts`.
17
+ */
18
+ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
19
+ import { bash } from "./index";
20
+
21
+ /** A ReadableStream that emits one chunk and then NEVER closes — modelling the
22
+ * orphaned-grandchild pipe that keeps the write-end alive. `Response.text()`
23
+ * on this stream never resolves, forcing the drain fallback to win the race. */
24
+ function neverClosingStream(): ReadableStream<Uint8Array> {
25
+ return new ReadableStream<Uint8Array>({
26
+ start(controller) {
27
+ controller.enqueue(new TextEncoder().encode("partial-output-no-eof"));
28
+ // Deliberately no controller.close() → the stream hangs open forever.
29
+ },
30
+ });
31
+ }
32
+
33
+ const spies: Array<{ mockRestore: () => void }> = [];
34
+
35
+ afterEach(() => {
36
+ for (const s of spies.splice(0)) s.mockRestore();
37
+ });
38
+
39
+ describe("Bash tool — stream drain fallback (orphaned-pipe guard)", () => {
40
+ test("falls back to empty streams when neither pipe EOFs before the grace window", async () => {
41
+ const killMock = mock((_sig?: unknown) => {});
42
+ // Fake proc: exits cleanly, but its stdout/stderr never close.
43
+ spies.push(
44
+ spyOn(Bun, "spawn").mockImplementation(
45
+ ((): ReturnType<typeof Bun.spawn> =>
46
+ ({
47
+ stdout: neverClosingStream(),
48
+ stderr: neverClosingStream(),
49
+ exited: Promise.resolve(0),
50
+ kill: killMock,
51
+ }) as unknown as ReturnType<typeof Bun.spawn>) as unknown as typeof Bun.spawn,
52
+ ),
53
+ );
54
+ // Fire ONLY the short DRAIN_GRACE (500ms) fallback timers synchronously —
55
+ // their `() => resolve("")` bodies are the target of this test. The long
56
+ // command-timeout timer (30s default) must NOT fire, or the run would be
57
+ // wrongly marked timed-out; for it we return an inert handle.
58
+ spies.push(
59
+ spyOn(globalThis, "setTimeout").mockImplementation(((
60
+ fn: (...a: unknown[]) => void,
61
+ ms?: number,
62
+ ) => {
63
+ if (ms !== undefined && ms <= 500) fn();
64
+ return 0 as unknown as ReturnType<typeof setTimeout>;
65
+ }) as unknown as typeof setTimeout),
66
+ );
67
+ spies.push(
68
+ spyOn(globalThis, "clearTimeout").mockImplementation(
69
+ (() => {}) as unknown as typeof clearTimeout,
70
+ ),
71
+ );
72
+
73
+ const out = await bash.execute({ command: "sleep 999" });
74
+
75
+ // Both streams drained to "" via the fallback, so the formatted result is
76
+ // just the exit line (no stdout/stderr sections) and the run is not marked
77
+ // as timed out (the process `exited` resolved on its own).
78
+ expect(out).toBe("[exit] 0");
79
+ expect(out).not.toContain("partial-output-no-eof");
80
+ expect(out).not.toContain("[stderr]");
81
+ expect(out).not.toContain("timed out");
82
+ });
83
+
84
+ test("drain fallback still reports a non-zero exit code from a hung-pipe process", async () => {
85
+ const killMock = mock((_sig?: unknown) => {});
86
+ spies.push(
87
+ spyOn(Bun, "spawn").mockImplementation(
88
+ ((): ReturnType<typeof Bun.spawn> =>
89
+ ({
90
+ stdout: neverClosingStream(),
91
+ stderr: neverClosingStream(),
92
+ exited: Promise.resolve(42),
93
+ kill: killMock,
94
+ }) as unknown as ReturnType<typeof Bun.spawn>) as unknown as typeof Bun.spawn,
95
+ ),
96
+ );
97
+ // Same selective firing as the first test: only the short DRAIN_GRACE
98
+ // (≤500ms) fallback timers run synchronously; the 30s command-timeout timer
99
+ // must stay inert, otherwise the run would be wrongly flagged as timed out
100
+ // and the exit line would read "[exit] 42 (timed out after 30000ms)".
101
+ spies.push(
102
+ spyOn(globalThis, "setTimeout").mockImplementation(((
103
+ fn: (...a: unknown[]) => void,
104
+ ms?: number,
105
+ ) => {
106
+ if (ms !== undefined && ms <= 500) fn();
107
+ return 0 as unknown as ReturnType<typeof setTimeout>;
108
+ }) as unknown as typeof setTimeout),
109
+ );
110
+ spies.push(
111
+ spyOn(globalThis, "clearTimeout").mockImplementation(
112
+ (() => {}) as unknown as typeof clearTimeout,
113
+ ),
114
+ );
115
+
116
+ const out = await bash.execute({ command: "exit 42" });
117
+ expect(out).toBe("[exit] 42");
118
+ });
119
+ });
package/src/index.test.ts CHANGED
@@ -8,6 +8,11 @@ describe("Bash tool metadata", () => {
8
8
  expect(bash.readOnly).toBe(false);
9
9
  expect(bash.concurrencySafe).toBe(false);
10
10
  });
11
+
12
+ test("crosses a process boundary → external scope + process io-capability (#146)", () => {
13
+ expect(bash.scope).toBe("external");
14
+ expect(bash.ioCapability).toBe("process");
15
+ });
11
16
  });
12
17
 
13
18
  describe("Bash tool execution", () => {
package/src/index.ts CHANGED
@@ -55,6 +55,12 @@ export const bash: RegisteredTool = buildTool({
55
55
  "Run a shell command via `sh -c`. Captures stdout and stderr; default timeout 30s, max 10min.",
56
56
  inputSchema: bashSchema,
57
57
  destructive: true,
58
+ // Pillar 3 sink-side: Bash spawns a host process and its command string is an
59
+ // exfiltration channel (curl, nc, `base64 | sh`, …). Mark it external +
60
+ // process so runtime-core runs classifyEgress on the command payload and the
61
+ // substring matcher can flag tagged secrets on the command line (#146).
62
+ scope: "external",
63
+ ioCapability: "process",
58
64
  execute: async (input, ctx) => {
59
65
  const timeoutMs = input.timeout ?? DEFAULT_TIMEOUT_MS;
60
66
  const proc = Bun.spawn(["sh", "-c", input.command], {