plugin-agent-orchestrator 1.0.22 → 1.0.23

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 (96) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/214.723affb37c13bf7a.js +10 -0
  5. package/dist/client-v2/264.0533912e6c5ea2d7.js +10 -0
  6. package/dist/client-v2/41.1805b2edfaa4afe2.js +10 -0
  7. package/dist/client-v2/418.5ae055abf141820e.js +10 -0
  8. package/dist/client-v2/619.d99d3c9e61c99064.js +10 -0
  9. package/dist/client-v2/70.a15d7fcec7c41768.js +10 -0
  10. package/dist/client-v2/892.72db4161511c8a16.js +10 -0
  11. package/dist/client-v2/926.87f660b670d85bcc.js +10 -0
  12. package/dist/client-v2/index.js +10 -0
  13. package/dist/externalVersion.js +7 -6
  14. package/dist/locale/en-US.json +7 -0
  15. package/dist/locale/vi-VN.json +7 -0
  16. package/dist/locale/zh-CN.json +27 -0
  17. package/dist/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.js +63 -0
  18. package/dist/server/plugin.js +32 -1
  19. package/dist/server/services/AgentHarness.js +52 -27
  20. package/dist/server/services/AgentLoopController.js +8 -2
  21. package/dist/server/services/AgentLoopService.js +1 -1
  22. package/dist/server/services/AgentRegistryService.js +53 -42
  23. package/dist/server/services/CircuitBreaker.js +7 -2
  24. package/dist/server/services/CodeValidator.js +48 -14
  25. package/dist/server/services/SandboxRunner.js +18 -14
  26. package/dist/server/skill-hub/plugin.js +44 -17
  27. package/dist/server/tools/delegate-task.js +7 -2
  28. package/dist/server/tools/skill-execute.js +33 -2
  29. package/dist/server/utils/ai-manager.js +51 -0
  30. package/dist/server/utils/ctx-utils.js +11 -0
  31. package/dist/server/utils/skill-settings.js +122 -0
  32. package/package.json +49 -45
  33. package/src/client/AIEmployeesContext.tsx +51 -14
  34. package/src/client/AgentRunsTab.tsx +767 -764
  35. package/src/client/HarnessProfilesTab.tsx +254 -247
  36. package/src/client/RulesTab.tsx +780 -716
  37. package/src/client/TracingTab.tsx +1 -0
  38. package/src/client/plugin.tsx +34 -27
  39. package/src/client/skill-hub/components/GitSkillImport.tsx +10 -3
  40. package/src/client/skill-hub/components/SkillMetrics.tsx +157 -124
  41. package/src/client/skill-hub/index.tsx +58 -51
  42. package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +132 -99
  43. package/src/client/skill-hub/tools/registerSkillLoopCards.ts +71 -58
  44. package/src/client/tools/registerOrchestratorCards.ts +17 -7
  45. package/src/client-v2/components/AIEmployeeSelect.tsx +47 -0
  46. package/src/client-v2/components/AIEmployeesContext.tsx +110 -0
  47. package/src/client-v2/components/AgentRunsTab.tsx +767 -0
  48. package/src/client-v2/components/HarnessProfilesTab.tsx +254 -0
  49. package/src/client-v2/components/RulesTab.tsx +782 -0
  50. package/src/client-v2/components/TracingTab.tsx +432 -0
  51. package/src/client-v2/hooks/useApiRequest.ts +114 -0
  52. package/src/client-v2/index.tsx +1 -0
  53. package/src/client-v2/pages/AgentRunsPage.tsx +13 -0
  54. package/src/client-v2/pages/ExecutionHistoryPage.tsx +10 -0
  55. package/src/client-v2/pages/HarnessProfilesPage.tsx +10 -0
  56. package/src/client-v2/pages/LoopSettingsPage.tsx +10 -0
  57. package/src/client-v2/pages/RulesPage.tsx +13 -0
  58. package/src/client-v2/pages/SkillDefinitionsPage.tsx +10 -0
  59. package/src/client-v2/pages/SkillMetricsPage.tsx +10 -0
  60. package/src/client-v2/pages/TracingPage.tsx +13 -0
  61. package/src/client-v2/plugin.tsx +70 -0
  62. package/src/client-v2/skill-hub/components/ExecutionHistory.tsx +196 -0
  63. package/src/client-v2/skill-hub/components/FileLinkList.tsx +37 -0
  64. package/src/client-v2/skill-hub/components/GitSkillImport.tsx +539 -0
  65. package/src/client-v2/skill-hub/components/LoopSettings.tsx +331 -0
  66. package/src/client-v2/skill-hub/components/SkillEditor.tsx +453 -0
  67. package/src/client-v2/skill-hub/components/SkillManager.tsx +174 -0
  68. package/src/client-v2/skill-hub/components/SkillMetrics.tsx +157 -0
  69. package/src/client-v2/skill-hub/components/SkillTestPanel.tsx +135 -0
  70. package/src/client-v2/skill-hub/locale.ts +13 -0
  71. package/src/client-v2/skill-hub/tools/loopTemplates.ts +52 -0
  72. package/src/client-v2/skill-hub/utils/jsonFields.ts +41 -0
  73. package/src/client-v2/utils/jsonFields.ts +41 -0
  74. package/src/locale/en-US.json +7 -0
  75. package/src/locale/vi-VN.json +7 -0
  76. package/src/locale/zh-CN.json +27 -0
  77. package/src/server/__tests__/agent-registry-service.test.ts +147 -0
  78. package/src/server/__tests__/code-validator.test.ts +63 -0
  79. package/src/server/__tests__/skill-execute.test.ts +33 -0
  80. package/src/server/__tests__/skill-settings.test.ts +63 -0
  81. package/src/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.ts +39 -0
  82. package/src/server/plugin.ts +62 -21
  83. package/src/server/services/AgentHarness.ts +49 -22
  84. package/src/server/services/AgentLoopController.ts +17 -6
  85. package/src/server/services/AgentLoopService.ts +1 -1
  86. package/src/server/services/AgentPlannerService.ts +10 -0
  87. package/src/server/services/AgentRegistryService.ts +89 -47
  88. package/src/server/services/CircuitBreaker.ts +10 -0
  89. package/src/server/services/CodeValidator.ts +237 -159
  90. package/src/server/services/SandboxRunner.ts +203 -189
  91. package/src/server/skill-hub/plugin.ts +933 -898
  92. package/src/server/tools/delegate-task.ts +12 -9
  93. package/src/server/tools/skill-execute.ts +194 -160
  94. package/src/server/utils/ai-manager.ts +24 -0
  95. package/src/server/utils/ctx-utils.ts +14 -0
  96. package/src/server/utils/skill-settings.ts +116 -0
@@ -1,5 +1,61 @@
1
1
  import { toPlain, asObject, normalizeEmployeeUsername } from '../utils/ctx-utils';
2
2
 
3
+ type OrchestratorConfigRow = {
4
+ leaderUsername?: string;
5
+ subAgentUsername?: string;
6
+ };
7
+
8
+ type ModelRef = { llmService: string; model: string };
9
+
10
+ /**
11
+ * Normalize an AI employee's `modelSettings` (or a per-rule override) into a
12
+ * flat { llmService, model } the harness can hand to getLLMService().
13
+ *
14
+ * The admin UI (plugin-ai ModelSettings) stores the dedicated model selection
15
+ * as `{ enabled, models: [{ llmService, model }] }` and clears the flat
16
+ * `llmService`/`model` fields. Older records still use the flat shape, and
17
+ * per-rule overrides arrive flat too. Mirror plugin-ai's resolveModel:
18
+ * - if `enabled`, prefer `models[0]`, else fall back to the flat fields
19
+ * - a bare flat { llmService, model } (e.g. a rule override) is also valid
20
+ */
21
+ function extractModelRef(value: any): ModelRef | undefined {
22
+ if (!value) return undefined;
23
+
24
+ const isValid = (m: any): m is ModelRef => Boolean(m?.llmService && m?.model);
25
+
26
+ // Dedicated model configuration (UI shape).
27
+ if (value.enabled) {
28
+ const models = Array.isArray(value.models) ? value.models : [];
29
+ const first = models.find(isValid);
30
+ if (first) {
31
+ return { llmService: first.llmService, model: first.model };
32
+ }
33
+ }
34
+
35
+ // Flat legacy shape, or a per-rule override.
36
+ if (isValid(value)) {
37
+ return { llmService: value.llmService, model: value.model };
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
43
+ function sanitizeToolPart(value: string) {
44
+ return (value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
45
+ }
46
+
47
+ function buildDelegateToolName(leaderUsername: string, subAgentUsername: string) {
48
+ return `delegate_${sanitizeToolPart(leaderUsername)}_to_${sanitizeToolPart(subAgentUsername)}`;
49
+ }
50
+
51
+ function buildDispatchToolName(leaderUsername: string) {
52
+ return `dispatch_subagents_${sanitizeToolPart(leaderUsername)}`;
53
+ }
54
+
55
+ function buildLegacyDelegateToolName(subAgentUsername: string) {
56
+ return `delegate_to_${sanitizeToolPart(subAgentUsername)}`;
57
+ }
58
+
3
59
  export class AgentRegistryService {
4
60
  constructor(private readonly plugin: any) {}
5
61
 
@@ -59,26 +115,28 @@ export class AgentRegistryService {
59
115
  throw new Error(`Sub-agent "${subAgentUsername}" was not found.`);
60
116
  }
61
117
 
62
- const hasModelSettings = (val: any): val is { llmService: string; model: string } => {
63
- return Boolean(val?.llmService && val?.model);
64
- };
65
-
66
- let modelSettings = hasModelSettings(dynamicValues) ? dynamicValues : undefined;
118
+ // 1. Explicit per-rule override (already a flat { llmService, model }).
119
+ const dynamic = extractModelRef(dynamicValues);
120
+ if (dynamic) {
121
+ return dynamic;
122
+ }
67
123
 
68
- if (!modelSettings) {
69
- if (hasModelSettings(subAgent.modelSettings)) {
70
- modelSettings = subAgent.modelSettings;
71
- }
124
+ // 2. Sub-agent's own dedicated model configuration.
125
+ const subAgentModel = extractModelRef(subAgent.modelSettings);
126
+ if (subAgentModel) {
127
+ return subAgentModel;
72
128
  }
73
129
 
74
- if (!modelSettings && leaderUsername) {
130
+ // 3. Inherit from leader.
131
+ if (leaderUsername) {
75
132
  const leader = await this.getAIEmployee(leaderUsername);
76
- if (leader && hasModelSettings(leader.modelSettings)) {
77
- modelSettings = leader.modelSettings;
133
+ const leaderModel = extractModelRef(leader?.modelSettings);
134
+ if (leaderModel) {
135
+ return leaderModel;
78
136
  }
79
137
  }
80
138
 
81
- return modelSettings;
139
+ return undefined;
82
140
  }
83
141
 
84
142
  /**
@@ -127,49 +185,33 @@ export class AgentRegistryService {
127
185
  const configRepo = this.db.getRepository('orchestratorConfig');
128
186
  if (!configRepo) return false;
129
187
 
188
+ const configs: OrchestratorConfigRow[] = await configRepo.find({
189
+ filter: { enabled: true },
190
+ });
191
+ if (!configs || configs.length === 0) return false;
192
+
130
193
  // 1. Check if it matches dispatch_subagents_${leader}
131
194
  if (toolName.startsWith('dispatch_subagents_')) {
132
- const leader = toolName.substring('dispatch_subagents_'.length);
133
- const count = await configRepo.count({
134
- filter: {
135
- leaderUsername: leader,
136
- enabled: true,
137
- },
138
- });
139
- return count > 0;
140
- }
141
-
142
- // 2. Check if it matches delegate_${leader}_to_${subAgent}
143
- if (toolName.startsWith('delegate_') && toolName.includes('_to_')) {
144
- const parts = toolName.substring('delegate_'.length).split('_to_');
145
- if (parts.length === 2) {
146
- const [leader, subAgent] = parts;
147
- const count = await configRepo.count({
148
- filter: {
149
- leaderUsername: leader,
150
- subAgentUsername: subAgent,
151
- enabled: true,
152
- },
153
- });
154
- if (count > 0) return true;
155
- }
195
+ return configs.some((config) => buildDispatchToolName(config.leaderUsername || '') === toolName);
156
196
  }
157
197
 
158
- // 3. Check legacy alias: delegate_to_${subAgent}
198
+ // 2. Check legacy alias: delegate_to_${subAgent}
159
199
  if (toolName.startsWith('delegate_to_')) {
160
- const subAgent = toolName.substring('delegate_to_'.length);
161
- const configs = await configRepo.find({
162
- filter: {
163
- subAgentUsername: subAgent,
164
- enabled: true,
165
- },
166
- });
167
- // Legacy alias is only registered if there is exactly one leader for this subAgent
168
- if (configs?.length === 1) {
200
+ const matchingConfigs = configs.filter(
201
+ (config) => buildLegacyDelegateToolName(config.subAgentUsername || '') === toolName,
202
+ );
203
+ if (matchingConfigs.length === 1) {
169
204
  return true;
170
205
  }
171
206
  }
172
207
 
208
+ // 3. Check if it matches delegate_${leader}_to_${subAgent}
209
+ if (toolName.startsWith('delegate_') && toolName.includes('_to_')) {
210
+ return configs.some(
211
+ (config) => buildDelegateToolName(config.leaderUsername || '', config.subAgentUsername || '') === toolName,
212
+ );
213
+ }
214
+
173
215
  return false;
174
216
  } catch {
175
217
  return false;
@@ -100,6 +100,16 @@ export class CircuitBreakerRegistry {
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * Build the circuit key for a sub-agent. Keying by `${leader}::${target}` keeps
105
+ * one leader's failures from opening the circuit for the same sub-agent under a
106
+ * different leader — their delegations are independent code paths. Falls back to
107
+ * the bare target when no leader is known.
108
+ */
109
+ export function subAgentCircuitKey(leaderUsername: string | undefined, target: string): string {
110
+ return leaderUsername ? `${leaderUsername}::${target}` : target;
111
+ }
112
+
103
113
  // Singleton shared across the plugin
104
114
  let globalInstance: CircuitBreakerRegistry | null = null;
105
115
 
@@ -1,159 +1,237 @@
1
- interface ForbiddenPattern {
2
- pattern: RegExp;
3
- reason: string;
4
- }
5
-
6
- const DANGEROUS_NODE_PATTERNS: ForbiddenPattern[] = [
7
- { pattern: /require\s*\(\s*['"]child_process['"]\s*\)/, reason: 'child_process module not allowed' },
8
- { pattern: /require\s*\(\s*['"]cluster['"]\s*\)/, reason: 'cluster module not allowed' },
9
- { pattern: /require\s*\(\s*['"]dgram['"]\s*\)/, reason: 'dgram module not allowed' },
10
- { pattern: /require\s*\(\s*['"]net['"]\s*\)/, reason: 'net module not allowed' },
11
- { pattern: /require\s*\(\s*['"]http['"]\s*\)/, reason: 'http module not allowed' },
12
- { pattern: /require\s*\(\s*['"]https['"]\s*\)/, reason: 'https module not allowed' },
13
- { pattern: /require\s*\(\s*['"]vm['"]\s*\)/, reason: 'vm module not allowed' },
14
- { pattern: /process\.exit/, reason: 'process.exit not allowed' },
15
- { pattern: /process\.env(?!\s*\.OUTPUT_DIR)/, reason: 'process.env access not allowed (use OUTPUT_DIR only)' },
16
- { pattern: /process\.kill/, reason: 'process.kill not allowed' },
17
- ];
18
-
19
- const DANGEROUS_PYTHON_PATTERNS: ForbiddenPattern[] = [
20
- { pattern: /import\s+subprocess/, reason: 'subprocess module not allowed' },
21
- { pattern: /from\s+subprocess\s+import/, reason: 'subprocess module not allowed' },
22
- { pattern: /import\s+shutil/, reason: 'shutil module not allowed' },
23
- { pattern: /__import__\s*\(/, reason: '__import__ not allowed' },
24
- { pattern: /os\.system\s*\(/, reason: 'os.system not allowed' },
25
- { pattern: /os\.popen\s*\(/, reason: 'os.popen not allowed' },
26
- { pattern: /os\.exec\w*\s*\(/, reason: 'os.exec* not allowed' },
27
- { pattern: /os\.spawn\w*\s*\(/, reason: 'os.spawn* not allowed' },
28
- { pattern: /\beval\s*\(/, reason: 'eval not allowed' },
29
- { pattern: /\bexec\s*\(/, reason: 'exec not allowed' },
30
- { pattern: /\bcompile\s*\(/, reason: 'compile not allowed' },
31
- ];
32
-
33
- /** Built-in Node.js modules that are always allowed in sandbox code */
34
- const NODE_BUILTINS = [
35
- 'fs', 'path', 'os', 'crypto', 'util', 'stream', 'buffer',
36
- 'querystring', 'url', 'assert', 'events', 'string_decoder', 'zlib',
37
- ];
38
-
39
- /** Built-in Python modules that are always allowed in sandbox code */
40
- const PYTHON_BUILTINS = [
41
- 'os', 'sys', 'json', 'math', 'datetime', 'collections', 'itertools',
42
- 'functools', 'pathlib', 'typing', 'io', 'csv', 're', 'string', 'textwrap',
43
- 'decimal', 'fractions', 'random', 'statistics', 'copy', 'enum', 'dataclasses',
44
- 'abc', 'contextlib', 'operator', 'time', 'calendar', 'locale', 'struct',
45
- 'hashlib', 'base64', 'binascii', 'codecs', 'unicodedata', 'pprint',
46
- 'warnings', 'traceback', 'logging', 'unittest', 'argparse', 'ast',
47
- 'tempfile', 'xml', 'zipfile',
48
- // Pre-installed local packages (trusted, bundled with plugin)
49
- 'svg_to_pptx',
50
- ];
51
-
52
- /**
53
- * Maps PyPI package names to their Python import names
54
- * (only where they differ from the package name).
55
- */
56
- const PYTHON_IMPORT_NAME_MAP: Record<string, string> = {
57
- 'python-docx': 'docx',
58
- 'python-pptx': 'pptx',
59
- 'Pillow': 'PIL',
60
- 'pyyaml': 'yaml',
61
- };
62
-
63
- export class CodeValidator {
64
- /**
65
- * Check code against forbidden patterns (dangerous modules/functions).
66
- * @throws CodeValidationError if a forbidden pattern is found.
67
- */
68
- validate(code: string, language: 'node' | 'python'): void {
69
- const patterns = language === 'node'
70
- ? DANGEROUS_NODE_PATTERNS
71
- : DANGEROUS_PYTHON_PATTERNS;
72
-
73
- for (const { pattern, reason } of patterns) {
74
- if (pattern.test(code)) {
75
- throw new CodeValidationError(reason, pattern.source);
76
- }
77
- }
78
- }
79
-
80
- /**
81
- * Validate that code only imports packages in the whitelist.
82
- * Called after the basic forbidden pattern check.
83
- * Skips validation if whitelist is empty (env not initialized yet).
84
- *
85
- * @param code - The code to validate
86
- * @param language - 'node' or 'python'
87
- * @param whitelist - Array of allowed package names (from skillWorkerConfigs.packageWhitelist)
88
- */
89
- validateImports(code: string, language: 'node' | 'python', whitelist: string[]): void {
90
- if (!whitelist?.length) return; // Skip if env not initialized
91
-
92
- if (language === 'node') {
93
- this.validateNodeImports(code, whitelist);
94
- } else if (language === 'python') {
95
- this.validatePythonImports(code, whitelist);
96
- }
97
- }
98
-
99
- /**
100
- * Check Node.js require() calls against the whitelist.
101
- * Built-in modules (fs, path, etc.) are always allowed.
102
- */
103
- private validateNodeImports(code: string, whitelist: string[]): void {
104
- // Match require statements with string literal arguments (non-relative paths)
105
- const requires = [...code.matchAll(/require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/g)];
106
-
107
- for (const match of requires) {
108
- const raw = match[1];
109
- // Handle scoped packages (e.g. '@org/lib') and subpaths (e.g. 'lib/utils')
110
- const pkgName = raw.startsWith('@')
111
- ? raw.split('/').slice(0, 2).join('/')
112
- : raw.split('/')[0];
113
-
114
- if (NODE_BUILTINS.includes(pkgName)) continue;
115
- if (whitelist.includes(pkgName)) continue;
116
-
117
- throw new CodeValidationError(
118
- `Package "${pkgName}" is not in the allowed whitelist. Allowed: ${whitelist.join(', ')}`,
119
- `require('${pkgName}')`,
120
- );
121
- }
122
- }
123
-
124
- /**
125
- * Check Python import/from statements against the whitelist.
126
- * Built-in modules (os, sys, json, etc.) are always allowed.
127
- * Handles PyPI→import name mapping (e.g., python-docx → docx, Pillow → PIL).
128
- */
129
- private validatePythonImports(code: string, whitelist: string[]): void {
130
- // Match: import pkg, from pkg import ..., import pkg.sub
131
- const imports = [...code.matchAll(/(?:^|\n)\s*(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g)];
132
-
133
- // Build allowed import names from whitelist
134
- const allowedImports = new Set([
135
- ...PYTHON_BUILTINS,
136
- ...whitelist.map((p) => PYTHON_IMPORT_NAME_MAP[p] || p),
137
- ]);
138
-
139
- for (const match of imports) {
140
- const pkg = match[1];
141
- if (allowedImports.has(pkg)) continue;
142
-
143
- throw new CodeValidationError(
144
- `Module "${pkg}" is not in the allowed whitelist. Allowed: ${[...allowedImports].join(', ')}`,
145
- `import ${pkg}`,
146
- );
147
- }
148
- }
149
- }
150
-
151
- export class CodeValidationError extends Error {
152
- constructor(
153
- message: string,
154
- public matchedPattern: string,
155
- ) {
156
- super(`Code validation failed: ${message}`);
157
- (this as any).name = 'CodeValidationError';
158
- }
159
- }
1
+ interface ForbiddenPattern {
2
+ pattern: RegExp;
3
+ reason: string;
4
+ }
5
+
6
+ const DANGEROUS_NODE_PATTERNS: ForbiddenPattern[] = [
7
+ { pattern: /require\s*\(\s*['"]child_process['"]\s*\)/, reason: 'child_process module not allowed' },
8
+ { pattern: /require\s*\(\s*['"]cluster['"]\s*\)/, reason: 'cluster module not allowed' },
9
+ { pattern: /require\s*\(\s*['"]dgram['"]\s*\)/, reason: 'dgram module not allowed' },
10
+ { pattern: /require\s*\(\s*['"]net['"]\s*\)/, reason: 'net module not allowed' },
11
+ { pattern: /require\s*\(\s*['"]http['"]\s*\)/, reason: 'http module not allowed' },
12
+ { pattern: /require\s*\(\s*['"]https['"]\s*\)/, reason: 'https module not allowed' },
13
+ { pattern: /require\s*\(\s*['"]vm['"]\s*\)/, reason: 'vm module not allowed' },
14
+ { pattern: /require\s*\(\s*['"]worker_threads['"]\s*\)/, reason: 'worker_threads module not allowed' },
15
+ { pattern: /require\s*\(\s*['"]inspector['"]\s*\)/, reason: 'inspector module not allowed' },
16
+ { pattern: /require\s*\(\s*['"]v8['"]\s*\)/, reason: 'v8 module not allowed' },
17
+ { pattern: /process\.exit/, reason: 'process.exit not allowed' },
18
+ { pattern: /process\.env(?!\s*\.OUTPUT_DIR)/, reason: 'process.env access not allowed (use OUTPUT_DIR only)' },
19
+ { pattern: /process\.kill/, reason: 'process.kill not allowed' },
20
+ { pattern: /process\.binding\s*\(/, reason: 'process.binding not allowed' },
21
+ { pattern: /\bglobalThis\b/, reason: 'globalThis access not allowed' },
22
+ // Indirect eval / dynamic Function construction (bypasses the import allowlist).
23
+ { pattern: /\beval\s*\(/, reason: 'eval not allowed' },
24
+ { pattern: /\bnew\s+Function\s*\(/, reason: 'new Function() not allowed' },
25
+ { pattern: /\bFunction\s*\(/, reason: 'Function() constructor not allowed' },
26
+ // Dynamic import() can pull arbitrary modules at runtime, bypassing require() checks.
27
+ { pattern: /\bimport\s*\(/, reason: 'dynamic import() not allowed' },
28
+ // require resolved from a non-literal expression (e.g. require(varName)).
29
+ { pattern: /require\s*\(\s*(?!['"])/, reason: 'require() with a non-literal argument not allowed' },
30
+ ];
31
+
32
+ const DANGEROUS_PYTHON_PATTERNS: ForbiddenPattern[] = [
33
+ { pattern: /import\s+subprocess/, reason: 'subprocess module not allowed' },
34
+ { pattern: /from\s+subprocess\s+import/, reason: 'subprocess module not allowed' },
35
+ { pattern: /import\s+shutil/, reason: 'shutil module not allowed' },
36
+ { pattern: /import\s+socket/, reason: 'socket module not allowed' },
37
+ { pattern: /import\s+ctypes/, reason: 'ctypes module not allowed' },
38
+ { pattern: /import\s+pickle/, reason: 'pickle module not allowed' },
39
+ { pattern: /import\s+marshal/, reason: 'marshal module not allowed' },
40
+ { pattern: /import\s+importlib/, reason: 'importlib module not allowed' },
41
+ {
42
+ pattern: /from\s+(?:socket|ctypes|pickle|marshal|importlib)\s+import/,
43
+ reason: 'restricted module import not allowed',
44
+ },
45
+ { pattern: /__import__\s*\(/, reason: '__import__ not allowed' },
46
+ { pattern: /\bimportlib\b/, reason: 'importlib access not allowed' },
47
+ { pattern: /os\.system\s*\(/, reason: 'os.system not allowed' },
48
+ { pattern: /os\.popen\s*\(/, reason: 'os.popen not allowed' },
49
+ { pattern: /os\.exec\w*\s*\(/, reason: 'os.exec* not allowed' },
50
+ { pattern: /os\.spawn\w*\s*\(/, reason: 'os.spawn* not allowed' },
51
+ { pattern: /os\.fork\s*\(/, reason: 'os.fork not allowed' },
52
+ // getattr/setattr can reconstruct blocked names like getattr(os,'sys'+'tem').
53
+ { pattern: /\bgetattr\s*\(/, reason: 'getattr not allowed (can bypass name checks)' },
54
+ { pattern: /\bsetattr\s*\(/, reason: 'setattr not allowed' },
55
+ { pattern: /\b__builtins__\b/, reason: '__builtins__ access not allowed' },
56
+ { pattern: /\beval\s*\(/, reason: 'eval not allowed' },
57
+ { pattern: /\bexec\s*\(/, reason: 'exec not allowed' },
58
+ { pattern: /\bcompile\s*\(/, reason: 'compile not allowed' },
59
+ ];
60
+
61
+ /** Built-in Node.js modules that are always allowed in sandbox code */
62
+ const NODE_BUILTINS = [
63
+ 'fs',
64
+ 'path',
65
+ 'os',
66
+ 'crypto',
67
+ 'util',
68
+ 'stream',
69
+ 'buffer',
70
+ 'querystring',
71
+ 'url',
72
+ 'assert',
73
+ 'events',
74
+ 'string_decoder',
75
+ 'zlib',
76
+ ];
77
+
78
+ /** Built-in Python modules that are always allowed in sandbox code */
79
+ const PYTHON_BUILTINS = [
80
+ 'os',
81
+ 'sys',
82
+ 'json',
83
+ 'math',
84
+ 'datetime',
85
+ 'collections',
86
+ 'itertools',
87
+ 'functools',
88
+ 'pathlib',
89
+ 'typing',
90
+ 'io',
91
+ 'csv',
92
+ 're',
93
+ 'string',
94
+ 'textwrap',
95
+ 'decimal',
96
+ 'fractions',
97
+ 'random',
98
+ 'statistics',
99
+ 'copy',
100
+ 'enum',
101
+ 'dataclasses',
102
+ 'abc',
103
+ 'contextlib',
104
+ 'operator',
105
+ 'time',
106
+ 'calendar',
107
+ 'locale',
108
+ 'struct',
109
+ 'hashlib',
110
+ 'base64',
111
+ 'binascii',
112
+ 'codecs',
113
+ 'unicodedata',
114
+ 'pprint',
115
+ 'warnings',
116
+ 'traceback',
117
+ 'logging',
118
+ 'unittest',
119
+ 'argparse',
120
+ 'ast',
121
+ 'tempfile',
122
+ 'xml',
123
+ 'zipfile',
124
+ // Pre-installed local packages (trusted, bundled with plugin)
125
+ 'svg_to_pptx',
126
+ ];
127
+
128
+ /**
129
+ * Maps PyPI package names to their Python import names
130
+ * (only where they differ from the package name).
131
+ */
132
+ const PYTHON_IMPORT_NAME_MAP: Record<string, string> = {
133
+ 'python-docx': 'docx',
134
+ 'python-pptx': 'pptx',
135
+ Pillow: 'PIL',
136
+ pyyaml: 'yaml',
137
+ };
138
+
139
+ export class CodeValidator {
140
+ /**
141
+ * Check code against forbidden patterns (dangerous modules/functions).
142
+ * @throws CodeValidationError if a forbidden pattern is found.
143
+ */
144
+ validate(code: string, language: 'node' | 'python'): void {
145
+ const patterns = language === 'node' ? DANGEROUS_NODE_PATTERNS : DANGEROUS_PYTHON_PATTERNS;
146
+
147
+ for (const { pattern, reason } of patterns) {
148
+ if (pattern.test(code)) {
149
+ throw new CodeValidationError(reason, pattern.source);
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Validate that code only imports packages in the allowlist (builtins +
156
+ * whitelist). Called after the basic forbidden pattern check.
157
+ *
158
+ * An empty whitelist does NOT skip validation: only built-in modules are
159
+ * then allowed. This closes an exfiltration hole — stdlib modules such as
160
+ * Python `urllib`/`socket`/`ftplib` need no install, so skipping the check
161
+ * when the env was not initialized would let a skill open outbound
162
+ * connections. Set SKILL_HUB_ALLOW_ANY_IMPORT=true to restore the old
163
+ * skip-when-empty behaviour for legacy deployments.
164
+ *
165
+ * @param code - The code to validate
166
+ * @param language - 'node' or 'python'
167
+ * @param whitelist - Allowed package names (from skillWorkerConfigs.packageWhitelist)
168
+ */
169
+ validateImports(code: string, language: 'node' | 'python', whitelist: string[]): void {
170
+ if (!whitelist?.length && process.env.SKILL_HUB_ALLOW_ANY_IMPORT === 'true') {
171
+ return;
172
+ }
173
+ const list = whitelist || [];
174
+
175
+ if (language === 'node') {
176
+ this.validateNodeImports(code, list);
177
+ } else if (language === 'python') {
178
+ this.validatePythonImports(code, list);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Check Node.js require() calls against the whitelist.
184
+ * Built-in modules (fs, path, etc.) are always allowed.
185
+ */
186
+ private validateNodeImports(code: string, whitelist: string[]): void {
187
+ // Match require statements with string literal arguments (non-relative paths)
188
+ const requires = [...code.matchAll(/require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/g)];
189
+
190
+ for (const match of requires) {
191
+ const raw = match[1];
192
+ // Handle scoped packages (e.g. '@org/lib') and subpaths (e.g. 'lib/utils')
193
+ const pkgName = raw.startsWith('@') ? raw.split('/').slice(0, 2).join('/') : raw.split('/')[0];
194
+
195
+ if (NODE_BUILTINS.includes(pkgName)) continue;
196
+ if (whitelist.includes(pkgName)) continue;
197
+
198
+ throw new CodeValidationError(
199
+ `Package "${pkgName}" is not in the allowed whitelist. Allowed: ${whitelist.join(', ')}`,
200
+ `require('${pkgName}')`,
201
+ );
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Check Python import/from statements against the whitelist.
207
+ * Built-in modules (os, sys, json, etc.) are always allowed.
208
+ * Handles PyPI→import name mapping (e.g., python-docx → docx, Pillow → PIL).
209
+ */
210
+ private validatePythonImports(code: string, whitelist: string[]): void {
211
+ // Match: import pkg, from pkg import ..., import pkg.sub
212
+ const imports = [...code.matchAll(/(?:^|\n)\s*(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g)];
213
+
214
+ // Build allowed import names from whitelist
215
+ const allowedImports = new Set([...PYTHON_BUILTINS, ...whitelist.map((p) => PYTHON_IMPORT_NAME_MAP[p] || p)]);
216
+
217
+ for (const match of imports) {
218
+ const pkg = match[1];
219
+ if (allowedImports.has(pkg)) continue;
220
+
221
+ throw new CodeValidationError(
222
+ `Module "${pkg}" is not in the allowed whitelist. Allowed: ${[...allowedImports].join(', ')}`,
223
+ `import ${pkg}`,
224
+ );
225
+ }
226
+ }
227
+ }
228
+
229
+ export class CodeValidationError extends Error {
230
+ constructor(
231
+ message: string,
232
+ public matchedPattern: string,
233
+ ) {
234
+ super(`Code validation failed: ${message}`);
235
+ (this as any).name = 'CodeValidationError';
236
+ }
237
+ }