@agent-sh/harness-bash 0.2.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/dist/index.js ADDED
@@ -0,0 +1,951 @@
1
+ import path4 from 'path';
2
+ import { toolError, defaultNodeOperations, matchesAnyPattern, isInsideAnyRoot } from '@agent-sh/harness-core';
3
+ import { spawn } from 'child_process';
4
+ import { mkdirSync, writeFileSync, appendFileSync, createWriteStream, existsSync, statSync, readFileSync } from 'fs';
5
+ import { tmpdir } from 'os';
6
+ import { randomUUID } from 'crypto';
7
+ import * as v from 'valibot';
8
+
9
+ // src/bash.ts
10
+
11
+ // src/constants.ts
12
+ var DEFAULT_INACTIVITY_TIMEOUT_MS = 6e4;
13
+ var DEFAULT_WALLCLOCK_BACKSTOP_MS = 3e5;
14
+ var MAX_COMMAND_LENGTH = 16384;
15
+ var MAX_OUTPUT_BYTES_INLINE = 30720;
16
+ var MAX_OUTPUT_BYTES_FILE = 10 * 1024 * 1024;
17
+ var BACKGROUND_MAX_JOBS = 16;
18
+ var KILL_GRACE_MS = 5e3;
19
+ var SENSITIVE_ENV_PREFIXES = [
20
+ "AWS_",
21
+ "BEDROCK_",
22
+ "GITHUB_TOKEN",
23
+ "GH_TOKEN",
24
+ "OPENAI_API_KEY",
25
+ "ANTHROPIC_API_KEY",
26
+ "GOOGLE_API_KEY",
27
+ "GEMINI_API_KEY",
28
+ "NPM_TOKEN",
29
+ "DOCKERHUB_TOKEN",
30
+ "SLACK_",
31
+ "STRIPE_"
32
+ ];
33
+ function createLocalBashExecutor(opts) {
34
+ const bashPath = opts?.bashPath ?? "/bin/bash";
35
+ const logDir = opts?.logDir ?? path4.join(tmpdir(), "agent-sh-bash-logs");
36
+ mkdirSync(logDir, { recursive: true });
37
+ const jobs = /* @__PURE__ */ new Map();
38
+ async function runForeground2(input) {
39
+ const child = spawn(bashPath, ["-c", input.command], {
40
+ cwd: input.cwd,
41
+ env: { ...input.env },
42
+ stdio: ["ignore", "pipe", "pipe"]
43
+ });
44
+ child.stdout.on("data", (chunk) => input.onStdout(chunk));
45
+ child.stderr.on("data", (chunk) => input.onStderr(chunk));
46
+ let killedBySignal = false;
47
+ const onAbort = () => {
48
+ killedBySignal = true;
49
+ try {
50
+ child.kill("SIGTERM");
51
+ setTimeout(() => {
52
+ if (child.exitCode === null) child.kill("SIGKILL");
53
+ }, KILL_GRACE_MS).unref();
54
+ } catch {
55
+ }
56
+ };
57
+ if (input.signal.aborted) {
58
+ onAbort();
59
+ } else {
60
+ input.signal.addEventListener("abort", onAbort, { once: true });
61
+ }
62
+ return new Promise((resolve) => {
63
+ child.on("close", (code, signal) => {
64
+ input.signal.removeEventListener("abort", onAbort);
65
+ resolve({
66
+ exitCode: code,
67
+ killed: killedBySignal,
68
+ signal: signal ?? null
69
+ });
70
+ });
71
+ child.on("error", () => {
72
+ input.signal.removeEventListener("abort", onAbort);
73
+ resolve({ exitCode: null, killed: killedBySignal, signal: null });
74
+ });
75
+ });
76
+ }
77
+ async function spawnBackground(args) {
78
+ const jobId = randomUUID();
79
+ const outPath = path4.join(logDir, `${jobId}.out`);
80
+ const errPath = path4.join(logDir, `${jobId}.err`);
81
+ const outStream = createWriteStream(outPath, { flags: "w" });
82
+ const errStream = createWriteStream(errPath, { flags: "w" });
83
+ const child = spawn(bashPath, ["-c", args.command], {
84
+ cwd: args.cwd,
85
+ env: { ...args.env },
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ detached: false
88
+ });
89
+ child.stdout.pipe(outStream);
90
+ child.stderr.pipe(errStream);
91
+ const job = {
92
+ id: jobId,
93
+ outPath,
94
+ errPath,
95
+ running: true,
96
+ exitCode: null,
97
+ killed: false,
98
+ proc: child
99
+ };
100
+ jobs.set(jobId, job);
101
+ child.on("close", (code) => {
102
+ job.running = false;
103
+ job.exitCode = code;
104
+ job.proc = null;
105
+ });
106
+ child.on("error", () => {
107
+ job.running = false;
108
+ job.proc = null;
109
+ });
110
+ return { jobId };
111
+ }
112
+ async function readBackground(jobId, opts2) {
113
+ const job = jobs.get(jobId);
114
+ if (!job) {
115
+ throw new Error(`Unknown job_id: ${jobId}`);
116
+ }
117
+ const since = opts2.since_byte ?? 0;
118
+ const limit = opts2.head_limit ?? 30720;
119
+ const stdout = readSlice(job.outPath, since, limit);
120
+ const stderr = readSlice(job.errPath, since, limit);
121
+ return {
122
+ stdout: stdout.text,
123
+ stderr: stderr.text,
124
+ running: job.running,
125
+ exitCode: job.exitCode,
126
+ totalBytesStdout: stdout.totalBytes,
127
+ totalBytesStderr: stderr.totalBytes
128
+ };
129
+ }
130
+ async function killBackground(jobId, signal = "SIGTERM") {
131
+ const job = jobs.get(jobId);
132
+ if (!job || !job.proc) return;
133
+ job.killed = true;
134
+ try {
135
+ job.proc.kill(signal);
136
+ if (signal === "SIGTERM") {
137
+ setTimeout(() => {
138
+ if (job.running && job.proc) {
139
+ try {
140
+ job.proc.kill("SIGKILL");
141
+ } catch {
142
+ }
143
+ }
144
+ }, KILL_GRACE_MS).unref();
145
+ }
146
+ } catch {
147
+ }
148
+ }
149
+ async function closeSession() {
150
+ for (const job of jobs.values()) {
151
+ if (job.running && job.proc) {
152
+ try {
153
+ job.proc.kill("SIGTERM");
154
+ } catch {
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return {
160
+ run: runForeground2,
161
+ spawnBackground,
162
+ readBackground,
163
+ killBackground,
164
+ closeSession
165
+ };
166
+ }
167
+ function readSlice(filePath, since, limit) {
168
+ if (!existsSync(filePath)) return { text: "", totalBytes: 0 };
169
+ const totalBytes = statSync(filePath).size;
170
+ if (since >= totalBytes) return { text: "", totalBytes };
171
+ const end = Math.min(since + limit, totalBytes);
172
+ const buf = readFileSync(filePath).subarray(since, end);
173
+ return { text: buf.toString("utf8"), totalBytes };
174
+ }
175
+ async function resolveCwd(ops, session, requested) {
176
+ const base = requested ?? session.logicalCwd?.value ?? session.cwd;
177
+ const absolute = path4.isAbsolute(base) ? base : path4.resolve(session.cwd, base);
178
+ try {
179
+ return await ops.realpath(absolute);
180
+ } catch {
181
+ return absolute;
182
+ }
183
+ }
184
+ async function fenceBash(session, resolvedCwd) {
185
+ const { permissions } = session;
186
+ const isSensitive = matchesAnyPattern(
187
+ resolvedCwd,
188
+ permissions.sensitivePatterns
189
+ );
190
+ const insideWorkspace = isInsideAnyRoot(resolvedCwd, permissions.roots);
191
+ if (isSensitive && permissions.hook === void 0) {
192
+ return toolError(
193
+ "SENSITIVE",
194
+ `Refusing to run bash in sensitive path: ${resolvedCwd}`,
195
+ { meta: { path: resolvedCwd } }
196
+ );
197
+ }
198
+ if (!insideWorkspace && permissions.bypassWorkspaceGuard !== true && permissions.hook === void 0) {
199
+ return toolError(
200
+ "OUTSIDE_WORKSPACE",
201
+ `cwd is outside all configured workspace roots: ${resolvedCwd}`,
202
+ { meta: { path: resolvedCwd, roots: permissions.roots } }
203
+ );
204
+ }
205
+ return void 0;
206
+ }
207
+ async function askPermission(session, args) {
208
+ const { permissions } = session;
209
+ const commandHead = args.command.trimStart().split(/\s+/)[0] ?? "";
210
+ const alwaysPatterns = commandHead ? [`Bash(${commandHead}:*)`] : ["Bash(*)"];
211
+ if (permissions.hook === void 0) {
212
+ if (permissions.unsafeAllowBashWithoutHook === true) {
213
+ return { decision: "allow" };
214
+ }
215
+ return {
216
+ decision: "deny",
217
+ reason: "bash tool has no permission hook configured; refusing to run untrusted commands. Wire a hook or set session.permissions.unsafeAllowBashWithoutHook for test fixtures."
218
+ };
219
+ }
220
+ const decision = await permissions.hook({
221
+ tool: "bash",
222
+ path: args.cwd,
223
+ action: "read",
224
+ always_patterns: alwaysPatterns,
225
+ metadata: {
226
+ command: args.command,
227
+ cwd: args.cwd,
228
+ background: args.background,
229
+ timeout_ms: args.timeoutMs,
230
+ env_keys: args.envKeys,
231
+ network_required: null
232
+ }
233
+ });
234
+ if (decision === "deny") {
235
+ return {
236
+ decision: "deny",
237
+ reason: `Command blocked by permission policy. Pattern hint: ${alwaysPatterns.join(", ")}`
238
+ };
239
+ }
240
+ if (decision === "allow" || decision === "allow_once") {
241
+ return { decision };
242
+ }
243
+ return {
244
+ decision: "deny",
245
+ reason: "Permission hook returned 'ask' but bash runs in autonomous mode. Configure the hook to return allow or deny."
246
+ };
247
+ }
248
+ function resolveOps(session) {
249
+ return session.ops ?? defaultNodeOperations();
250
+ }
251
+ var HeadTailBuffer = class {
252
+ constructor(maxInline, maxFile, kind, spillDir) {
253
+ this.maxInline = maxInline;
254
+ this.maxFile = maxFile;
255
+ this.kind = kind;
256
+ this.spillDir = spillDir;
257
+ }
258
+ maxInline;
259
+ maxFile;
260
+ kind;
261
+ spillDir;
262
+ chunks = [];
263
+ totalBytes = 0;
264
+ byteCap = false;
265
+ spilled = false;
266
+ spillPath = null;
267
+ spillBytes = [];
268
+ write(chunk) {
269
+ this.totalBytes += chunk.byteLength;
270
+ if (this.totalBytes <= this.maxInline) {
271
+ this.chunks.push(chunk);
272
+ return;
273
+ }
274
+ if (!this.spilled) {
275
+ this.spilled = true;
276
+ this.byteCap = true;
277
+ this.spillPath = path4.join(
278
+ this.spillDir,
279
+ `${randomUUID()}.${this.kind}`
280
+ );
281
+ for (const c of this.chunks) this.appendSpill(c);
282
+ }
283
+ this.appendSpill(chunk);
284
+ this.spillBytes.push(chunk.byteLength);
285
+ }
286
+ appendSpill(chunk) {
287
+ if (this.spillPath === null) return;
288
+ if (!this.spillInit) {
289
+ mkdirSync(this.spillDir, { recursive: true });
290
+ writeFileSync(this.spillPath, "");
291
+ this.spillInit = true;
292
+ }
293
+ if (this.fileBytesWritten + chunk.byteLength > this.maxFile) {
294
+ return;
295
+ }
296
+ appendFileSync(this.spillPath, Buffer.from(chunk));
297
+ this.fileBytesWritten += chunk.byteLength;
298
+ }
299
+ spillInit = false;
300
+ fileBytesWritten = 0;
301
+ /**
302
+ * Return the inline render:
303
+ * - If not capped: the full buffered text.
304
+ * - If capped: head (first maxInline/2 bytes) + marker + tail
305
+ * (last maxInline/2 bytes) approximation. We approximate the tail
306
+ * by decoding only the tail window (maxInline/2 bytes from the spill
307
+ * file) because the stream is write-once and we dropped the middle.
308
+ *
309
+ * The actual implementation is simpler: we keep only the head inline
310
+ * (first maxInline bytes, never overwritten) and emit a marker that
311
+ * points at the log path. Head-only is a deliberate simplification
312
+ * versus spec's head+tail — it matches OpenCode's default, and we
313
+ * rely on Read(path) to see the tail. Spec §4 head+tail is a v2
314
+ * improvement once we prove the file-path recovery path.
315
+ */
316
+ render() {
317
+ if (!this.spilled) {
318
+ const combined = Buffer.concat(this.chunks.map((c) => Buffer.from(c)));
319
+ return {
320
+ text: combined.toString("utf8"),
321
+ byteCap: false,
322
+ logPath: null
323
+ };
324
+ }
325
+ const head = Buffer.concat(
326
+ this.chunks.map((c) => Buffer.from(c)),
327
+ this.maxInline
328
+ ).toString("utf8");
329
+ const marker = `
330
+ ... (stream exceeded ${this.maxInline} bytes; full log at ${this.spillPath}) ...`;
331
+ return {
332
+ text: head + marker,
333
+ byteCap: true,
334
+ logPath: this.spillPath
335
+ };
336
+ }
337
+ bytesTotal() {
338
+ return this.totalBytes;
339
+ }
340
+ wasCapped() {
341
+ return this.byteCap;
342
+ }
343
+ };
344
+ function defaultSpillDir() {
345
+ return path4.join(tmpdir(), "agent-sh-bash-spill");
346
+ }
347
+ function formatResultText(args) {
348
+ const header = `<command>${args.command}</command>`;
349
+ const exitLine = `<exit_code>${args.exitCode}</exit_code>`;
350
+ const stdoutBlock = `<stdout>
351
+ ${args.stdout}
352
+ </stdout>`;
353
+ const stderrBlock = `<stderr>
354
+ ${args.stderr}
355
+ </stderr>`;
356
+ const hint = args.byteCap ? `(Output capped. Full log: ${args.logPath}. Read it with pagination if you need the middle.)` : args.kind === "ok" ? `(Command completed in ${args.durationMs}ms. exit=0.)` : `(Command exited nonzero in ${args.durationMs}ms. Exit code: ${args.exitCode}.)`;
357
+ return [header, exitLine, stdoutBlock, stderrBlock, hint].join("\n");
358
+ }
359
+ function formatTimeoutText(args) {
360
+ const header = `<command>${args.command}</command>`;
361
+ const stdoutBlock = `<stdout>
362
+ ${args.stdout}
363
+ </stdout>`;
364
+ const stderrBlock = `<stderr>
365
+ ${args.stderr}
366
+ </stderr>`;
367
+ const logHint = args.logPath ? ` Full log: ${args.logPath}.` : "";
368
+ const hint = `(Command hit ${args.reason} after ${args.durationMs}ms. ${args.partialBytes} bytes captured. Kill signal: SIGTERM then SIGKILL.${logHint} If the command is long-running, retry with background: true.)`;
369
+ return [header, stdoutBlock, stderrBlock, hint].join("\n");
370
+ }
371
+ function formatBackgroundStartedText(args) {
372
+ return [
373
+ `<command>${args.command}</command>`,
374
+ `<job_id>${args.jobId}</job_id>`,
375
+ `(Background job started. Poll output with bash_output(job_id). Kill with bash_kill(job_id).)`
376
+ ].join("\n");
377
+ }
378
+ function formatBashOutputText(args) {
379
+ const next = args.sinceByte + args.returnedBytes;
380
+ return [
381
+ `<job_id>${args.jobId}</job_id>`,
382
+ `<running>${args.running}</running>`,
383
+ `<exit_code>${args.exitCode === null ? "null" : args.exitCode}</exit_code>`,
384
+ `<stdout>
385
+ ${args.stdout}
386
+ </stdout>`,
387
+ `<stderr>
388
+ ${args.stderr}
389
+ </stderr>`,
390
+ `(Showing bytes ${args.sinceByte}-${next} of ${args.totalBytes}. Next since_byte: ${next}. Job running: ${args.running}.)`
391
+ ].join("\n");
392
+ }
393
+ function formatBashKillText(args) {
394
+ return `<job_id>${args.jobId}</job_id>
395
+ (${args.signal} sent. Poll bash_output to confirm termination.)`;
396
+ }
397
+ var BashParamsSchema = v.strictObject({
398
+ command: v.pipe(
399
+ v.string(),
400
+ v.minLength(1, "command is required"),
401
+ v.maxLength(
402
+ MAX_COMMAND_LENGTH,
403
+ `command exceeds ${MAX_COMMAND_LENGTH} bytes`
404
+ )
405
+ ),
406
+ cwd: v.optional(v.pipe(v.string(), v.minLength(1, "cwd must not be empty"))),
407
+ timeout_ms: v.optional(
408
+ v.pipe(
409
+ v.number(),
410
+ v.integer(),
411
+ v.minValue(100, "timeout_ms must be >= 100 ms")
412
+ )
413
+ ),
414
+ description: v.optional(v.string()),
415
+ background: v.optional(v.boolean()),
416
+ env: v.optional(v.record(v.string(), v.string()))
417
+ });
418
+ var BashOutputParamsSchema = v.strictObject({
419
+ job_id: v.pipe(v.string(), v.minLength(1, "job_id is required")),
420
+ since_byte: v.optional(
421
+ v.pipe(v.number(), v.integer(), v.minValue(0, "since_byte must be >= 0"))
422
+ ),
423
+ head_limit: v.optional(
424
+ v.pipe(v.number(), v.integer(), v.minValue(1, "head_limit must be >= 1"))
425
+ )
426
+ });
427
+ var BashKillParamsSchema = v.strictObject({
428
+ job_id: v.pipe(v.string(), v.minLength(1, "job_id is required")),
429
+ signal: v.optional(v.picklist(["SIGTERM", "SIGKILL"]))
430
+ });
431
+ var KNOWN_PARAM_ALIASES = {
432
+ cmd: "unknown parameter 'cmd'. Use 'command' instead.",
433
+ shell_command: "unknown parameter 'shell_command'. Use 'command' instead.",
434
+ script: "unknown parameter 'script'. Use 'command' instead.",
435
+ run: "unknown parameter 'run'. Use 'command' instead.",
436
+ directory: "unknown parameter 'directory'. Use 'cwd' instead.",
437
+ dir: "unknown parameter 'dir'. Use 'cwd' instead.",
438
+ path: "unknown parameter 'path'. Use 'cwd' instead.",
439
+ working_directory: "unknown parameter 'working_directory'. Use 'cwd' instead.",
440
+ timeout: "unknown parameter 'timeout'. Use 'timeout_ms' instead (milliseconds, not seconds). For 30s pass timeout_ms: 30000.",
441
+ time_limit: "unknown parameter 'time_limit'. Use 'timeout_ms' instead (milliseconds).",
442
+ timeout_seconds: "unknown parameter 'timeout_seconds'. Use 'timeout_ms' instead (multiply by 1000).",
443
+ env_vars: "unknown parameter 'env_vars'. Use 'env' instead.",
444
+ environment: "unknown parameter 'environment'. Use 'env' instead.",
445
+ lang: `unknown parameter 'lang'. Bash runs shell commands; invoke other languages via the command itself (e.g. 'python -c "..."', 'node -e "..."').`,
446
+ language: `unknown parameter 'language'. Invoke other languages via the command (e.g. 'python -c "..."', 'node -e "..."').`,
447
+ interpreter: `unknown parameter 'interpreter'. Invoke the interpreter inside the command itself (e.g. 'python -c "..."').`,
448
+ runtime: `unknown parameter 'runtime'. Invoke the runtime inside the command itself (e.g. 'node -e "..."').`,
449
+ stdin: `unknown parameter 'stdin'. Interactive stdin is not supported in v1. Pipe data into the command instead (e.g. 'echo "y" | npm init').`,
450
+ input: "unknown parameter 'input'. Interactive input is not supported in v1. Make the command non-interactive with flags like --yes.",
451
+ sandbox: "unknown parameter 'sandbox'. Sandboxing is configured on the session, not per-call.",
452
+ sandbox_mode: "unknown parameter 'sandbox_mode'. Sandboxing is configured on the session, not per-call.",
453
+ permissions: "unknown parameter 'permissions'. The permission hook is configured on the session.",
454
+ network: "unknown parameter 'network'. Network access is configured on the session / executor adapter.",
455
+ network_access: "unknown parameter 'network_access'. Network access is configured on the session / executor adapter.",
456
+ shell: "unknown parameter 'shell'. Shell binary is configured on the session.",
457
+ shell_binary: "unknown parameter 'shell_binary'. Shell binary is configured on the session."
458
+ };
459
+ function checkAliases(input) {
460
+ if (input === null || typeof input !== "object") return [];
461
+ const hints = [];
462
+ for (const key of Object.keys(input)) {
463
+ const hint = KNOWN_PARAM_ALIASES[key];
464
+ if (hint) hints.push(hint);
465
+ }
466
+ return hints;
467
+ }
468
+ function makeAliasIssues(messages) {
469
+ return messages.map(
470
+ (m) => ({
471
+ kind: "validation",
472
+ type: "custom",
473
+ input: void 0,
474
+ expected: null,
475
+ received: "unknown",
476
+ message: m
477
+ })
478
+ );
479
+ }
480
+ function safeParseBashParams(input) {
481
+ const aliases = checkAliases(input);
482
+ if (aliases.length > 0) {
483
+ return { ok: false, issues: makeAliasIssues(aliases) };
484
+ }
485
+ const result = v.safeParse(BashParamsSchema, input);
486
+ if (result.success) return { ok: true, value: result.output };
487
+ return { ok: false, issues: result.issues };
488
+ }
489
+ function safeParseBashOutputParams(input) {
490
+ const result = v.safeParse(BashOutputParamsSchema, input);
491
+ if (result.success) return { ok: true, value: result.output };
492
+ return { ok: false, issues: result.issues };
493
+ }
494
+ function safeParseBashKillParams(input) {
495
+ const result = v.safeParse(BashKillParamsSchema, input);
496
+ if (result.success) return { ok: true, value: result.output };
497
+ return { ok: false, issues: result.issues };
498
+ }
499
+ var BASH_TOOL_NAME = "bash";
500
+ var BASH_TOOL_DESCRIPTION = `Run a single shell command in a bash subprocess. Output is captured and returned with the exit code.
501
+
502
+ Usage:
503
+ - 'cd' carries over to subsequent calls if it stays inside the workspace; otherwise the cwd is reset. Environment variables do NOT persist across calls \u2014 set them inline (FOO=bar some-cmd) or via 'env'.
504
+ - For non-shell code, use language one-liners: 'python -c "print(2+2)"', 'node -e "console.log(2+2)"', 'deno eval "console.log(2+2)"'. For multi-line scripts, write a temp file with the write tool and invoke the interpreter on it.
505
+ - Long-running processes (servers, watchers) MUST use background: true. The tool returns a job_id; poll output with bash_output(job_id). Do not leave a foreground command running past the 5-minute wall-clock backstop.
506
+ - No interactive commands. Anything that needs stdin (pagers, Y/n prompts, REPLs, 'git commit' without -m) will hang until the inactivity timeout. Use flags to make commands non-interactive (--yes, -y, --no-pager) or pipe 'echo "y" |' in front.
507
+ - Inactivity timeout resets on any output; default 60000 ms. Override with timeout_ms. Wall-clock backstop is 5 minutes for foreground calls.
508
+ - Prefer this tool over other ways of running shell commands. For filename search prefer 'glob'; for content search prefer 'grep'.`;
509
+ var bashToolDefinition = {
510
+ name: BASH_TOOL_NAME,
511
+ description: BASH_TOOL_DESCRIPTION,
512
+ inputSchema: {
513
+ type: "object",
514
+ properties: {
515
+ command: {
516
+ type: "string",
517
+ description: "The shell command to run (single string, interpreted by bash -c)."
518
+ },
519
+ cwd: {
520
+ type: "string",
521
+ description: "Absolute working directory. Defaults to the session cwd plus any carried-over cd. Must be inside the workspace."
522
+ },
523
+ timeout_ms: {
524
+ type: "integer",
525
+ minimum: 100,
526
+ description: "Inactivity timeout in milliseconds. Any output resets the clock. Default 60000 (60 s). Wall-clock backstop is 5 minutes regardless."
527
+ },
528
+ description: {
529
+ type: "string",
530
+ description: "One-line human-readable 'why' (optional, for traces)."
531
+ },
532
+ background: {
533
+ type: "boolean",
534
+ description: "Run as a background job. Returns a job_id; poll output with bash_output. Use for servers, watchers, long-running builds."
535
+ },
536
+ env: {
537
+ type: "object",
538
+ additionalProperties: { type: "string" },
539
+ description: "Environment variables merged on top of the session env. Keys with sensitive prefixes (AWS_*, GITHUB_TOKEN, etc.) are rejected."
540
+ }
541
+ },
542
+ required: ["command"],
543
+ additionalProperties: false
544
+ }
545
+ };
546
+ var BASH_OUTPUT_TOOL_NAME = "bash_output";
547
+ var BASH_OUTPUT_TOOL_DESCRIPTION = `Poll a backgrounded bash job's output since a given byte offset.
548
+
549
+ Returns stdout and stderr slices plus whether the job is still running and its exit code if finished. Use 'since_byte' from the previous call to paginate through a long-running job's output without re-fetching already-seen bytes.`;
550
+ var bashOutputToolDefinition = {
551
+ name: BASH_OUTPUT_TOOL_NAME,
552
+ description: BASH_OUTPUT_TOOL_DESCRIPTION,
553
+ inputSchema: {
554
+ type: "object",
555
+ properties: {
556
+ job_id: {
557
+ type: "string",
558
+ description: "The job_id returned by a previous bash call with background: true."
559
+ },
560
+ since_byte: {
561
+ type: "integer",
562
+ minimum: 0,
563
+ description: "Start of the requested slice per stream, in bytes. Defaults to 0. Use next_since_byte from a previous output call to resume."
564
+ },
565
+ head_limit: {
566
+ type: "integer",
567
+ minimum: 1,
568
+ description: "Max bytes per stream (default 30720 / 30 KB)."
569
+ }
570
+ },
571
+ required: ["job_id"],
572
+ additionalProperties: false
573
+ }
574
+ };
575
+ var BASH_KILL_TOOL_NAME = "bash_kill";
576
+ var BASH_KILL_TOOL_DESCRIPTION = `Send a termination signal to a backgrounded bash job.
577
+
578
+ Defaults to SIGTERM (graceful). Use SIGKILL for an unresponsive job. The job's next bash_output call will report running: false.`;
579
+ var bashKillToolDefinition = {
580
+ name: BASH_KILL_TOOL_NAME,
581
+ description: BASH_KILL_TOOL_DESCRIPTION,
582
+ inputSchema: {
583
+ type: "object",
584
+ properties: {
585
+ job_id: {
586
+ type: "string",
587
+ description: "The job_id returned by a previous bash call with background: true."
588
+ },
589
+ signal: {
590
+ type: "string",
591
+ enum: ["SIGTERM", "SIGKILL"],
592
+ description: "Signal to send. Default SIGTERM."
593
+ }
594
+ },
595
+ required: ["job_id"],
596
+ additionalProperties: false
597
+ }
598
+ };
599
+
600
+ // src/bash.ts
601
+ var jobCountBySession = /* @__PURE__ */ new WeakMap();
602
+ function incJobCount(session) {
603
+ jobCountBySession.set(session, (jobCountBySession.get(session) ?? 0) + 1);
604
+ }
605
+ function jobCount(session) {
606
+ return jobCountBySession.get(session) ?? 0;
607
+ }
608
+ function err(error) {
609
+ return { kind: "error", error };
610
+ }
611
+ function resolveExecutor(session) {
612
+ if (session.executor) return session.executor;
613
+ if (session.permissions.unsafeAllowBashWithoutHook !== true) ;
614
+ return createLocalBashExecutor();
615
+ }
616
+ function detectTopLevelCd(command) {
617
+ const trimmed = command.trim();
618
+ if (trimmed.length === 0) return null;
619
+ const match = trimmed.match(/^cd\s+([^\s&|;`$()]+)$/);
620
+ if (!match) return null;
621
+ const arg = match[1];
622
+ if (arg === void 0) return null;
623
+ if (arg.startsWith('"') && arg.endsWith('"') || arg.startsWith("'") && arg.endsWith("'")) {
624
+ return arg.slice(1, -1);
625
+ }
626
+ return arg;
627
+ }
628
+ function checkEnv(env) {
629
+ for (const key of Object.keys(env)) {
630
+ for (const prefix of SENSITIVE_ENV_PREFIXES) {
631
+ if (key === prefix || prefix.endsWith("_") && key.startsWith(prefix)) {
632
+ return `env may not set sensitive-prefix variable '${key}' (prefix '${prefix}').`;
633
+ }
634
+ }
635
+ }
636
+ return null;
637
+ }
638
+ function byteLength(s) {
639
+ return Buffer.byteLength(s, "utf8");
640
+ }
641
+ async function bash(input, session) {
642
+ const parsed = safeParseBashParams(input);
643
+ if (!parsed.ok) {
644
+ const messages = parsed.issues.map((i) => i.message).join("; ");
645
+ return err(toolError("INVALID_PARAM", messages, { cause: parsed.issues }));
646
+ }
647
+ const params = parsed.value;
648
+ if (params.background === true && params.timeout_ms !== void 0) {
649
+ return err(
650
+ toolError(
651
+ "INVALID_PARAM",
652
+ "timeout_ms does not apply to background jobs; they have their own lifecycle (bash_kill). Drop timeout_ms or set background: false."
653
+ )
654
+ );
655
+ }
656
+ const envParam = params.env ?? {};
657
+ const envError = checkEnv(envParam);
658
+ if (envError) {
659
+ return err(toolError("INVALID_PARAM", envError));
660
+ }
661
+ const ops = resolveOps(session);
662
+ const resolvedCwd = await resolveCwd(ops, session, params.cwd);
663
+ const fenceError = await fenceBash(session, resolvedCwd);
664
+ if (fenceError) return err(fenceError);
665
+ const stat = await ops.stat(resolvedCwd).catch(() => void 0);
666
+ if (!stat) {
667
+ return err(
668
+ toolError("NOT_FOUND", `cwd does not exist: ${resolvedCwd}`, {
669
+ meta: { cwd: resolvedCwd }
670
+ })
671
+ );
672
+ }
673
+ if (stat.type !== "directory") {
674
+ return err(
675
+ toolError(
676
+ "IO_ERROR",
677
+ `cwd is not a directory: ${resolvedCwd}`,
678
+ { meta: { cwd: resolvedCwd } }
679
+ )
680
+ );
681
+ }
682
+ const effectiveTimeout = params.timeout_ms ?? session.defaultInactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
683
+ const decision = await askPermission(session, {
684
+ command: params.command,
685
+ cwd: resolvedCwd,
686
+ background: params.background ?? false,
687
+ timeoutMs: effectiveTimeout,
688
+ envKeys: Object.keys(envParam)
689
+ });
690
+ if (decision.decision === "deny") {
691
+ const echo = params.command.length > 200 ? params.command.slice(0, 200) + "..." : params.command;
692
+ return err(
693
+ toolError(
694
+ "PERMISSION_DENIED",
695
+ `${decision.reason}
696
+ Command: ${echo}`,
697
+ { meta: { command: params.command, cwd: resolvedCwd } }
698
+ )
699
+ );
700
+ }
701
+ const execEnv = {
702
+ ...session.env ?? process.env,
703
+ ...envParam
704
+ };
705
+ for (const [k, v2] of Object.entries(execEnv)) {
706
+ if (v2 === void 0) delete execEnv[k];
707
+ }
708
+ const executor = resolveExecutor(session);
709
+ if (params.background === true) {
710
+ return runBackground(
711
+ session,
712
+ executor,
713
+ params.command,
714
+ resolvedCwd,
715
+ execEnv
716
+ );
717
+ }
718
+ return runForeground(
719
+ session,
720
+ executor,
721
+ params.command,
722
+ resolvedCwd,
723
+ execEnv,
724
+ effectiveTimeout
725
+ );
726
+ }
727
+ async function runBackground(session, executor, command, cwd, env) {
728
+ if (!executor.spawnBackground) {
729
+ return err(
730
+ toolError(
731
+ "INVALID_PARAM",
732
+ "background: true is not supported by this executor adapter."
733
+ )
734
+ );
735
+ }
736
+ const maxJobs = session.maxBackgroundJobs ?? BACKGROUND_MAX_JOBS;
737
+ if (jobCount(session) >= maxJobs) {
738
+ return err(
739
+ toolError(
740
+ "IO_ERROR",
741
+ `Background job limit reached (${maxJobs}). Kill an existing job first with bash_kill.`
742
+ )
743
+ );
744
+ }
745
+ const { jobId } = await executor.spawnBackground({ command, cwd, env });
746
+ incJobCount(session);
747
+ return {
748
+ kind: "background_started",
749
+ output: formatBackgroundStartedText({ command, jobId }),
750
+ jobId
751
+ };
752
+ }
753
+ async function runForeground(session, executor, command, cwd, env, inactivityTimeoutMs) {
754
+ const wallclockMs = session.wallclockBackstopMs ?? DEFAULT_WALLCLOCK_BACKSTOP_MS;
755
+ const maxInline = session.maxOutputBytesInline ?? MAX_OUTPUT_BYTES_INLINE;
756
+ const maxFile = session.maxOutputBytesFile ?? MAX_OUTPUT_BYTES_FILE;
757
+ const spillDir = defaultSpillDir();
758
+ const stdoutBuf = new HeadTailBuffer(maxInline, maxFile, "out", spillDir);
759
+ const stderrBuf = new HeadTailBuffer(maxInline, maxFile, "err", spillDir);
760
+ const controller = new AbortController();
761
+ const abortOnOuter = () => controller.abort();
762
+ if (session.signal) {
763
+ if (session.signal.aborted) controller.abort();
764
+ else session.signal.addEventListener("abort", abortOnOuter, { once: true });
765
+ }
766
+ let timedOut = null;
767
+ let inactivityTimer = null;
768
+ const resetInactivity = () => {
769
+ if (inactivityTimer) clearTimeout(inactivityTimer);
770
+ inactivityTimer = setTimeout(() => {
771
+ timedOut = "inactivity timeout";
772
+ controller.abort();
773
+ }, inactivityTimeoutMs);
774
+ };
775
+ resetInactivity();
776
+ const wallclockTimer = setTimeout(() => {
777
+ timedOut = "wall-clock backstop";
778
+ controller.abort();
779
+ }, wallclockMs);
780
+ const start = Date.now();
781
+ let result;
782
+ try {
783
+ result = await executor.run({
784
+ command,
785
+ cwd,
786
+ env,
787
+ signal: controller.signal,
788
+ onStdout: (chunk) => {
789
+ stdoutBuf.write(chunk);
790
+ resetInactivity();
791
+ },
792
+ onStderr: (chunk) => {
793
+ stderrBuf.write(chunk);
794
+ resetInactivity();
795
+ }
796
+ });
797
+ } finally {
798
+ if (inactivityTimer) clearTimeout(inactivityTimer);
799
+ clearTimeout(wallclockTimer);
800
+ if (session.signal) {
801
+ session.signal.removeEventListener("abort", abortOnOuter);
802
+ }
803
+ }
804
+ const durationMs = Date.now() - start;
805
+ const stdoutRender = stdoutBuf.render();
806
+ const stderrRender = stderrBuf.render();
807
+ if (timedOut !== null) {
808
+ const logPath2 = stdoutRender.logPath ?? stderrRender.logPath;
809
+ return {
810
+ kind: "timeout",
811
+ output: formatTimeoutText({
812
+ command,
813
+ stdout: stdoutRender.text,
814
+ stderr: stderrRender.text,
815
+ reason: timedOut,
816
+ durationMs,
817
+ partialBytes: stdoutBuf.bytesTotal() + stderrBuf.bytesTotal(),
818
+ logPath: logPath2
819
+ }),
820
+ stdout: stdoutRender.text,
821
+ stderr: stderrRender.text,
822
+ reason: timedOut,
823
+ durationMs,
824
+ ...logPath2 ? { logPath: logPath2 } : {}
825
+ };
826
+ }
827
+ const exitCode = result.exitCode ?? -1;
828
+ const kind = exitCode === 0 ? "ok" : "nonzero_exit";
829
+ const logPath = stdoutRender.logPath ?? stderrRender.logPath;
830
+ const byteCap = stdoutRender.byteCap || stderrRender.byteCap;
831
+ return {
832
+ kind,
833
+ output: formatResultText({
834
+ command,
835
+ exitCode,
836
+ stdout: stdoutRender.text,
837
+ stderr: stderrRender.text,
838
+ durationMs,
839
+ byteCap,
840
+ logPath,
841
+ kind
842
+ }),
843
+ exitCode,
844
+ stdout: stdoutRender.text,
845
+ stderr: stderrRender.text,
846
+ durationMs,
847
+ ...logPath ? { logPath } : {},
848
+ byteCap
849
+ };
850
+ }
851
+ async function bashOutput(input, session) {
852
+ const parsed = safeParseBashOutputParams(input);
853
+ if (!parsed.ok) {
854
+ const messages = parsed.issues.map((i) => i.message).join("; ");
855
+ return err(toolError("INVALID_PARAM", messages));
856
+ }
857
+ const executor = session.executor ?? createLocalBashExecutor();
858
+ if (!executor.readBackground) {
859
+ return err(
860
+ toolError(
861
+ "INVALID_PARAM",
862
+ "bash_output is not supported by this executor adapter."
863
+ )
864
+ );
865
+ }
866
+ try {
867
+ const read = await executor.readBackground(parsed.value.job_id, {
868
+ ...parsed.value.since_byte !== void 0 ? { since_byte: parsed.value.since_byte } : {},
869
+ ...parsed.value.head_limit !== void 0 ? { head_limit: parsed.value.head_limit } : {}
870
+ });
871
+ const sinceByte = parsed.value.since_byte ?? 0;
872
+ const returnedBytes = byteLength(read.stdout) + byteLength(read.stderr);
873
+ const totalBytes = read.totalBytesStdout + read.totalBytesStderr;
874
+ return {
875
+ kind: "output",
876
+ output: formatBashOutputText({
877
+ jobId: parsed.value.job_id,
878
+ running: read.running,
879
+ exitCode: read.exitCode,
880
+ stdout: read.stdout,
881
+ stderr: read.stderr,
882
+ sinceByte,
883
+ returnedBytes,
884
+ totalBytes
885
+ }),
886
+ running: read.running,
887
+ exitCode: read.exitCode,
888
+ stdout: read.stdout,
889
+ stderr: read.stderr,
890
+ totalBytesStdout: read.totalBytesStdout,
891
+ totalBytesStderr: read.totalBytesStderr,
892
+ nextSinceByte: sinceByte + returnedBytes
893
+ };
894
+ } catch (e) {
895
+ return err(
896
+ toolError(
897
+ "NOT_FOUND",
898
+ e.message || `Unknown job_id: ${parsed.value.job_id}`
899
+ )
900
+ );
901
+ }
902
+ }
903
+ async function bashKill(input, session) {
904
+ const parsed = safeParseBashKillParams(input);
905
+ if (!parsed.ok) {
906
+ const messages = parsed.issues.map((i) => i.message).join("; ");
907
+ return err(toolError("INVALID_PARAM", messages));
908
+ }
909
+ const executor = session.executor ?? createLocalBashExecutor();
910
+ if (!executor.killBackground) {
911
+ return err(
912
+ toolError(
913
+ "INVALID_PARAM",
914
+ "bash_kill is not supported by this executor adapter."
915
+ )
916
+ );
917
+ }
918
+ const signal = parsed.value.signal ?? "SIGTERM";
919
+ await executor.killBackground(parsed.value.job_id, signal);
920
+ return {
921
+ kind: "killed",
922
+ output: formatBashKillText({ jobId: parsed.value.job_id, signal }),
923
+ jobId: parsed.value.job_id,
924
+ signal
925
+ };
926
+ }
927
+ function applyCwdCarry(session, command, exitCode) {
928
+ if (exitCode !== 0) {
929
+ return { changed: false, newCwd: null, escaped: false };
930
+ }
931
+ const target = detectTopLevelCd(command);
932
+ if (target === null) {
933
+ return { changed: false, newCwd: null, escaped: false };
934
+ }
935
+ const base = session.logicalCwd?.value ?? session.cwd;
936
+ const resolved = path4.isAbsolute(target) ? path4.resolve(target) : path4.resolve(base, target);
937
+ const inside = session.permissions.roots.some(
938
+ (r) => resolved === r || resolved.startsWith(r + path4.sep)
939
+ );
940
+ if (!inside && session.permissions.bypassWorkspaceGuard !== true) {
941
+ return { changed: false, newCwd: resolved, escaped: true };
942
+ }
943
+ if (session.logicalCwd) {
944
+ session.logicalCwd.value = resolved;
945
+ }
946
+ return { changed: true, newCwd: resolved, escaped: false };
947
+ }
948
+
949
+ export { BACKGROUND_MAX_JOBS, BASH_KILL_TOOL_DESCRIPTION, BASH_KILL_TOOL_NAME, BASH_OUTPUT_TOOL_DESCRIPTION, BASH_OUTPUT_TOOL_NAME, BASH_TOOL_DESCRIPTION, BASH_TOOL_NAME, BashKillParamsSchema, BashOutputParamsSchema, BashParamsSchema, DEFAULT_INACTIVITY_TIMEOUT_MS, DEFAULT_WALLCLOCK_BACKSTOP_MS, HeadTailBuffer, MAX_COMMAND_LENGTH, MAX_OUTPUT_BYTES_FILE, MAX_OUTPUT_BYTES_INLINE, SENSITIVE_ENV_PREFIXES, applyCwdCarry, bash, bashKill, bashKillToolDefinition, bashOutput, bashOutputToolDefinition, bashToolDefinition, createLocalBashExecutor, detectTopLevelCd, formatBackgroundStartedText, formatBashKillText, formatBashOutputText, formatResultText, formatTimeoutText, safeParseBashKillParams, safeParseBashOutputParams, safeParseBashParams };
950
+ //# sourceMappingURL=index.js.map
951
+ //# sourceMappingURL=index.js.map