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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Splits a complex bash command into individual simple commands by shell operators (&&, ||, ;, |, &).
3
+ * Correctly handles quotes, escaped characters, and subshells.
4
+ */
5
+ export function splitBashCommand(command) {
6
+ let inSingleQuote = false;
7
+ let inDoubleQuote = false;
8
+ let escaped = false;
9
+ let parenLevel = 0;
10
+ const splitPositions = [];
11
+ for (let i = 0; i < command.length; i++) {
12
+ const char = command[i];
13
+ const nextChar = command[i + 1];
14
+ if (escaped) {
15
+ escaped = false;
16
+ continue;
17
+ }
18
+ if (char === "\\") {
19
+ escaped = true;
20
+ continue;
21
+ }
22
+ if (char === "'" && !inDoubleQuote) {
23
+ inSingleQuote = !inSingleQuote;
24
+ continue;
25
+ }
26
+ if (char === '"' && !inSingleQuote) {
27
+ inDoubleQuote = !inDoubleQuote;
28
+ continue;
29
+ }
30
+ if (inSingleQuote || inDoubleQuote) {
31
+ continue;
32
+ }
33
+ if (char === "(") {
34
+ parenLevel++;
35
+ continue;
36
+ }
37
+ if (char === ")") {
38
+ parenLevel--;
39
+ continue;
40
+ }
41
+ if (parenLevel > 0) {
42
+ continue;
43
+ }
44
+ // Check for operators
45
+ let opLen = 0;
46
+ if (char === "&" && nextChar === "&")
47
+ opLen = 2;
48
+ else if (char === "|" && nextChar === "|")
49
+ opLen = 2;
50
+ else if (char === "|" && nextChar === "&")
51
+ opLen = 2;
52
+ else if (char === ";")
53
+ opLen = 1;
54
+ else if (char === "|")
55
+ opLen = 1;
56
+ else if (char === "&" && nextChar !== ">")
57
+ opLen = 1;
58
+ if (opLen > 0) {
59
+ // Check if preceded by an odd number of backslashes
60
+ let backslashCount = 0;
61
+ for (let j = i - 1; j >= 0; j--) {
62
+ if (command[j] === "\\")
63
+ backslashCount++;
64
+ else
65
+ break;
66
+ }
67
+ // ALSO check if preceded by an escaped operator character (e.g., \&&)
68
+ let precededByEscapedOp = false;
69
+ if (i > 0 && /[&|;]/.test(command[i - 1])) {
70
+ let bsCount = 0;
71
+ for (let j = i - 2; j >= 0; j--) {
72
+ if (command[j] === "\\")
73
+ bsCount++;
74
+ else
75
+ break;
76
+ }
77
+ if (bsCount % 2 !== 0)
78
+ precededByEscapedOp = true;
79
+ }
80
+ if (backslashCount % 2 === 0 && !precededByEscapedOp) {
81
+ splitPositions.push(i, i + opLen);
82
+ i += opLen - 1;
83
+ }
84
+ }
85
+ }
86
+ let lastPos = 0;
87
+ const parts = [];
88
+ for (let i = 0; i < splitPositions.length; i += 2) {
89
+ const start = splitPositions[i];
90
+ const end = splitPositions[i + 1];
91
+ const part = command.substring(lastPos, start).trim();
92
+ if (part)
93
+ parts.push(part);
94
+ lastPos = end;
95
+ }
96
+ const lastPart = command.substring(lastPos).trim();
97
+ if (lastPart)
98
+ parts.push(lastPart);
99
+ const finalResult = [];
100
+ for (const part of parts) {
101
+ const stripped = stripRedirections(stripEnvVars(part));
102
+ if (stripped.startsWith("(") && stripped.endsWith(")")) {
103
+ const inner = stripped.substring(1, stripped.length - 1).trim();
104
+ if (inner) {
105
+ finalResult.push(...splitBashCommand(inner));
106
+ }
107
+ }
108
+ else {
109
+ finalResult.push(part);
110
+ }
111
+ }
112
+ return finalResult;
113
+ }
114
+ /**
115
+ * Removes inline environment variable assignments (e.g., VAR=val cmd -> cmd).
116
+ */
117
+ export function stripEnvVars(command) {
118
+ let result = command.trim();
119
+ while (true) {
120
+ const match = result.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=/);
121
+ if (!match)
122
+ break;
123
+ const varNameEnd = match[0].length;
124
+ let valueEnd = varNameEnd;
125
+ if (result[varNameEnd] === "'") {
126
+ valueEnd = result.indexOf("'", varNameEnd + 1);
127
+ if (valueEnd === -1)
128
+ break;
129
+ valueEnd++;
130
+ }
131
+ else if (result[varNameEnd] === '"') {
132
+ let escaped = false;
133
+ let found = false;
134
+ for (let i = varNameEnd + 1; i < result.length; i++) {
135
+ if (escaped) {
136
+ escaped = false;
137
+ continue;
138
+ }
139
+ if (result[i] === "\\") {
140
+ escaped = true;
141
+ continue;
142
+ }
143
+ if (result[i] === '"') {
144
+ valueEnd = i + 1;
145
+ found = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!found)
150
+ break;
151
+ }
152
+ else {
153
+ const spaceIndex = result.search(/\s/);
154
+ if (spaceIndex === -1) {
155
+ return "";
156
+ }
157
+ valueEnd = spaceIndex;
158
+ }
159
+ result = result.substring(valueEnd).trim();
160
+ }
161
+ return result;
162
+ }
163
+ /**
164
+ * Removes redirections (e.g., echo "data" > output.txt -> echo "data").
165
+ */
166
+ export function stripRedirections(command) {
167
+ let result = "";
168
+ let inSingleQuote = false;
169
+ let inDoubleQuote = false;
170
+ let escaped = false;
171
+ for (let i = 0; i < command.length; i++) {
172
+ const char = command[i];
173
+ if (escaped) {
174
+ result += char;
175
+ escaped = false;
176
+ continue;
177
+ }
178
+ if (char === "\\") {
179
+ result += char;
180
+ escaped = true;
181
+ continue;
182
+ }
183
+ if (char === "'" && !inDoubleQuote) {
184
+ inSingleQuote = !inSingleQuote;
185
+ result += char;
186
+ continue;
187
+ }
188
+ if (char === '"' && !inSingleQuote) {
189
+ inDoubleQuote = !inDoubleQuote;
190
+ result += char;
191
+ continue;
192
+ }
193
+ if (inSingleQuote || inDoubleQuote) {
194
+ result += char;
195
+ continue;
196
+ }
197
+ // Handle whitespace outside quotes: collapse multiple spaces into one
198
+ if (/\s/.test(char)) {
199
+ if (result.length > 0 && !/\s/.test(result[result.length - 1])) {
200
+ result += " ";
201
+ }
202
+ continue;
203
+ }
204
+ // Check for redirection
205
+ if (char === ">" || char === "<") {
206
+ // Check if preceded by a digit or & (for 2> or &>)
207
+ if (result.length > 0 && /[0-9&]/.test(result[result.length - 1])) {
208
+ // Ensure it's at the start of a word or preceded by whitespace
209
+ if (result.length === 1 || /\s/.test(result[result.length - 2])) {
210
+ // Remove the digit/& from result
211
+ result = result.substring(0, result.length - 1);
212
+ }
213
+ }
214
+ let end = i + 1;
215
+ if (command[end] === char) {
216
+ end++;
217
+ if (char === "<" && command[end] === "-") {
218
+ end++;
219
+ }
220
+ }
221
+ else if (command[end] === "&" ||
222
+ (char === ">" && command[end] === "|")) {
223
+ end++;
224
+ }
225
+ // Skip whitespace after operator
226
+ while (end < command.length && /\s/.test(command[end])) {
227
+ end++;
228
+ }
229
+ // Skip the following word (the target of redirection)
230
+ let wordEscaped = false;
231
+ let wordInSingleQuote = false;
232
+ let wordInDoubleQuote = false;
233
+ while (end < command.length) {
234
+ const c = command[end];
235
+ if (wordEscaped) {
236
+ wordEscaped = false;
237
+ end++;
238
+ continue;
239
+ }
240
+ if (c === "\\") {
241
+ wordEscaped = true;
242
+ end++;
243
+ continue;
244
+ }
245
+ if (c === "'" && !wordInDoubleQuote) {
246
+ wordInSingleQuote = !wordInSingleQuote;
247
+ end++;
248
+ continue;
249
+ }
250
+ if (c === '"' && !wordInSingleQuote) {
251
+ wordInDoubleQuote = !wordInDoubleQuote;
252
+ end++;
253
+ continue;
254
+ }
255
+ if (!wordInSingleQuote && !wordInDoubleQuote && /\s/.test(c)) {
256
+ break;
257
+ }
258
+ end++;
259
+ }
260
+ i = end - 1;
261
+ // After stripping a redirection, ensure there's a space if we're not at the end
262
+ if (result.length > 0 && !/\s/.test(result[result.length - 1])) {
263
+ result += " ";
264
+ }
265
+ continue;
266
+ }
267
+ result += char;
268
+ }
269
+ return result.trim();
270
+ }
271
+ /**
272
+ * Blacklist of dangerous commands that should not be safely prefix-matched
273
+ * and should not have persistent permissions.
274
+ */
275
+ export const DANGEROUS_COMMANDS = [
276
+ "rm",
277
+ "mv",
278
+ "chmod",
279
+ "chown",
280
+ "sh",
281
+ "bash",
282
+ "sudo",
283
+ "dd",
284
+ "apt",
285
+ "apt-get",
286
+ "yum",
287
+ "dnf",
288
+ ];
289
+ /**
290
+ * Extracts a "smart prefix" from a bash command based on common developer tools.
291
+ * Returns null if the command is blacklisted or cannot be safely prefix-matched.
292
+ */
293
+ export function getSmartPrefix(command) {
294
+ const parts = splitBashCommand(command);
295
+ if (parts.length === 0)
296
+ return null;
297
+ // For now, we only support prefix matching for single commands or the first command in a chain
298
+ // to keep it simple and safe.
299
+ const firstCommand = parts[0];
300
+ let stripped = stripRedirections(stripEnvVars(firstCommand));
301
+ // Handle sudo
302
+ if (stripped.startsWith("sudo ")) {
303
+ stripped = stripped.substring(5).trim();
304
+ }
305
+ const tokens = stripped.split(/\s+/);
306
+ if (tokens.length === 0)
307
+ return null;
308
+ const exe = tokens[0];
309
+ const sub = tokens[1];
310
+ // Blacklist - Hard blacklist for dangerous commands
311
+ if (DANGEROUS_COMMANDS.includes(exe))
312
+ return null;
313
+ // Node/JS
314
+ if (["npm", "pnpm", "yarn", "deno", "bun"].includes(exe)) {
315
+ if ([
316
+ "install",
317
+ "i",
318
+ "add",
319
+ "remove",
320
+ "test",
321
+ "t",
322
+ "build",
323
+ "start",
324
+ "dev",
325
+ ].includes(sub)) {
326
+ return `${exe} ${sub}`;
327
+ }
328
+ if (sub === "run" && tokens[2]) {
329
+ return `${exe} run ${tokens[2]}`;
330
+ }
331
+ return exe;
332
+ }
333
+ // Git
334
+ if (exe === "git") {
335
+ if ([
336
+ "commit",
337
+ "push",
338
+ "pull",
339
+ "checkout",
340
+ "add",
341
+ "status",
342
+ "diff",
343
+ "branch",
344
+ "merge",
345
+ "rebase",
346
+ "log",
347
+ "fetch",
348
+ "remote",
349
+ "stash",
350
+ ].includes(sub)) {
351
+ return `${exe} ${sub}`;
352
+ }
353
+ return exe;
354
+ }
355
+ // Python
356
+ if (["python", "python3", "pip", "pip3", "poetry", "conda"].includes(exe)) {
357
+ if (exe === "python" || exe === "python3") {
358
+ if (sub === "-m" && tokens[2] === "pip" && tokens[3] === "install") {
359
+ return `${exe} -m pip install`;
360
+ }
361
+ return exe;
362
+ }
363
+ if (["install", "add", "remove", "test", "run"].includes(sub)) {
364
+ return `${exe} ${sub}`;
365
+ }
366
+ return exe;
367
+ }
368
+ // Java
369
+ if (["mvn", "gradle"].includes(exe)) {
370
+ if (sub && !sub.startsWith("-")) {
371
+ return `${exe} ${sub}`;
372
+ }
373
+ return exe;
374
+ }
375
+ if (exe === "java") {
376
+ if (sub === "-jar")
377
+ return "java -jar";
378
+ return "java";
379
+ }
380
+ // Rust & Go
381
+ if (exe === "cargo") {
382
+ if (["build", "test", "run", "add", "check"].includes(sub)) {
383
+ return `${exe} ${sub}`;
384
+ }
385
+ return exe;
386
+ }
387
+ if (exe === "go") {
388
+ if (["build", "test", "run", "get", "mod"].includes(sub)) {
389
+ return `${exe} ${sub}`;
390
+ }
391
+ return exe;
392
+ }
393
+ // Containers & Infrastructure
394
+ if (exe === "docker" || exe === "docker-compose") {
395
+ if (["run", "build", "ps", "exec", "up", "down"].includes(sub)) {
396
+ return `${exe} ${sub}`;
397
+ }
398
+ return exe;
399
+ }
400
+ if (exe === "kubectl") {
401
+ if (["get", "describe", "apply", "logs"].includes(sub)) {
402
+ return `${exe} ${sub}`;
403
+ }
404
+ return exe;
405
+ }
406
+ if (exe === "terraform") {
407
+ if (["plan", "apply", "destroy", "init"].includes(sub)) {
408
+ return `${exe} ${sub}`;
409
+ }
410
+ return exe;
411
+ }
412
+ return null;
413
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Check if a target path is inside a parent directory.
3
+ * Resolves symlinks and handles absolute paths.
4
+ * Returns true if target is the same as parent or is a subdirectory of parent.
5
+ * @param target The path to check
6
+ * @param parent The parent directory path
7
+ * @returns boolean
8
+ */
9
+ export declare function isPathInside(target: string, parent: string): boolean;
10
+ //# sourceMappingURL=pathSafety.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pathSafety.d.ts","sourceRoot":"","sources":["../../src/utils/pathSafety.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAcpE"}
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ /**
4
+ * Check if a target path is inside a parent directory.
5
+ * Resolves symlinks and handles absolute paths.
6
+ * Returns true if target is the same as parent or is a subdirectory of parent.
7
+ * @param target The path to check
8
+ * @param parent The parent directory path
9
+ * @returns boolean
10
+ */
11
+ export function isPathInside(target, parent) {
12
+ try {
13
+ const absoluteTarget = path.resolve(target);
14
+ const absoluteParent = path.resolve(parent);
15
+ const realTarget = fs.realpathSync(absoluteTarget);
16
+ const realParent = fs.realpathSync(absoluteParent);
17
+ const relative = path.relative(realParent, realTarget);
18
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-agent-sdk",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "SDK for building AI-powered development tools and agents",
5
5
  "keywords": [
6
6
  "ai",
@@ -42,6 +42,9 @@
42
42
  "watch": "tsc -p tsconfig.build.json --watch & tsc-alias -p tsconfig.build.json --watch",
43
43
  "test": "vitest run",
44
44
  "lint": "eslint --cache",
45
- "format": "prettier --write ."
45
+ "format": "prettier --write .",
46
+ "version:patch": "node ../../scripts/version.js patch",
47
+ "version:minor": "node ../../scripts/version.js minor",
48
+ "version:major": "node ../../scripts/version.js major"
46
49
  }
47
50
  }
package/src/agent.ts CHANGED
@@ -1114,20 +1114,35 @@ export class Agent {
1114
1114
  * @param rule - The rule to add (e.g., "Bash(ls)")
1115
1115
  */
1116
1116
  private async addPermissionRule(rule: string): Promise<void> {
1117
- // 1. Update PermissionManager state
1118
- const currentRules = this.permissionManager.getAllowedRules();
1119
- if (!currentRules.includes(rule)) {
1120
- this.permissionManager.updateAllowedRules([...currentRules, rule]);
1117
+ // 1. Expand rule if it's a Bash command
1118
+ let rulesToAdd = [rule];
1119
+ const bashMatch = rule.match(/^Bash\((.*)\)$/);
1120
+ if (bashMatch) {
1121
+ const command = bashMatch[1];
1122
+ rulesToAdd = this.permissionManager.expandBashRule(command, this.workdir);
1123
+ }
1121
1124
 
1122
- // 2. Persist to settings.local.json
1123
- try {
1124
- await this.configurationService.addAllowedRule(this.workdir, rule);
1125
- this.logger?.debug("Persistent permission rule added", { rule });
1126
- } catch (error) {
1127
- this.logger?.error("Failed to persist permission rule", {
1128
- rule,
1129
- error: error instanceof Error ? error.message : String(error),
1130
- });
1125
+ for (const ruleToAdd of rulesToAdd) {
1126
+ // 2. Update PermissionManager state
1127
+ const currentRules = this.permissionManager.getAllowedRules();
1128
+ if (!currentRules.includes(ruleToAdd)) {
1129
+ this.permissionManager.updateAllowedRules([...currentRules, ruleToAdd]);
1130
+
1131
+ // 3. Persist to settings.local.json
1132
+ try {
1133
+ await this.configurationService.addAllowedRule(
1134
+ this.workdir,
1135
+ ruleToAdd,
1136
+ );
1137
+ this.logger?.debug("Persistent permission rule added", {
1138
+ rule: ruleToAdd,
1139
+ });
1140
+ } catch (error) {
1141
+ this.logger?.error("Failed to persist permission rule", {
1142
+ rule: ruleToAdd,
1143
+ error: error instanceof Error ? error.message : String(error),
1144
+ });
1145
+ }
1131
1146
  }
1132
1147
  }
1133
1148
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./agent.js";
11
11
 
12
12
  // Export all utilities
13
13
  export * from "./utils/bashHistory.js";
14
+ export * from "./utils/bashParser.js";
14
15
  export * from "./utils/convertMessagesForAPI.js";
15
16
  export * from "./utils/fileFilter.js";
16
17
  export * from "./utils/fileSearch.js";