kernelbot 1.0.38 → 1.0.40

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 (83) hide show
  1. package/bin/kernel.js +335 -451
  2. package/config.example.yaml +1 -1
  3. package/knowledge_base/active_inference_foraging.md +126 -0
  4. package/knowledge_base/index.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/business/business-analyst.md +32 -0
  7. package/skills/business/product-manager.md +32 -0
  8. package/skills/business/project-manager.md +32 -0
  9. package/skills/business/startup-advisor.md +32 -0
  10. package/skills/creative/music-producer.md +32 -0
  11. package/skills/creative/photographer.md +32 -0
  12. package/skills/creative/video-producer.md +32 -0
  13. package/skills/data/bi-analyst.md +37 -0
  14. package/skills/data/data-scientist.md +38 -0
  15. package/skills/data/ml-engineer.md +38 -0
  16. package/skills/design/graphic-designer.md +38 -0
  17. package/skills/design/product-designer.md +41 -0
  18. package/skills/design/ui-ux.md +38 -0
  19. package/skills/education/curriculum-designer.md +32 -0
  20. package/skills/education/language-teacher.md +32 -0
  21. package/skills/education/tutor.md +32 -0
  22. package/skills/engineering/data-eng.md +55 -0
  23. package/skills/engineering/devops.md +56 -0
  24. package/skills/engineering/mobile-dev.md +55 -0
  25. package/skills/engineering/security-eng.md +55 -0
  26. package/skills/engineering/sr-backend.md +55 -0
  27. package/skills/engineering/sr-frontend.md +55 -0
  28. package/skills/finance/accountant.md +35 -0
  29. package/skills/finance/crypto-defi.md +39 -0
  30. package/skills/finance/financial-analyst.md +35 -0
  31. package/skills/healthcare/health-wellness.md +32 -0
  32. package/skills/healthcare/medical-researcher.md +33 -0
  33. package/skills/legal/contract-reviewer.md +35 -0
  34. package/skills/legal/legal-advisor.md +36 -0
  35. package/skills/marketing/content-marketer.md +38 -0
  36. package/skills/marketing/growth.md +38 -0
  37. package/skills/marketing/seo.md +43 -0
  38. package/skills/marketing/social-media.md +43 -0
  39. package/skills/writing/academic-writer.md +33 -0
  40. package/skills/writing/copywriter.md +32 -0
  41. package/skills/writing/creative-writer.md +32 -0
  42. package/skills/writing/tech-writer.md +33 -0
  43. package/src/agent.js +153 -118
  44. package/src/automation/scheduler.js +36 -3
  45. package/src/bot.js +147 -64
  46. package/src/coder.js +30 -8
  47. package/src/conversation.js +96 -19
  48. package/src/dashboard/dashboard.css +6 -0
  49. package/src/dashboard/dashboard.js +28 -1
  50. package/src/dashboard/index.html +12 -0
  51. package/src/dashboard/server.js +77 -15
  52. package/src/dashboard/shared.js +10 -1
  53. package/src/life/codebase.js +2 -1
  54. package/src/life/daydream_engine.js +386 -0
  55. package/src/life/engine.js +88 -6
  56. package/src/life/evolution.js +4 -3
  57. package/src/prompts/orchestrator.js +1 -1
  58. package/src/prompts/system.js +1 -1
  59. package/src/prompts/workers.js +8 -1
  60. package/src/providers/anthropic.js +3 -1
  61. package/src/providers/base.js +33 -0
  62. package/src/providers/index.js +1 -1
  63. package/src/providers/models.js +22 -0
  64. package/src/providers/openai-compat.js +3 -0
  65. package/src/services/x-api.js +14 -3
  66. package/src/skills/loader.js +382 -0
  67. package/src/swarm/worker-registry.js +2 -2
  68. package/src/tools/browser.js +10 -3
  69. package/src/tools/coding.js +16 -0
  70. package/src/tools/docker.js +13 -0
  71. package/src/tools/git.js +31 -29
  72. package/src/tools/jira.js +11 -2
  73. package/src/tools/monitor.js +9 -1
  74. package/src/tools/network.js +34 -0
  75. package/src/tools/orchestrator-tools.js +2 -1
  76. package/src/tools/os.js +20 -6
  77. package/src/utils/config.js +87 -83
  78. package/src/utils/display.js +118 -66
  79. package/src/utils/logger.js +1 -1
  80. package/src/utils/timeAwareness.js +72 -0
  81. package/src/worker.js +26 -33
  82. package/src/skills/catalog.js +0 -506
  83. package/src/skills/custom.js +0 -128
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Skills loader — parses markdown skill files with YAML frontmatter.
3
+ * Replaces catalog.js as the primary skill source.
4
+ * Uses js-yaml (already a dependency) instead of gray-matter.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, unlinkSync, renameSync } from 'fs';
8
+ import { join, basename, extname } from 'path';
9
+ import { homedir } from 'os';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname } from 'path';
12
+ import yaml from 'js-yaml';
13
+ import { getLogger as _getLogger } from '../utils/logger.js';
14
+
15
+ /** Safe logger that falls back to console if logger not initialized. */
16
+ function getLogger() {
17
+ try {
18
+ return _getLogger();
19
+ } catch {
20
+ return console;
21
+ }
22
+ }
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const BUILTIN_DIR = join(__dirname, '..', '..', 'skills');
26
+ const CUSTOM_DIR = join(homedir(), '.kernelbot', 'skills');
27
+
28
+ /** Category metadata for display purposes. */
29
+ export const SKILL_CATEGORIES = {
30
+ engineering: { name: 'Engineering', emoji: '⚙️' },
31
+ design: { name: 'Design', emoji: '🎨' },
32
+ marketing: { name: 'Marketing', emoji: '📣' },
33
+ business: { name: 'Business', emoji: '💼' },
34
+ writing: { name: 'Writing', emoji: '✍️' },
35
+ data: { name: 'Data & AI', emoji: '📊' },
36
+ finance: { name: 'Finance', emoji: '💰' },
37
+ legal: { name: 'Legal', emoji: '⚖️' },
38
+ education: { name: 'Education', emoji: '📚' },
39
+ healthcare: { name: 'Healthcare', emoji: '🏥' },
40
+ creative: { name: 'Creative', emoji: '🎬' },
41
+ };
42
+
43
+ /** In-memory cache. Map<id, Skill> */
44
+ let skillCache = null;
45
+
46
+ /**
47
+ * Parse a markdown file with YAML frontmatter.
48
+ * Returns { data: {frontmatter}, body: string } or null on failure.
49
+ */
50
+ function parseFrontmatter(content) {
51
+ const trimmed = content.trim();
52
+ if (!trimmed.startsWith('---')) return null;
53
+
54
+ const endIdx = trimmed.indexOf('---', 3);
55
+ if (endIdx === -1) return null;
56
+
57
+ const yamlStr = trimmed.slice(3, endIdx).trim();
58
+ const body = trimmed.slice(endIdx + 3).trim();
59
+
60
+ try {
61
+ const data = yaml.load(yamlStr) || {};
62
+ return { data, body };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Load a single .md skill file into a Skill object.
70
+ * @returns {object|null} Skill object or null if invalid
71
+ */
72
+ function loadSkillFile(filePath) {
73
+ try {
74
+ const content = readFileSync(filePath, 'utf-8');
75
+ const parsed = parseFrontmatter(content);
76
+ if (!parsed) return null;
77
+
78
+ const { data, body } = parsed;
79
+ if (!data.id || !data.name) return null;
80
+
81
+ return {
82
+ id: data.id,
83
+ name: data.name,
84
+ emoji: data.emoji || '🛠️',
85
+ category: data.category || 'custom',
86
+ description: data.description || '',
87
+ worker_affinity: data.worker_affinity || null,
88
+ tags: data.tags || [],
89
+ body, // raw markdown body = the full prompt
90
+ filePath,
91
+ isCustom: filePath.startsWith(CUSTOM_DIR),
92
+ };
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Scan a directory recursively for .md files and load them as skills.
100
+ * @returns {Map<string, object>} Map of id → skill
101
+ */
102
+ function scanDirectory(dir) {
103
+ const results = new Map();
104
+ if (!existsSync(dir)) return results;
105
+
106
+ function walk(current) {
107
+ const entries = readdirSync(current, { withFileTypes: true });
108
+ for (const entry of entries) {
109
+ const fullPath = join(current, entry.name);
110
+ if (entry.isDirectory()) {
111
+ walk(fullPath);
112
+ } else if (entry.isFile() && extname(entry.name) === '.md') {
113
+ const skill = loadSkillFile(fullPath);
114
+ if (skill) {
115
+ results.set(skill.id, skill);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ walk(dir);
122
+ return results;
123
+ }
124
+
125
+ /**
126
+ * Load all skills from built-in and custom directories.
127
+ * Custom skills override built-in skills with the same ID.
128
+ * @param {boolean} force - Force reload even if cached
129
+ * @returns {Map<string, object>} Map of id → skill
130
+ */
131
+ export function loadAllSkills(force = false) {
132
+ if (skillCache && !force) return skillCache;
133
+
134
+ const logger = getLogger();
135
+ skillCache = new Map();
136
+
137
+ // Load built-in skills first
138
+ const builtins = scanDirectory(BUILTIN_DIR);
139
+ for (const [id, skill] of builtins) {
140
+ skillCache.set(id, skill);
141
+ }
142
+
143
+ // Load custom skills (override built-ins with same ID)
144
+ const customs = scanDirectory(CUSTOM_DIR);
145
+ for (const [id, skill] of customs) {
146
+ skillCache.set(id, skill);
147
+ }
148
+
149
+ logger.debug(`Skills loaded: ${builtins.size} built-in, ${customs.size} custom, ${skillCache.size} total`);
150
+ return skillCache;
151
+ }
152
+
153
+ /** Get a skill by ID. Custom-first lookup (custom dir overrides built-in). */
154
+ export function getSkillById(id) {
155
+ const skills = loadAllSkills();
156
+ return skills.get(id) || null;
157
+ }
158
+
159
+ /** Return all skills in a given category. */
160
+ export function getSkillsByCategory(categoryKey) {
161
+ const skills = loadAllSkills();
162
+ return [...skills.values()].filter(s => s.category === categoryKey);
163
+ }
164
+
165
+ /** Return an array of { key, name, emoji, count } for all categories that have skills. */
166
+ export function getCategoryList() {
167
+ const skills = loadAllSkills();
168
+ const counts = new Map();
169
+
170
+ for (const skill of skills.values()) {
171
+ counts.set(skill.category, (counts.get(skill.category) || 0) + 1);
172
+ }
173
+
174
+ const result = [];
175
+ // Built-in categories first (in defined order)
176
+ for (const [key, cat] of Object.entries(SKILL_CATEGORIES)) {
177
+ const count = counts.get(key) || 0;
178
+ if (count > 0) {
179
+ result.push({ key, name: cat.name, emoji: cat.emoji, count });
180
+ }
181
+ }
182
+
183
+ // Custom category if any custom skills exist
184
+ const customSkills = [...skills.values()].filter(s => s.isCustom && s.category === 'custom');
185
+ if (customSkills.length > 0 && !result.find(r => r.key === 'custom')) {
186
+ result.push({ key: 'custom', name: 'Custom', emoji: '🛠️', count: customSkills.length });
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Build a combined prompt string from multiple skill IDs.
194
+ * Each skill gets a header and its full body.
195
+ * @param {string[]} skillIds - Array of skill IDs
196
+ * @param {number} charBudget - Max characters (default ~16000 ≈ 4000 tokens)
197
+ * @returns {string|null} Combined prompt or null if no valid skills
198
+ */
199
+ export function buildSkillPrompt(skillIds, charBudget = 16000) {
200
+ if (!skillIds || skillIds.length === 0) return null;
201
+
202
+ const skills = loadAllSkills();
203
+ const sections = [];
204
+
205
+ for (const id of skillIds) {
206
+ const skill = skills.get(id);
207
+ if (!skill) continue;
208
+ sections.push(`### Skill: ${skill.emoji} ${skill.name}\n${skill.body}`);
209
+ }
210
+
211
+ if (sections.length === 0) return null;
212
+
213
+ let combined = sections.join('\n\n');
214
+
215
+ // Truncate from end if over budget
216
+ if (combined.length > charBudget) {
217
+ combined = combined.slice(0, charBudget) + '\n\n[...truncated due to token budget]';
218
+ }
219
+
220
+ return combined;
221
+ }
222
+
223
+ /**
224
+ * Filter skill IDs by worker affinity.
225
+ * Skills with null worker_affinity pass through (available to all workers).
226
+ * @param {string[]} skillIds - All active skill IDs
227
+ * @param {string} workerType - The worker type (coding, browser, system, etc.)
228
+ * @returns {string[]} Filtered skill IDs
229
+ */
230
+ export function filterSkillsForWorker(skillIds, workerType) {
231
+ if (!skillIds || skillIds.length === 0) return [];
232
+
233
+ const skills = loadAllSkills();
234
+ return skillIds.filter(id => {
235
+ const skill = skills.get(id);
236
+ if (!skill) return false;
237
+ // null affinity = available to all workers
238
+ if (!skill.worker_affinity) return true;
239
+ return skill.worker_affinity.includes(workerType);
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Save a custom skill as a .md file.
245
+ * @param {{ name: string, emoji?: string, category?: string, body: string, description?: string }} opts
246
+ * @returns {object} The saved skill object
247
+ */
248
+ export function saveCustomSkill({ name, emoji, category, body, description }) {
249
+ mkdirSync(CUSTOM_DIR, { recursive: true });
250
+
251
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
252
+ let id = `custom_${slug}`;
253
+
254
+ // Check for collision
255
+ const existing = loadAllSkills();
256
+ if (existing.has(id)) {
257
+ let n = 2;
258
+ while (existing.has(`${id}-${n}`)) n++;
259
+ id = `${id}-${n}`;
260
+ }
261
+
262
+ const frontmatter = {
263
+ id,
264
+ name,
265
+ emoji: emoji || '🛠️',
266
+ category: category || 'custom',
267
+ description: description || `Custom skill: ${name}`,
268
+ };
269
+
270
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trim();
271
+ const content = `---\n${yamlStr}\n---\n\n${body}`;
272
+
273
+ const filePath = join(CUSTOM_DIR, `${id}.md`);
274
+ writeFileSync(filePath, content, 'utf-8');
275
+
276
+ // Invalidate cache
277
+ skillCache = null;
278
+
279
+ return loadSkillFile(filePath);
280
+ }
281
+
282
+ /**
283
+ * Delete a custom skill by ID.
284
+ * @returns {boolean} true if found and deleted
285
+ */
286
+ export function deleteCustomSkill(id) {
287
+ const skill = getSkillById(id);
288
+ if (!skill || !skill.isCustom) return false;
289
+
290
+ try {
291
+ unlinkSync(skill.filePath);
292
+ skillCache = null; // invalidate cache
293
+ return true;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
299
+ /** Get all custom skills. */
300
+ export function getCustomSkills() {
301
+ const skills = loadAllSkills();
302
+ return [...skills.values()].filter(s => s.isCustom);
303
+ }
304
+
305
+ /**
306
+ * Migrate old custom_skills.json to .md files.
307
+ * Called once on startup if the old file exists.
308
+ */
309
+ export function migrateOldCustomSkills() {
310
+ const oldFile = join(homedir(), '.kernelbot', 'custom_skills.json');
311
+ if (!existsSync(oldFile)) return;
312
+
313
+ const logger = getLogger();
314
+ try {
315
+ const raw = readFileSync(oldFile, 'utf-8');
316
+ const oldSkills = JSON.parse(raw);
317
+ if (!Array.isArray(oldSkills) || oldSkills.length === 0) return;
318
+
319
+ mkdirSync(CUSTOM_DIR, { recursive: true });
320
+ let migrated = 0;
321
+
322
+ for (const old of oldSkills) {
323
+ if (!old.name || !old.systemPrompt) continue;
324
+
325
+ const slug = old.id || old.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
326
+ const id = slug.startsWith('custom_') ? slug : `custom_${slug}`;
327
+ const filePath = join(CUSTOM_DIR, `${id}.md`);
328
+
329
+ // Don't overwrite if already migrated
330
+ if (existsSync(filePath)) continue;
331
+
332
+ const frontmatter = {
333
+ id,
334
+ name: old.name,
335
+ emoji: old.emoji || '🛠️',
336
+ category: 'custom',
337
+ description: old.description || `Custom skill: ${old.name}`,
338
+ };
339
+
340
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trim();
341
+ const content = `---\n${yamlStr}\n---\n\n${old.systemPrompt}`;
342
+ writeFileSync(filePath, content, 'utf-8');
343
+ migrated++;
344
+ }
345
+
346
+ if (migrated > 0) {
347
+ logger.info(`Migrated ${migrated} custom skills from JSON to .md files`);
348
+ }
349
+
350
+ // Rename old file to mark as migrated
351
+ renameSync(oldFile, oldFile + '.bak');
352
+ logger.info('Renamed custom_skills.json to custom_skills.json.bak');
353
+ } catch (err) {
354
+ logger.warn(`Failed to migrate old custom skills: ${err.message}`);
355
+ }
356
+ }
357
+
358
+ // ── Backward-compatible aliases ──────────────────────────────────────────
359
+ // These maintain the same API surface that custom.js exported,
360
+ // so existing imports keep working during the transition.
361
+
362
+ /** Unified skill lookup (same as getSkillById — customs already override). */
363
+ export const getUnifiedSkillById = getSkillById;
364
+
365
+ /** Unified category list (same as getCategoryList — already includes custom). */
366
+ export const getUnifiedCategoryList = getCategoryList;
367
+
368
+ /** Unified skills by category (same as getSkillsByCategory). */
369
+ export const getUnifiedSkillsByCategory = getSkillsByCategory;
370
+
371
+ /** Backward-compat: load custom skills from disk (now a no-op, auto-loaded). */
372
+ export function loadCustomSkills() {
373
+ loadAllSkills();
374
+ }
375
+
376
+ /**
377
+ * Backward-compat: add a custom skill.
378
+ * @param {{ name: string, systemPrompt: string, description?: string }} opts
379
+ */
380
+ export function addCustomSkill({ name, systemPrompt, description }) {
381
+ return saveCustomSkill({ name, body: systemPrompt, description });
382
+ }
@@ -24,7 +24,7 @@ export const WORKER_TYPES = {
24
24
  emoji: '🖥️',
25
25
  categories: ['core', 'process', 'monitor', 'network'],
26
26
  description: 'OS operations, monitoring, network',
27
- timeout: 600, // 10 minutes
27
+ timeout: 86400, // 24 hours
28
28
  },
29
29
  devops: {
30
30
  label: 'DevOps Worker',
@@ -38,7 +38,7 @@ export const WORKER_TYPES = {
38
38
  emoji: '🔍',
39
39
  categories: ['browser', 'core'],
40
40
  description: 'Deep web research and analysis',
41
- timeout: 600, // 10 minutes
41
+ timeout: 86400, // 24 hours
42
42
  },
43
43
  social: {
44
44
  label: 'Social Worker',
@@ -665,9 +665,16 @@ async function handleInteract(params, context) {
665
665
 
666
666
  for (const action of params.actions) {
667
667
  if (action.type === 'evaluate' && action.script) {
668
- const blocked = /fetch\s*\(|XMLHttpRequest|window\.location\s*=|document\.cookie|localStorage|sessionStorage/i;
669
- if (blocked.test(action.script)) {
670
- return { error: 'Script contains blocked patterns (network requests, cookie access, storage access, or redirects)' };
668
+ // Block dangerous APIs using both direct access and bracket notation bypass patterns
669
+ const blocked = /fetch\s*\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage/i;
670
+ // Also detect bracket notation bypasses like window['location'], globalThis['fetch']
671
+ const bracketBypass = /\[\s*['"`](location|cookie|fetch|XMLHttpRequest|localStorage|sessionStorage|eval|Function)['"`]\s*\]/i;
672
+ // Block window.location assignment (direct or bracket)
673
+ const locationAssign = /window\s*(\.\s*location\s*=|\[\s*['"`]location['"`]\s*\]\s*=)/i;
674
+ // Block eval and Function constructor
675
+ const evalPattern = /\beval\s*\(|\bnew\s+Function\s*\(/i;
676
+ if (blocked.test(action.script) || bracketBypass.test(action.script) || locationAssign.test(action.script) || evalPattern.test(action.script)) {
677
+ return { error: 'Script contains blocked patterns (network requests, cookie access, storage access, eval, or redirects)' };
671
678
  }
672
679
  }
673
680
  }
@@ -34,6 +34,10 @@ export const definitions = [
34
34
  type: 'number',
35
35
  description: 'Max turns for Claude Code (optional, default from config)',
36
36
  },
37
+ timeout_seconds: {
38
+ type: 'number',
39
+ description: 'Override timeout in seconds for this invocation (optional, default from config)',
40
+ },
37
41
  },
38
42
  required: ['working_directory', 'prompt'],
39
43
  },
@@ -46,6 +50,17 @@ export const handlers = {
46
50
  const onUpdate = context.onUpdate || null;
47
51
  const dir = resolve(params.working_directory);
48
52
 
53
+ // Validate working_directory against blocked paths
54
+ const blockedPaths = context.config?.security?.blocked_paths || [];
55
+ for (const bp of blockedPaths) {
56
+ const expandedBp = resolve(bp.startsWith('~') ? bp.replace('~', process.env.HOME || '') : bp);
57
+ if (dir.startsWith(expandedBp) || dir === expandedBp) {
58
+ const msg = `Blocked: working directory is within restricted path ${bp}`;
59
+ logger.warn(`spawn_claude_code: ${msg}`);
60
+ return { error: msg };
61
+ }
62
+ }
63
+
49
64
  // Validate directory exists
50
65
  if (!existsSync(dir)) {
51
66
  const msg = `Directory not found: ${dir}`;
@@ -60,6 +75,7 @@ export const handlers = {
60
75
  workingDirectory: dir,
61
76
  prompt: params.prompt,
62
77
  maxTurns: params.max_turns,
78
+ timeoutMs: params.timeout_seconds ? params.timeout_seconds * 1000 : undefined,
63
79
  onOutput: onUpdate,
64
80
  signal: context.signal || null,
65
81
  });
@@ -94,6 +94,19 @@ export const handlers = {
94
94
  docker_compose: async (params) => {
95
95
  const logger = getLogger();
96
96
  const dir = params.project_dir ? `-f ${shellEscape(params.project_dir + '/docker-compose.yml')}` : '';
97
+
98
+ // Sanitize action: only allow known compose subcommands and safe flags
99
+ const ALLOWED_ACTIONS = ['up', 'down', 'build', 'logs', 'ps', 'restart', 'stop', 'start', 'pull', 'config', 'exec', 'run', 'top', 'images'];
100
+ const actionParts = params.action.trim().split(/\s+/);
101
+ const subcommand = actionParts[0];
102
+ if (!ALLOWED_ACTIONS.includes(subcommand)) {
103
+ return { error: `Invalid compose action: "${subcommand}". Allowed: ${ALLOWED_ACTIONS.join(', ')}` };
104
+ }
105
+ // Reject shell metacharacters in the action string to prevent injection
106
+ if (/[;&|`$(){}]/.test(params.action)) {
107
+ return { error: 'Invalid characters in compose action' };
108
+ }
109
+
97
110
  logger.debug(`docker_compose: ${params.action}`);
98
111
  const result = await run(`docker compose ${dir} ${params.action}`, 120000);
99
112
  if (result.error) logger.error(`docker_compose '${params.action}' failed: ${result.error}`);
package/src/tools/git.js CHANGED
@@ -10,22 +10,29 @@ function getWorkspaceDir(config) {
10
10
  return dir;
11
11
  }
12
12
 
13
- function injectToken(url, config) {
13
+ /**
14
+ * Get the auth header value for GitHub HTTPS operations.
15
+ * Uses extraheader instead of embedding credentials in the URL,
16
+ * preventing token leaks in git remote -v, error messages, and process listings.
17
+ */
18
+ function getGitAuthEnv(config) {
14
19
  const token = config.github?.token || process.env.GITHUB_TOKEN;
15
- if (!token) return url;
20
+ if (!token) return null;
21
+ const base64 = Buffer.from(`x-access-token:${token}`).toString('base64');
22
+ return `AUTHORIZATION: basic ${base64}`;
23
+ }
16
24
 
17
- // Inject token into HTTPS GitHub URLs for auth-free push/pull
18
- try {
19
- const parsed = new URL(url);
20
- if (parsed.hostname === 'github.com' && parsed.protocol === 'https:') {
21
- parsed.username = token;
22
- parsed.password = 'x-oauth-basic';
23
- return parsed.toString();
24
- }
25
- } catch {
26
- // Not a parseable URL (e.g. org/repo shorthand before expansion)
25
+ /**
26
+ * Configure a simple-git instance with auth via extraheader (not URL embedding).
27
+ */
28
+ function configureGitAuth(git, config) {
29
+ const authHeader = getGitAuthEnv(config);
30
+ if (authHeader) {
31
+ git.env('GIT_CONFIG_COUNT', '1');
32
+ git.env('GIT_CONFIG_KEY_0', 'http.extraheader');
33
+ git.env('GIT_CONFIG_VALUE_0', authHeader);
27
34
  }
28
- return url;
35
+ return git;
29
36
  }
30
37
 
31
38
  export const definitions = [
@@ -107,15 +114,19 @@ export const handlers = {
107
114
  url = `https://github.com/${repo}.git`;
108
115
  }
109
116
 
110
- // Inject GitHub token for authenticated clone (enables push later)
111
- const authUrl = injectToken(url, context.config);
112
-
113
117
  const repoName = dest || repo.split('/').pop().replace('.git', '');
118
+ // Prevent path traversal — dest must not escape workspace directory
119
+ if (repoName.includes('..') || repoName.startsWith('/')) {
120
+ return { error: 'Invalid destination: path traversal is not allowed' };
121
+ }
114
122
  const targetDir = join(workspaceDir, repoName);
123
+ if (!targetDir.startsWith(workspaceDir)) {
124
+ return { error: 'Invalid destination: path escapes workspace directory' };
125
+ }
115
126
 
116
127
  try {
117
- const git = simpleGit();
118
- await git.clone(authUrl, targetDir);
128
+ const git = configureGitAuth(simpleGit(), context.config);
129
+ await git.clone(url, targetDir);
119
130
  return { success: true, path: targetDir };
120
131
  } catch (err) {
121
132
  getLogger().error(`git_clone failed for ${params.repo}: ${err.message}`);
@@ -155,17 +166,8 @@ export const handlers = {
155
166
  git_push: async (params, context) => {
156
167
  const { dir, force = false } = params;
157
168
  try {
158
- const git = simpleGit(dir);
159
-
160
- // Ensure remote URL has auth token for push
161
- const remotes = await git.getRemotes(true);
162
- const origin = remotes.find((r) => r.name === 'origin');
163
- if (origin) {
164
- const authUrl = injectToken(origin.refs.push || origin.refs.fetch, context.config);
165
- if (authUrl !== (origin.refs.push || origin.refs.fetch)) {
166
- await git.remote(['set-url', 'origin', authUrl]);
167
- }
168
- }
169
+ // Use extraheader auth instead of modifying remote URLs
170
+ const git = configureGitAuth(simpleGit(dir), context.config);
169
171
 
170
172
  const branch = (await git.branchLocal()).current;
171
173
  const options = ['-u'];
package/src/tools/jira.js CHANGED
@@ -186,7 +186,11 @@ export const handlers = {
186
186
  const client = getJiraClient(context.config);
187
187
  const assignee = params.assignee || 'currentUser()';
188
188
  const maxResults = params.max_results || 20;
189
- const jql = `assignee = ${assignee} ORDER BY updated DESC`;
189
+ // Sanitize assignee to prevent JQL injection allow currentUser() or quote the value
190
+ const safeAssignee = assignee === 'currentUser()'
191
+ ? 'currentUser()'
192
+ : `"${assignee.replace(/["\\]/g, '')}"`;
193
+ const jql = `assignee = ${safeAssignee} ORDER BY updated DESC`;
190
194
 
191
195
  const { data } = await client.get('/search', {
192
196
  params: {
@@ -215,7 +219,12 @@ export const handlers = {
215
219
  try {
216
220
  const client = getJiraClient(context.config);
217
221
  const maxResults = params.max_results || 20;
218
- const jql = `project = ${params.project_key} ORDER BY updated DESC`;
222
+ // Sanitize project_key to prevent JQL injection only allow alphanumeric and underscore
223
+ const safeProjectKey = params.project_key.replace(/[^a-zA-Z0-9_]/g, '');
224
+ if (safeProjectKey !== params.project_key) {
225
+ return { error: `Invalid project key: "${params.project_key}". Only alphanumeric characters and underscores are allowed.` };
226
+ }
227
+ const jql = `project = "${safeProjectKey}" ORDER BY updated DESC`;
219
228
 
220
229
  const { data } = await client.get('/search', {
221
230
  params: {
@@ -75,7 +75,15 @@ export const handlers = {
75
75
  return await run(`journalctl -n ${finalLines}${filterArg} --no-pager`);
76
76
  }
77
77
 
78
- // Reading a log file
78
+ // Reading a log file — restrict to known safe directories
79
+ const { resolve: resolvePath } = await import('path');
80
+ const resolvedSource = resolvePath(source);
81
+ const ALLOWED_LOG_DIRS = ['/var/log', process.cwd(), process.env.HOME || ''];
82
+ const isAllowed = ALLOWED_LOG_DIRS.some(dir => dir && resolvedSource.startsWith(dir));
83
+ if (!isAllowed) {
84
+ return { error: `Blocked: log source must be within /var/log, the project directory, or home directory` };
85
+ }
86
+
79
87
  const filterCmd = filter ? ` | grep -i ${shellEscape(filter)}` : '';
80
88
  return await run(`tail -n ${finalLines} ${shellEscape(source)}${filterCmd}`);
81
89
  },
@@ -37,6 +37,25 @@ export const definitions = [
37
37
  },
38
38
  ];
39
39
 
40
+ // SSRF protection: block requests to internal/cloud metadata addresses
41
+ const BLOCKED_HOSTS = [
42
+ /^127\./,
43
+ /^10\./,
44
+ /^172\.(1[6-9]|2\d|3[01])\./,
45
+ /^192\.168\./,
46
+ /^169\.254\./, // AWS/cloud metadata endpoint
47
+ /^0\./,
48
+ /^localhost$/i,
49
+ /^metadata\.google\.internal$/i,
50
+ /^\[::1\]$/,
51
+ /^\[fd/i, // IPv6 private
52
+ /^\[fe80:/i, // IPv6 link-local
53
+ ];
54
+
55
+ function isBlockedHost(host) {
56
+ return BLOCKED_HOSTS.some(pattern => pattern.test(host));
57
+ }
58
+
40
59
  export const handlers = {
41
60
  check_port: async (params) => {
42
61
  const logger = getLogger();
@@ -44,6 +63,11 @@ export const handlers = {
44
63
  const port = parseInt(params.port, 10);
45
64
  if (!Number.isFinite(port) || port <= 0 || port > 65535) return { error: 'Invalid port number' };
46
65
 
66
+ // SSRF protection: block internal network probing
67
+ if (host !== 'localhost' && isBlockedHost(host)) {
68
+ return { error: 'Blocked: cannot probe internal or cloud metadata addresses' };
69
+ }
70
+
47
71
  logger.debug(`check_port: checking ${host}:${port}`);
48
72
  // Use nc (netcat) for port check — works on both macOS and Linux
49
73
  const result = await run(`nc -z -w 3 ${shellEscape(host)} ${port} 2>&1 && echo "OPEN" || echo "CLOSED"`, 5000);
@@ -60,6 +84,16 @@ export const handlers = {
60
84
  curl_url: async (params) => {
61
85
  const { url, method = 'GET', headers, body } = params;
62
86
 
87
+ // SSRF protection: block requests to internal networks and cloud metadata
88
+ try {
89
+ const parsed = new URL(url);
90
+ if (isBlockedHost(parsed.hostname)) {
91
+ return { error: 'Blocked: cannot access internal or cloud metadata addresses' };
92
+ }
93
+ } catch {
94
+ return { error: 'Invalid URL' };
95
+ }
96
+
63
97
  let cmd = `curl -s -w "\\n---HTTP_STATUS:%{http_code}" -X ${shellEscape(method)}`;
64
98
 
65
99
  if (headers) {
@@ -770,7 +770,8 @@ export async function executeOrchestratorTool(name, input, context) {
770
770
  if (!conversationManager) return { error: 'Conversation system not available.' };
771
771
 
772
772
  const { query, chat_id } = input;
773
- const targetChatId = chat_id || chatId;
773
+ // Prevent cross-chat history access — only allow searching own chat
774
+ const targetChatId = chatId;
774
775
  logger.info(`[search_conversations] Searching chat ${targetChatId} for: "${query}"`);
775
776
 
776
777
  const history = conversationManager.getHistory(targetChatId);