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.
- package/bin/kernel.js +335 -451
- package/config.example.yaml +1 -1
- package/knowledge_base/active_inference_foraging.md +126 -0
- package/knowledge_base/index.md +1 -1
- package/package.json +2 -1
- package/skills/business/business-analyst.md +32 -0
- package/skills/business/product-manager.md +32 -0
- package/skills/business/project-manager.md +32 -0
- package/skills/business/startup-advisor.md +32 -0
- package/skills/creative/music-producer.md +32 -0
- package/skills/creative/photographer.md +32 -0
- package/skills/creative/video-producer.md +32 -0
- package/skills/data/bi-analyst.md +37 -0
- package/skills/data/data-scientist.md +38 -0
- package/skills/data/ml-engineer.md +38 -0
- package/skills/design/graphic-designer.md +38 -0
- package/skills/design/product-designer.md +41 -0
- package/skills/design/ui-ux.md +38 -0
- package/skills/education/curriculum-designer.md +32 -0
- package/skills/education/language-teacher.md +32 -0
- package/skills/education/tutor.md +32 -0
- package/skills/engineering/data-eng.md +55 -0
- package/skills/engineering/devops.md +56 -0
- package/skills/engineering/mobile-dev.md +55 -0
- package/skills/engineering/security-eng.md +55 -0
- package/skills/engineering/sr-backend.md +55 -0
- package/skills/engineering/sr-frontend.md +55 -0
- package/skills/finance/accountant.md +35 -0
- package/skills/finance/crypto-defi.md +39 -0
- package/skills/finance/financial-analyst.md +35 -0
- package/skills/healthcare/health-wellness.md +32 -0
- package/skills/healthcare/medical-researcher.md +33 -0
- package/skills/legal/contract-reviewer.md +35 -0
- package/skills/legal/legal-advisor.md +36 -0
- package/skills/marketing/content-marketer.md +38 -0
- package/skills/marketing/growth.md +38 -0
- package/skills/marketing/seo.md +43 -0
- package/skills/marketing/social-media.md +43 -0
- package/skills/writing/academic-writer.md +33 -0
- package/skills/writing/copywriter.md +32 -0
- package/skills/writing/creative-writer.md +32 -0
- package/skills/writing/tech-writer.md +33 -0
- package/src/agent.js +153 -118
- package/src/automation/scheduler.js +36 -3
- package/src/bot.js +147 -64
- package/src/coder.js +30 -8
- package/src/conversation.js +96 -19
- package/src/dashboard/dashboard.css +6 -0
- package/src/dashboard/dashboard.js +28 -1
- package/src/dashboard/index.html +12 -0
- package/src/dashboard/server.js +77 -15
- package/src/dashboard/shared.js +10 -1
- package/src/life/codebase.js +2 -1
- package/src/life/daydream_engine.js +386 -0
- package/src/life/engine.js +88 -6
- package/src/life/evolution.js +4 -3
- package/src/prompts/orchestrator.js +1 -1
- package/src/prompts/system.js +1 -1
- package/src/prompts/workers.js +8 -1
- package/src/providers/anthropic.js +3 -1
- package/src/providers/base.js +33 -0
- package/src/providers/index.js +1 -1
- package/src/providers/models.js +22 -0
- package/src/providers/openai-compat.js +3 -0
- package/src/services/x-api.js +14 -3
- package/src/skills/loader.js +382 -0
- package/src/swarm/worker-registry.js +2 -2
- package/src/tools/browser.js +10 -3
- package/src/tools/coding.js +16 -0
- package/src/tools/docker.js +13 -0
- package/src/tools/git.js +31 -29
- package/src/tools/jira.js +11 -2
- package/src/tools/monitor.js +9 -1
- package/src/tools/network.js +34 -0
- package/src/tools/orchestrator-tools.js +2 -1
- package/src/tools/os.js +20 -6
- package/src/utils/config.js +87 -83
- package/src/utils/display.js +118 -66
- package/src/utils/logger.js +1 -1
- package/src/utils/timeAwareness.js +72 -0
- package/src/worker.js +26 -33
- package/src/skills/catalog.js +0 -506
- 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:
|
|
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:
|
|
41
|
+
timeout: 86400, // 24 hours
|
|
42
42
|
},
|
|
43
43
|
social: {
|
|
44
44
|
label: 'Social Worker',
|
package/src/tools/browser.js
CHANGED
|
@@ -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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
}
|
package/src/tools/coding.js
CHANGED
|
@@ -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
|
});
|
package/src/tools/docker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
package/src/tools/monitor.js
CHANGED
|
@@ -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
|
},
|
package/src/tools/network.js
CHANGED
|
@@ -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
|
-
|
|
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);
|