wave-agent-sdk 0.0.10 → 0.0.11

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.
@@ -6,6 +6,7 @@
6
6
  * handles custom callback integration.
7
7
  */
8
8
 
9
+ import path from "node:path";
9
10
  import type {
10
11
  PermissionDecision,
11
12
  ToolPermissionContext,
@@ -14,6 +15,16 @@ import type {
14
15
  } from "../types/permissions.js";
15
16
  import { RESTRICTED_TOOLS } from "../types/permissions.js";
16
17
  import type { Logger } from "../types/index.js";
18
+ import {
19
+ splitBashCommand,
20
+ stripEnvVars,
21
+ stripRedirections,
22
+ getSmartPrefix,
23
+ DANGEROUS_COMMANDS,
24
+ } from "../utils/bashParser.js";
25
+ import { isPathInside } from "../utils/pathSafety.js";
26
+
27
+ const SAFE_COMMANDS = ["cd", "ls", "pwd"];
17
28
 
18
29
  export interface PermissionManagerOptions {
19
30
  /** Logger for debugging permission decisions */
@@ -239,18 +250,71 @@ export class PermissionManager {
239
250
  callback?: PermissionCallback,
240
251
  toolInput?: Record<string, unknown>,
241
252
  ): ToolPermissionContext {
253
+ let suggestedPrefix: string | undefined;
254
+ if (toolName === "Bash" && toolInput?.command) {
255
+ const command = String(toolInput.command);
256
+ const parts = splitBashCommand(command);
257
+ // Only suggest prefix for single commands to avoid confusion with complex chains
258
+ if (parts.length === 1) {
259
+ const processedPart = stripRedirections(stripEnvVars(parts[0]));
260
+ suggestedPrefix = getSmartPrefix(processedPart) ?? undefined;
261
+ }
262
+ }
263
+
242
264
  const context: ToolPermissionContext = {
243
265
  toolName,
244
266
  permissionMode,
245
267
  canUseToolCallback: callback,
246
268
  toolInput,
269
+ suggestedPrefix,
247
270
  };
248
271
 
272
+ // Set hidePersistentOption for dangerous or out-of-bounds bash commands
273
+ if (toolName === "Bash" && toolInput?.command) {
274
+ const command = String(toolInput.command);
275
+ const workdir = toolInput.workdir as string | undefined;
276
+ const parts = splitBashCommand(command);
277
+
278
+ const isDangerous = parts.some((part) => {
279
+ const processedPart = stripRedirections(stripEnvVars(part));
280
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
281
+ if (commandMatch) {
282
+ const cmd = commandMatch[1];
283
+ const args = commandMatch[2]?.trim() || "";
284
+
285
+ // Check blacklist
286
+ if (DANGEROUS_COMMANDS.includes(cmd)) {
287
+ return true;
288
+ }
289
+
290
+ // Check out-of-bounds for cd and ls
291
+ if (workdir && (cmd === "cd" || cmd === "ls")) {
292
+ const pathArgs =
293
+ (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
294
+ (arg) => !arg.startsWith("-"),
295
+ ) || [];
296
+
297
+ return pathArgs.some((pathArg) => {
298
+ const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
299
+ const absolutePath = path.resolve(workdir, cleanPath);
300
+ return !isPathInside(absolutePath, workdir);
301
+ });
302
+ }
303
+ }
304
+ return false;
305
+ });
306
+
307
+ if (isDangerous) {
308
+ context.hidePersistentOption = true;
309
+ }
310
+ }
311
+
249
312
  this.logger?.debug("Created permission context", {
250
313
  toolName,
251
314
  permissionMode,
252
315
  hasCallback: !!callback,
253
316
  hasToolInput: !!toolInput,
317
+ suggestedPrefix,
254
318
  });
255
319
 
256
320
  return context;
@@ -261,16 +325,156 @@ export class PermissionManager {
261
325
  */
262
326
  private isAllowedByRule(context: ToolPermissionContext): boolean {
263
327
  if (context.toolName === "Bash" && context.toolInput?.command) {
264
- const action = `Bash(${context.toolInput.command})`;
265
- return this.allowedRules.some((rule) => {
266
- if (rule.endsWith(":*)")) {
267
- const prefix = rule.slice(0, -3);
268
- return action.startsWith(prefix);
328
+ const command = String(context.toolInput.command);
329
+ const parts = splitBashCommand(command);
330
+ if (parts.length === 0) return false;
331
+
332
+ const workdir = context.toolInput?.workdir as string | undefined;
333
+
334
+ return parts.every((part) => {
335
+ const processedPart = stripRedirections(stripEnvVars(part));
336
+
337
+ // Check for safe commands
338
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
339
+ if (commandMatch) {
340
+ const cmd = commandMatch[1];
341
+ const args = commandMatch[2]?.trim() || "";
342
+
343
+ if (SAFE_COMMANDS.includes(cmd)) {
344
+ if (workdir) {
345
+ if (cmd === "pwd") {
346
+ return true;
347
+ }
348
+
349
+ // For cd and ls, check paths
350
+ const pathArgs =
351
+ (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
352
+ (arg) => !arg.startsWith("-"),
353
+ ) || [];
354
+
355
+ if (pathArgs.length === 0) {
356
+ // cd or ls without arguments operates on current dir (workdir)
357
+ return true;
358
+ }
359
+
360
+ const allPathsSafe = pathArgs.every((pathArg) => {
361
+ // Remove quotes if present
362
+ const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
363
+ const absolutePath = path.resolve(workdir, cleanPath);
364
+ return isPathInside(absolutePath, workdir);
365
+ });
366
+
367
+ if (allPathsSafe) {
368
+ return true;
369
+ }
370
+ }
371
+ }
269
372
  }
270
- return action === rule;
373
+
374
+ const action = `${context.toolName}(${processedPart})`;
375
+ const allowedByRule = this.allowedRules.some((rule) => {
376
+ if (rule.endsWith(":*)")) {
377
+ const prefix = rule.slice(0, -3);
378
+ return action.startsWith(prefix);
379
+ }
380
+ return action === rule;
381
+ });
382
+
383
+ if (allowedByRule) return true;
384
+ return !this.isRestrictedTool(context.toolName);
271
385
  });
272
386
  }
273
387
  // Add other tools if needed in the future
274
388
  return false;
275
389
  }
390
+
391
+ /**
392
+ * Expand a bash command into individual permission rules, filtering out safe commands.
393
+ * Used when saving permissions to the allow list.
394
+ *
395
+ * @param command The full bash command string
396
+ * @param workdir The working directory for path safety checks
397
+ * @returns Array of permission rules in "Bash(cmd)" format
398
+ */
399
+ public expandBashRule(command: string, workdir: string): string[] {
400
+ const parts = splitBashCommand(command);
401
+ const rules: string[] = [];
402
+
403
+ for (const part of parts) {
404
+ const processedPart = stripRedirections(stripEnvVars(part));
405
+
406
+ // Check for safe commands
407
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
408
+ let isSafe = false;
409
+
410
+ if (commandMatch) {
411
+ const cmd = commandMatch[1];
412
+ const args = commandMatch[2]?.trim() || "";
413
+
414
+ if (SAFE_COMMANDS.includes(cmd)) {
415
+ if (cmd === "pwd") {
416
+ isSafe = true;
417
+ } else {
418
+ // For cd and ls, check paths
419
+ const pathArgs =
420
+ (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
421
+ (arg) => !arg.startsWith("-"),
422
+ ) || [];
423
+
424
+ if (pathArgs.length === 0) {
425
+ isSafe = true;
426
+ } else {
427
+ const allPathsSafe = pathArgs.every((pathArg) => {
428
+ const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
429
+ const absolutePath = path.resolve(workdir, cleanPath);
430
+ return isPathInside(absolutePath, workdir);
431
+ });
432
+ if (allPathsSafe) {
433
+ isSafe = true;
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ if (!isSafe) {
441
+ // Check if command is dangerous or out-of-bounds
442
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
443
+ if (commandMatch) {
444
+ const cmd = commandMatch[1];
445
+ const args = commandMatch[2]?.trim() || "";
446
+
447
+ if (DANGEROUS_COMMANDS.includes(cmd)) {
448
+ continue;
449
+ }
450
+
451
+ if (cmd === "cd" || cmd === "ls") {
452
+ const pathArgs =
453
+ (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
454
+ (arg) => !arg.startsWith("-"),
455
+ ) || [];
456
+
457
+ const isOutOfBounds = pathArgs.some((pathArg) => {
458
+ const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
459
+ const absolutePath = path.resolve(workdir, cleanPath);
460
+ return !isPathInside(absolutePath, workdir);
461
+ });
462
+
463
+ if (isOutOfBounds) {
464
+ continue;
465
+ }
466
+ }
467
+ }
468
+
469
+ const smartPrefix = getSmartPrefix(processedPart);
470
+ if (smartPrefix) {
471
+ rules.push(`Bash(${smartPrefix}:*)`);
472
+ } else {
473
+ rules.push(`Bash(${processedPart})`);
474
+ }
475
+ }
476
+ }
477
+
478
+ return rules;
479
+ }
276
480
  }
@@ -1,9 +1,11 @@
1
1
  import { spawn, ChildProcess } from "child_process";
2
2
  import { logger } from "../utils/globalLogger.js";
3
3
  import { stripAnsiColors } from "../utils/stringUtils.js";
4
- import { handleLargeOutput } from "../utils/largeOutputHandler.js";
5
4
  import type { ToolPlugin, ToolResult, ToolContext } from "./types.js";
6
5
 
6
+ const MAX_OUTPUT_LENGTH = 30000;
7
+ const BASH_DEFAULT_TIMEOUT_MS = 120000;
8
+
7
9
  /**
8
10
  * Bash command execution tool - supports both foreground and background execution
9
11
  */
@@ -13,8 +15,52 @@ export const bashTool: ToolPlugin = {
13
15
  type: "function",
14
16
  function: {
15
17
  name: "Bash",
16
- description:
17
- "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",
18
+ description: `Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
19
+
20
+ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
21
+
22
+ Before executing the command, please follow these steps:
23
+
24
+ 1. Directory Verification:
25
+ - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location
26
+ - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory
27
+
28
+ 2. Command Execution:
29
+ - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
30
+ - Examples of proper quoting:
31
+ - cd "/Users/name/My Documents" (correct)
32
+ - cd /Users/name/My Documents (incorrect - will fail)
33
+ - python "/path/with spaces/script.py" (correct)
34
+ - python /path/with spaces/script.py (incorrect - will fail)
35
+ - After ensuring proper quoting, execute the command.
36
+ - Capture the output of the command.
37
+
38
+ Usage notes:
39
+ - The command argument is required.
40
+ - You can specify an optional timeout in milliseconds (up to ${BASH_DEFAULT_TIMEOUT_MS}ms / ${BASH_DEFAULT_TIMEOUT_MS / 60000} minutes). If not specified, commands will timeout after ${BASH_DEFAULT_TIMEOUT_MS}ms (${BASH_DEFAULT_TIMEOUT_MS / 60000} minutes).
41
+ - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
42
+ - If the output exceeds ${MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you.
43
+ - You can use the \`run_in_background\` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
44
+ - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
45
+ - File search: Use Glob (NOT find or ls)
46
+ - Content search: Use Grep (NOT grep or rg)
47
+ - Read files: Use Read (NOT cat/head/tail)
48
+ - Edit files: Use Edit (NOT sed/awk)
49
+ - Write files: Use Write (NOT echo >/cat <<EOF)
50
+ - Communication: Output text directly (NOT echo/printf)
51
+ - When issuing multiple commands:
52
+ - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
53
+ - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
54
+ - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
55
+ - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
56
+ - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
57
+ <good-example>
58
+ pytest /foo/bar/tests
59
+ </good-example>
60
+ <bad-example>
61
+ cd /foo/bar && pytest tests
62
+ </bad-example>
63
+ `,
18
64
  parameters: {
19
65
  type: "object",
20
66
  properties: {
@@ -48,10 +94,10 @@ export const bashTool: ToolPlugin = {
48
94
  const command = args.command as string;
49
95
  const runInBackground = args.run_in_background as boolean | undefined;
50
96
  const description = args.description as string | undefined;
51
- // Set default timeout: 60s for foreground, no timeout for background
97
+ // Set default timeout: BASH_DEFAULT_TIMEOUT_MS for foreground, no timeout for background
52
98
  const timeout =
53
99
  (args.timeout as number | undefined) ??
54
- (runInBackground ? undefined : 60000);
100
+ (runInBackground ? undefined : BASH_DEFAULT_TIMEOUT_MS);
55
101
 
56
102
  if (!command || typeof command !== "string") {
57
103
  return {
@@ -90,6 +136,7 @@ export const bashTool: ToolPlugin = {
90
136
  description,
91
137
  run_in_background: runInBackground,
92
138
  timeout,
139
+ workdir: context.workdir,
93
140
  },
94
141
  );
95
142
  const permissionResult =
@@ -238,32 +285,23 @@ export const bashTool: ToolPlugin = {
238
285
  const combinedOutput =
239
286
  outputBuffer + (errorBuffer ? "\n" + errorBuffer : "");
240
287
 
241
- // Handle large output by writing to temp file if needed
288
+ // Handle large output by truncation if needed
242
289
  const finalOutput =
243
290
  combinedOutput || `Command executed with exit code: ${exitCode}`;
244
- handleLargeOutput(finalOutput)
245
- .then(({ content, filePath }) => {
246
- resolve({
247
- success: exitCode === 0,
248
- content,
249
- filePath,
250
- error:
251
- exitCode !== 0
252
- ? `Command failed with exit code: ${exitCode}`
253
- : undefined,
254
- });
255
- })
256
- .catch((error) => {
257
- logger.warn(`Error handling large output: ${error}`);
258
- resolve({
259
- success: exitCode === 0,
260
- content: finalOutput,
261
- error:
262
- exitCode !== 0
263
- ? `Command failed with exit code: ${exitCode}`
264
- : undefined,
265
- });
266
- });
291
+ const content =
292
+ finalOutput.length > MAX_OUTPUT_LENGTH
293
+ ? finalOutput.substring(0, MAX_OUTPUT_LENGTH) +
294
+ "\n\n... (output truncated)"
295
+ : finalOutput;
296
+
297
+ resolve({
298
+ success: exitCode === 0,
299
+ content,
300
+ error:
301
+ exitCode !== 0
302
+ ? `Command failed with exit code: ${exitCode}`
303
+ : undefined,
304
+ });
267
305
  }
268
306
  });
269
307
 
@@ -374,13 +412,15 @@ export const bashOutputTool: ToolPlugin = {
374
412
  }
375
413
 
376
414
  const finalContent = content || "No output available";
377
- const { content: processedContent, filePath } =
378
- await handleLargeOutput(finalContent);
415
+ const processedContent =
416
+ finalContent.length > MAX_OUTPUT_LENGTH
417
+ ? finalContent.substring(0, MAX_OUTPUT_LENGTH) +
418
+ "\n\n... (output truncated)"
419
+ : finalContent;
379
420
 
380
421
  return {
381
422
  success: true,
382
423
  content: processedContent,
383
- filePath,
384
424
  shortResult: `${bashId}: ${output.status}${shell.exitCode !== undefined ? ` (${shell.exitCode})` : ""}`,
385
425
  error: undefined,
386
426
  };
@@ -33,6 +33,10 @@ export interface ToolPermissionContext {
33
33
  canUseToolCallback?: PermissionCallback;
34
34
  /** Tool input parameters for better context */
35
35
  toolInput?: Record<string, unknown>;
36
+ /** Suggested prefix for bash commands */
37
+ suggestedPrefix?: string;
38
+ /** Whether to hide the persistent permission option (e.g., "Don't ask again") in the UI */
39
+ hidePersistentOption?: boolean;
36
40
  }
37
41
 
38
42
  /** List of tools that require permission checks in default mode */