wave-agent-sdk 0.19.8 → 1.0.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.
Files changed (95) hide show
  1. package/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
  2. package/builtin/plugins/sdd/hooks/hooks.json +14 -0
  3. package/builtin/plugins/sdd/scripts/session-start.js +24 -0
  4. package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
  5. package/builtin/plugins/sdd/skills/specify/SKILL.md +47 -0
  6. package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
  7. package/builtin/skills/settings/ENV.md +15 -9
  8. package/builtin/skills/settings/HOOKS.md +27 -2
  9. package/dist/agent.d.ts +1 -0
  10. package/dist/agent.js +26 -12
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/managers/aiManager.d.ts +25 -0
  14. package/dist/managers/aiManager.js +172 -51
  15. package/dist/managers/backgroundTaskManager.d.ts +6 -0
  16. package/dist/managers/backgroundTaskManager.js +11 -0
  17. package/dist/managers/bangManager.d.ts +6 -0
  18. package/dist/managers/bangManager.js +11 -0
  19. package/dist/managers/hookManager.d.ts +8 -2
  20. package/dist/managers/hookManager.js +14 -4
  21. package/dist/managers/mcpManager.d.ts +18 -4
  22. package/dist/managers/mcpManager.js +40 -18
  23. package/dist/managers/permissionManager.d.ts +7 -0
  24. package/dist/managers/permissionManager.js +102 -142
  25. package/dist/managers/pluginManager.d.ts +7 -0
  26. package/dist/managers/pluginManager.js +31 -0
  27. package/dist/managers/toolManager.js +5 -0
  28. package/dist/prompts/index.d.ts +12 -1
  29. package/dist/prompts/index.js +133 -45
  30. package/dist/services/aiService.d.ts +1 -17
  31. package/dist/services/aiService.js +3 -85
  32. package/dist/services/configurationService.d.ts +21 -2
  33. package/dist/services/configurationService.js +72 -23
  34. package/dist/services/initializationService.js +14 -4
  35. package/dist/services/interactionService.js +35 -7
  36. package/dist/services/remoteSettingsService.d.ts +12 -0
  37. package/dist/services/remoteSettingsService.js +15 -1
  38. package/dist/services/session.d.ts +3 -1
  39. package/dist/services/session.js +12 -4
  40. package/dist/services/taskManager.d.ts +1 -0
  41. package/dist/services/taskManager.js +41 -6
  42. package/dist/tools/bashTool.js +1 -0
  43. package/dist/tools/editTool.js +24 -10
  44. package/dist/tools/enterWorktreeTool.js +14 -3
  45. package/dist/tools/exitWorktreeTool.js +11 -10
  46. package/dist/tools/grepTool.js +8 -2
  47. package/dist/tools/types.d.ts +7 -0
  48. package/dist/tools/writeTool.js +36 -0
  49. package/dist/types/config.d.ts +2 -0
  50. package/dist/types/hooks.d.ts +2 -2
  51. package/dist/utils/bashParser.d.ts +25 -0
  52. package/dist/utils/bashParser.js +103 -0
  53. package/dist/utils/configPaths.d.ts +4 -0
  54. package/dist/utils/configPaths.js +6 -0
  55. package/dist/utils/containerSetup.js +1 -1
  56. package/dist/utils/fileSearch.js +4 -2
  57. package/dist/utils/openaiClient.js +2 -1
  58. package/dist/utils/pathEncoder.js +7 -2
  59. package/dist/utils/worktreeUtils.d.ts +17 -0
  60. package/dist/utils/worktreeUtils.js +339 -1
  61. package/package.json +1 -1
  62. package/src/agent.ts +26 -12
  63. package/src/index.ts +1 -0
  64. package/src/managers/aiManager.ts +238 -66
  65. package/src/managers/backgroundTaskManager.ts +15 -0
  66. package/src/managers/bangManager.ts +15 -0
  67. package/src/managers/hookManager.ts +20 -5
  68. package/src/managers/mcpManager.ts +60 -18
  69. package/src/managers/permissionManager.ts +116 -168
  70. package/src/managers/pluginManager.ts +29 -0
  71. package/src/managers/toolManager.ts +7 -0
  72. package/src/prompts/index.ts +144 -37
  73. package/src/services/aiService.ts +9 -128
  74. package/src/services/configurationService.ts +84 -23
  75. package/src/services/initializationService.ts +17 -4
  76. package/src/services/interactionService.ts +49 -6
  77. package/src/services/remoteSettingsService.ts +16 -1
  78. package/src/services/session.ts +18 -4
  79. package/src/services/taskManager.ts +56 -8
  80. package/src/tools/bashTool.ts +1 -0
  81. package/src/tools/editTool.ts +29 -11
  82. package/src/tools/enterWorktreeTool.ts +19 -2
  83. package/src/tools/exitWorktreeTool.ts +15 -12
  84. package/src/tools/grepTool.ts +11 -2
  85. package/src/tools/types.ts +7 -0
  86. package/src/tools/writeTool.ts +43 -0
  87. package/src/types/config.ts +2 -0
  88. package/src/types/hooks.ts +2 -2
  89. package/src/utils/bashParser.ts +106 -0
  90. package/src/utils/configPaths.ts +7 -0
  91. package/src/utils/containerSetup.ts +3 -1
  92. package/src/utils/fileSearch.ts +6 -2
  93. package/src/utils/openaiClient.ts +2 -0
  94. package/src/utils/pathEncoder.ts +7 -2
  95. package/src/utils/worktreeUtils.ts +401 -1
@@ -9,6 +9,7 @@ import { ChatCompletionFunctionTool } from "openai/resources.js";
9
9
  import { createMcpToolPlugin, findToolServer } from "../utils/mcpUtils.js";
10
10
  import type { ToolPlugin, ToolResult, ToolContext } from "../tools/types.js";
11
11
  import { Container } from "../utils/container.js";
12
+ import type { ConfigurationService } from "../services/configurationService.js";
12
13
  import type {
13
14
  Logger,
14
15
  McpServerConfig,
@@ -42,7 +43,21 @@ export interface McpManagerOptions {
42
43
  */
43
44
  const WAVE_TEMPLATE_VARS = ["WAVE_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"];
44
45
 
45
- export function expandEnvVars(value: string): string {
46
+ /**
47
+ * Expand environment variables in a string value.
48
+ * Supports ${VAR} and ${VAR:-default} patterns.
49
+ *
50
+ * @param env - Merged env to resolve against (session snapshot over OS env).
51
+ * Defaults to `process.env` for backward compatibility with tests
52
+ * and standalone callers.
53
+ */
54
+ export function expandEnvVars(
55
+ value: string,
56
+ env: Record<string, string | undefined> = process.env as Record<
57
+ string,
58
+ string | undefined
59
+ >,
60
+ ): string {
46
61
  return value.replace(/\$\{([^}]+)\}/g, (_match, expr: string) => {
47
62
  const [varName, ...rest] = expr.split(":-");
48
63
  const defaultValue = rest.join(":-");
@@ -50,45 +65,53 @@ export function expandEnvVars(value: string): string {
50
65
  if (WAVE_TEMPLATE_VARS.includes(varName)) {
51
66
  return _match; // return original ${...} string untouched
52
67
  }
53
- return process.env[varName] ?? defaultValue;
68
+ return env[varName] ?? process.env[varName] ?? defaultValue;
54
69
  });
55
70
  }
56
71
 
57
72
  /**
58
73
  * Walk an MCP config and resolve environment variables in all string fields.
59
- * Only expands ${VAR} from process.env (skipping WAVE_PLUGIN_ROOT which is
60
- * handled at spawn time).
74
+ * Expands ${VAR} against the session env snapshot (over OS env); falls back to
75
+ * process.env. WAVE_PLUGIN_ROOT is skipped — handled at spawn time.
61
76
  */
62
- export function resolveMcpConfig(config: McpConfig): McpConfig {
77
+ export function resolveMcpConfig(
78
+ config: McpConfig,
79
+ env: Record<string, string | undefined> = process.env as Record<
80
+ string,
81
+ string | undefined
82
+ >,
83
+ ): McpConfig {
63
84
  const resolved: McpConfig = { mcpServers: {} };
64
85
 
65
86
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
66
87
  const resolvedServer: McpServerConfig = { ...serverConfig };
67
88
 
68
89
  if (resolvedServer.command) {
69
- resolvedServer.command = expandEnvVars(resolvedServer.command);
90
+ resolvedServer.command = expandEnvVars(resolvedServer.command, env);
70
91
  }
71
92
 
72
93
  if (resolvedServer.args) {
73
- resolvedServer.args = resolvedServer.args.map(expandEnvVars);
94
+ resolvedServer.args = resolvedServer.args.map((a) =>
95
+ expandEnvVars(a, env),
96
+ );
74
97
  }
75
98
 
76
99
  if (resolvedServer.env) {
77
100
  const resolvedEnv: Record<string, string> = {};
78
101
  for (const [key, val] of Object.entries(resolvedServer.env)) {
79
- resolvedEnv[key] = expandEnvVars(val);
102
+ resolvedEnv[key] = expandEnvVars(val, env);
80
103
  }
81
104
  resolvedServer.env = resolvedEnv;
82
105
  }
83
106
 
84
107
  if (resolvedServer.url) {
85
- resolvedServer.url = expandEnvVars(resolvedServer.url);
108
+ resolvedServer.url = expandEnvVars(resolvedServer.url, env);
86
109
  }
87
110
 
88
111
  if (resolvedServer.headers) {
89
112
  const resolvedHeaders: Record<string, string> = {};
90
113
  for (const [key, val] of Object.entries(resolvedServer.headers)) {
91
- resolvedHeaders[key] = expandEnvVars(val);
114
+ resolvedHeaders[key] = expandEnvVars(val, env);
92
115
  }
93
116
  resolvedServer.headers = resolvedHeaders;
94
117
  }
@@ -119,6 +142,19 @@ export class McpManager {
119
142
  this.mcpServers = options.mcpServers;
120
143
  }
121
144
 
145
+ /**
146
+ * Merged env for this session: OS env overlaid with the per-session settings
147
+ * snapshot. MCP env-var expansion (${VAR}) resolves against this so multiple
148
+ * sessions in one `wave --stdio` process don't read each other's settings env.
149
+ */
150
+ private get envSnapshot(): Record<string, string> {
151
+ return (
152
+ this.container
153
+ .get<ConfigurationService>("ConfigurationService")
154
+ ?.getMergedEnv?.() ?? (process.env as Record<string, string>)
155
+ );
156
+ }
157
+
122
158
  /**
123
159
  * Initialize MCP manager with working directory and optionally auto-connect
124
160
  */
@@ -189,7 +225,7 @@ export class McpManager {
189
225
  try {
190
226
  const configContent = await fs.readFile(this.configPath, "utf-8");
191
227
  const rawConfig: McpConfig = JSON.parse(configContent);
192
- const workspaceConfig = resolveMcpConfig(rawConfig);
228
+ const workspaceConfig = resolveMcpConfig(rawConfig, this.envSnapshot);
193
229
 
194
230
  // Extract original (pre-resolution) URLs for safe display
195
231
  const originalUrls: Record<string, string | undefined> = {};
@@ -284,28 +320,31 @@ export class McpManager {
284
320
  // Capture original URL before any resolution for safe display
285
321
  const originalUrl = config.url;
286
322
 
287
- // Expand env vars from process.env (e.g. ${TAVILY_API_KEY})
323
+ // Expand env vars against the session snapshot (over OS env, e.g. ${TAVILY_API_KEY})
324
+ const env = this.envSnapshot;
288
325
  const resolvedConfig: McpServerConfig = { ...config };
289
326
  if (resolvedConfig.command) {
290
- resolvedConfig.command = expandEnvVars(resolvedConfig.command);
327
+ resolvedConfig.command = expandEnvVars(resolvedConfig.command, env);
291
328
  }
292
329
  if (resolvedConfig.args) {
293
- resolvedConfig.args = resolvedConfig.args.map(expandEnvVars);
330
+ resolvedConfig.args = resolvedConfig.args.map((a) =>
331
+ expandEnvVars(a, env),
332
+ );
294
333
  }
295
334
  if (resolvedConfig.env) {
296
335
  const resolvedEnv: Record<string, string> = {};
297
336
  for (const [key, val] of Object.entries(resolvedConfig.env)) {
298
- resolvedEnv[key] = expandEnvVars(val);
337
+ resolvedEnv[key] = expandEnvVars(val, env);
299
338
  }
300
339
  resolvedConfig.env = resolvedEnv;
301
340
  }
302
341
  if (resolvedConfig.url) {
303
- resolvedConfig.url = expandEnvVars(resolvedConfig.url);
342
+ resolvedConfig.url = expandEnvVars(resolvedConfig.url, env);
304
343
  }
305
344
  if (resolvedConfig.headers) {
306
345
  const resolvedHeaders: Record<string, string> = {};
307
346
  for (const [key, val] of Object.entries(resolvedConfig.headers)) {
308
- resolvedHeaders[key] = expandEnvVars(val);
347
+ resolvedHeaders[key] = expandEnvVars(val, env);
309
348
  }
310
349
  resolvedConfig.headers = resolvedHeaders;
311
350
  }
@@ -433,8 +472,11 @@ export class McpManager {
433
472
  );
434
473
  }
435
474
 
475
+ // Base env = OS env overlaid with this session's settings snapshot, so
476
+ // custom env vars from settings.json reach the MCP server subprocess
477
+ // without polluting other sessions sharing one `wave --stdio` process.
436
478
  const env: Record<string, string> = {
437
- ...(process.env as Record<string, string>),
479
+ ...this.envSnapshot,
438
480
  ...(server.config.env || {}),
439
481
  };
440
482
 
@@ -23,7 +23,11 @@ import {
23
23
  hasWriteRedirections,
24
24
  getSmartPrefix,
25
25
  isDangerousFind,
26
+ hasCommandSubstitution,
27
+ hasProcessSubstitution,
28
+ hasSedInPlace,
26
29
  DANGEROUS_COMMANDS,
30
+ READ_ONLY_COMMANDS,
27
31
  } from "../utils/bashParser.js";
28
32
  import { isPathInside } from "../utils/pathSafety.js";
29
33
  import {
@@ -37,23 +41,6 @@ import { Container } from "../utils/container.js";
37
41
  import { ConfigurationService } from "../services/configurationService.js";
38
42
  import type { WorktreeSession } from "../utils/worktreeSession.js";
39
43
 
40
- const SAFE_COMMANDS = [
41
- "cd",
42
- "ls",
43
- "pwd",
44
- "true",
45
- "false",
46
- "grep",
47
- "rg",
48
- "cat",
49
- "head",
50
- "tail",
51
- "wc",
52
- "sleep",
53
- "find",
54
- "sort",
55
- ];
56
-
57
44
  const DEFAULT_ALLOWED_RULES = [
58
45
  "Bash(git status*)",
59
46
  "Bash(git diff*)",
@@ -747,6 +734,14 @@ export class PermissionManager {
747
734
  if (hasWriteRedirections(part)) {
748
735
  return true;
749
736
  }
737
+ // Command/process substitution and sed -i are dangerous (FR-019.5, FR-019.6)
738
+ if (
739
+ hasCommandSubstitution(part) ||
740
+ hasProcessSubstitution(part) ||
741
+ hasSedInPlace(part)
742
+ ) {
743
+ return true;
744
+ }
750
745
  const processedPart = stripRedirections(stripEnvVars(part));
751
746
  const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
752
747
  if (commandMatch) {
@@ -862,122 +857,115 @@ export class PermissionManager {
862
857
  }
863
858
 
864
859
  /**
865
- * Check if a tool call is allowed by persistent or temporary rules
860
+ * Check if a single bash command part is auto-allowed (read-only and safe).
861
+ * Auto-allowed commands skip the confirmation dialog entirely.
862
+ * FR-019.2 through FR-019.7: read-only commands without write redirections,
863
+ * command substitution, process substitution, or sed -i are auto-allowed.
866
864
  */
867
- private isAllowedByRule(context: ToolPermissionContext): boolean {
868
- const isAllowedByRuleList = (
869
- ctx: ToolPermissionContext,
870
- rules: string[],
871
- isDefaultRules: boolean = false,
872
- ) => {
873
- if (ctx.toolName === BASH_TOOL_NAME && ctx.toolInput?.command) {
874
- const command = String(ctx.toolInput.command);
875
- const parts = splitBashCommand(command);
876
- if (parts.length === 0) return false;
877
-
878
- const workdir = ctx.toolInput?.workdir as string | undefined;
879
-
880
- return parts.every((part) => {
881
- const hasWrite = hasWriteRedirections(part);
882
- const processedPart = stripRedirections(stripEnvVars(part));
883
-
884
- // Check for safe commands
885
- if (!hasWrite) {
886
- const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
887
- if (commandMatch) {
888
- const cmd = commandMatch[1];
889
- const args = commandMatch[2]?.trim() || "";
890
-
891
- if (SAFE_COMMANDS.includes(cmd)) {
892
- if (
893
- cmd === "pwd" ||
894
- cmd === "true" ||
895
- cmd === "false" ||
896
- cmd === "ls" ||
897
- cmd === "grep" ||
898
- cmd === "rg" ||
899
- cmd === "cat" ||
900
- cmd === "head" ||
901
- cmd === "tail" ||
902
- cmd === "wc" ||
903
- cmd === "sleep" ||
904
- cmd === "sort" ||
905
- (cmd === "find" && !isDangerousFind(part))
906
- ) {
907
- return true;
908
- }
865
+ private isAutoAllowedPart(part: string, workdir?: string): boolean {
866
+ // Write redirections disqualify (FR-019.4)
867
+ if (hasWriteRedirections(part)) return false;
868
+ // Command substitution $(...) or `...` disqualifies (FR-019.6)
869
+ if (hasCommandSubstitution(part)) return false;
870
+ // Process substitution <(...) or >(...) disqualifies (FR-019.6)
871
+ if (hasProcessSubstitution(part)) return false;
872
+
873
+ const processedPart = stripRedirections(stripEnvVars(part));
874
+ const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
875
+ if (!commandMatch) return false;
876
+
877
+ const cmd = commandMatch[1];
878
+ const args = commandMatch[2]?.trim() || "";
879
+
880
+ // sed -i (in-place edit) disqualifies (FR-019.5)
881
+ if (hasSedInPlace(part)) return false;
882
+
883
+ // Read-only commands are auto-allowed (FR-019.2, FR-019.3)
884
+ if (READ_ONLY_COMMANDS.includes(cmd)) {
885
+ // find with dangerous flags (e.g. -exec, -delete) disqualifies
886
+ if (cmd === "find" && isDangerousFind(part)) return false;
887
+ return true;
888
+ }
909
889
 
910
- if (workdir) {
911
- // For cd, check paths
912
- const pathArgs =
913
- (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
914
- (arg) => !arg.startsWith("-"),
915
- ) || [];
916
-
917
- if (pathArgs.length === 0) {
918
- // cd without arguments operates on current dir (workdir)
919
- return true;
920
- }
921
-
922
- const allPathsSafe = pathArgs.every((pathArg) => {
923
- // Remove quotes if present
924
- const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
925
- const { isInside } = this.isInsideSafeZone(
926
- cleanPath,
927
- workdir,
928
- );
929
- return isInside;
930
- });
931
-
932
- if (allPathsSafe) {
933
- return true;
934
- }
935
- }
936
- }
937
- }
938
- }
890
+ // cd is not read-only but is safe if all paths are within the Safe Zone
891
+ if (cmd === "cd") {
892
+ if (!workdir) return false;
893
+ const pathArgs =
894
+ (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
895
+ (arg) => !arg.startsWith("-"),
896
+ ) || [];
897
+ if (pathArgs.length === 0) return true; // cd without args = current dir
898
+ return pathArgs.every((pathArg) => {
899
+ const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
900
+ const { isInside } = this.isInsideSafeZone(cleanPath, workdir);
901
+ return isInside;
902
+ });
903
+ }
939
904
 
940
- // Check if this specific part is allowed by any rule
941
- if (isDefaultRules && (hasWrite || isDangerousFind(part))) {
942
- return false;
943
- }
905
+ return false;
906
+ }
944
907
 
945
- // We create a temporary context with just this part of the command
946
- const partContext = {
947
- ...ctx,
948
- toolInput: { ...ctx.toolInput, command: part },
949
- };
950
- const allowedByRule = rules.some((rule) => {
951
- return this.matchesRule(partContext, rule);
952
- });
908
+ /**
909
+ * Check if a tool call is allowed by persistent or temporary rules
910
+ */
911
+ private isAllowedByRule(context: ToolPermissionContext): boolean {
912
+ // All allow-rule sources are matched as a union: each part of a chained
913
+ // command only needs to hit at least one rule across all sources, so
914
+ // different parts may be covered by different sources.
915
+ const explicitRules = [
916
+ ...this.instanceAllowedRules,
917
+ ...this.temporaryRules,
918
+ ...this.allowedRules,
919
+ ];
920
+
921
+ if (context.toolName === BASH_TOOL_NAME && context.toolInput?.command) {
922
+ const command = String(context.toolInput.command);
923
+ const parts = splitBashCommand(command);
924
+ if (parts.length === 0) return false;
953
925
 
954
- if (allowedByRule) return true;
926
+ const workdir = context.toolInput?.workdir as string | undefined;
955
927
 
956
- return !this.isRestrictedTool(ctx.toolName);
957
- });
958
- }
928
+ return parts.every((part) => {
929
+ // Check for auto-allowed read-only commands (FR-019.2 through FR-019.7)
930
+ if (this.isAutoAllowedPart(part, workdir)) {
931
+ return true;
932
+ }
959
933
 
960
- // For other tools, check if any rule matches
961
- return rules.some((rule) => this.matchesRule(ctx, rule));
962
- };
934
+ // We create a temporary context with just this part of the command
935
+ const partContext = {
936
+ ...context,
937
+ toolInput: { ...context.toolInput, command: part },
938
+ };
963
939
 
964
- // Check instance-specific allowed rules first
965
- if (isAllowedByRuleList(context, this.instanceAllowedRules)) {
966
- return true;
967
- }
940
+ if (explicitRules.some((rule) => this.matchesRule(partContext, rule))) {
941
+ return true;
942
+ }
968
943
 
969
- // Check temporary rules
970
- if (isAllowedByRuleList(context, this.temporaryRules)) {
971
- return true;
972
- }
944
+ // Default rules must not auto-allow dangerous variants (write
945
+ // redirections, substitutions, sed -i, dangerous find) through broad
946
+ // rules like Bash(echo*) or Bash(cat*)
947
+ const isDangerousVariant =
948
+ hasWriteRedirections(part) ||
949
+ isDangerousFind(part) ||
950
+ hasCommandSubstitution(part) ||
951
+ hasProcessSubstitution(part) ||
952
+ hasSedInPlace(part);
953
+ if (
954
+ !isDangerousVariant &&
955
+ DEFAULT_ALLOWED_RULES.some((rule) =>
956
+ this.matchesRule(partContext, rule),
957
+ )
958
+ ) {
959
+ return true;
960
+ }
973
961
 
974
- // Check persistent allowed rules
975
- if (isAllowedByRuleList(context, this.allowedRules)) {
976
- return true;
962
+ return !this.isRestrictedTool(context.toolName);
963
+ });
977
964
  }
978
965
 
979
- // Check default allowed rules
980
- return isAllowedByRuleList(context, DEFAULT_ALLOWED_RULES, true);
966
+ // For other tools, check if any rule matches
967
+ const allRules = [...explicitRules, ...DEFAULT_ALLOWED_RULES];
968
+ return allRules.some((rule) => this.matchesRule(context, rule));
981
969
  }
982
970
 
983
971
  /**
@@ -996,52 +984,8 @@ export class PermissionManager {
996
984
  const hasWrite = hasWriteRedirections(part);
997
985
  const processedPart = stripRedirections(stripEnvVars(part));
998
986
 
999
- // Check for safe commands
1000
- const commandMatch = processedPart.match(/^(\w+)(\s+.*)?$/);
1001
- let isSafe = false;
1002
-
1003
- if (commandMatch && !hasWrite) {
1004
- const cmd = commandMatch[1];
1005
- const args = commandMatch[2]?.trim() || "";
1006
-
1007
- if (SAFE_COMMANDS.includes(cmd)) {
1008
- if (
1009
- cmd === "pwd" ||
1010
- cmd === "true" ||
1011
- cmd === "false" ||
1012
- cmd === "ls" ||
1013
- cmd === "grep" ||
1014
- cmd === "rg" ||
1015
- cmd === "cat" ||
1016
- cmd === "head" ||
1017
- cmd === "tail" ||
1018
- cmd === "wc" ||
1019
- cmd === "sleep" ||
1020
- (cmd === "find" && !isDangerousFind(part))
1021
- ) {
1022
- isSafe = true;
1023
- } else {
1024
- // For cd, check paths
1025
- const pathArgs =
1026
- (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).filter(
1027
- (arg) => !arg.startsWith("-"),
1028
- ) || [];
1029
-
1030
- if (pathArgs.length === 0) {
1031
- isSafe = true;
1032
- } else {
1033
- const allPathsSafe = pathArgs.every((pathArg) => {
1034
- const cleanPath = pathArg.replace(/^['"](.*)['"]$/, "$1");
1035
- const { isInside } = this.isInsideSafeZone(cleanPath, workdir);
1036
- return isInside;
1037
- });
1038
- if (allPathsSafe) {
1039
- isSafe = true;
1040
- }
1041
- }
1042
- }
1043
- }
1044
- }
987
+ // Check for auto-allowed read-only commands
988
+ const isSafe = this.isAutoAllowedPart(part, workdir);
1045
989
 
1046
990
  if (!isSafe) {
1047
991
  // Check if command is dangerous or out-of-bounds
@@ -1050,7 +994,11 @@ export class PermissionManager {
1050
994
  const cmd = commandMatch[1];
1051
995
  const args = commandMatch[2]?.trim() || "";
1052
996
 
1053
- if (DANGEROUS_COMMANDS.includes(cmd) || isDangerousFind(part)) {
997
+ if (
998
+ DANGEROUS_COMMANDS.includes(cmd) ||
999
+ isDangerousFind(part) ||
1000
+ hasSedInPlace(part)
1001
+ ) {
1054
1002
  continue;
1055
1003
  }
1056
1004
 
@@ -2,6 +2,8 @@ import { logger } from "../utils/globalLogger.js";
2
2
  import { Plugin, PluginConfig } from "../types/index.js";
3
3
  import { PluginLoader } from "../services/pluginLoader.js";
4
4
  import * as path from "path";
5
+ import { existsSync, readdirSync } from "fs";
6
+ import { getBuiltinPluginsDir } from "../utils/configPaths.js";
5
7
  import { SkillManager } from "./skillManager.js";
6
8
  import { HookManager } from "./hookManager.js";
7
9
  import { LspManager } from "./lspManager.js";
@@ -98,6 +100,8 @@ export class PluginManager {
98
100
 
99
101
  const [name, marketplaceName] = pluginId.split("@");
100
102
  if (!name || !marketplaceName) continue;
103
+ // `@builtin` entries are loaded by loadBuiltinPlugins, not the marketplace.
104
+ if (marketplaceName === "builtin") continue;
101
105
 
102
106
  const isInstalled = installedRegistry.plugins.some(
103
107
  (p) => p.name === name && p.marketplace === marketplaceName,
@@ -283,6 +287,31 @@ export class PluginManager {
283
287
 
284
288
  // Load installed plugins from marketplace
285
289
  await this.loadInstalledPlugins();
290
+
291
+ // Load built-in plugins bundled with the SDK (lowest priority)
292
+ await this.loadBuiltinPlugins();
293
+ }
294
+
295
+ /**
296
+ * Load built-in plugins bundled with the SDK (e.g. sdd).
297
+ * Opt-in via `enabledPlugins`, keyed `<name>@builtin` (default off, consistent
298
+ * with marketplace plugins). Lowest priority: explicit config and marketplace
299
+ * plugins win on name conflicts (loadSinglePlugin skips duplicates).
300
+ */
301
+ private async loadBuiltinPlugins(): Promise<void> {
302
+ try {
303
+ const builtinDir = getBuiltinPluginsDir();
304
+ if (!existsSync(builtinDir)) return;
305
+
306
+ // The builtin plugin directory name is the plugin name by convention.
307
+ for (const entry of readdirSync(builtinDir, { withFileTypes: true })) {
308
+ if (!entry.isDirectory()) continue;
309
+ if (this.enabledPlugins[`${entry.name}@builtin`] !== true) continue;
310
+ await this.loadSinglePlugin(path.join(builtinDir, entry.name));
311
+ }
312
+ } catch (error) {
313
+ logger?.error("Failed to load built-in plugins:", error);
314
+ }
286
315
  }
287
316
 
288
317
  /**
@@ -253,6 +253,13 @@ class ToolManager {
253
253
  "WorkflowManager",
254
254
  )
255
255
  : undefined,
256
+ sessionEnv: this.container.has("ConfigurationService")
257
+ ? this.container
258
+ .get<
259
+ import("../services/configurationService.js").ConfigurationService
260
+ >("ConfigurationService")
261
+ ?.getMergedEnv?.()
262
+ : undefined,
256
263
  sessionId: context.sessionId,
257
264
  toolCallId: context.toolCallId,
258
265
  };