squidclaw 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js CHANGED
@@ -62,6 +62,8 @@ export class SquidclawEngine {
62
62
  pipeline.use('media', mediaMiddleware);
63
63
  pipeline.use('auto-links', autoLinksMiddleware);
64
64
  pipeline.use('auto-memory', autoMemoryMiddleware);
65
+ const { configChatMiddleware } = await import('./middleware/config-chat.js');
66
+ pipeline.use('config-chat', configChatMiddleware);
65
67
  pipeline.use('skill-check', skillCheckMiddleware);
66
68
  pipeline.use('typing', typingMiddleware); // wraps AI call with typing indicator
67
69
  pipeline.use('ai-process', aiProcessMiddleware);
@@ -136,6 +138,19 @@ export class SquidclawEngine {
136
138
  // 5. Features (reminders, auto-memory, usage alerts)
137
139
  await this._initFeatures();
138
140
 
141
+ // 5b. Config Manager
142
+ try {
143
+ const { ConfigManager } = await import('./features/config-manager.js');
144
+ this.configManager = new ConfigManager(this);
145
+ } catch {}
146
+
147
+ // 5c. Sub-agents
148
+ try {
149
+ const { SubAgentManager } = await import('./features/sub-agents.js');
150
+ this.subAgents = new SubAgentManager(this);
151
+ if (this.toolRouter) this.toolRouter._subAgents = this.subAgents;
152
+ } catch {}
153
+
139
154
  // 6. Message Pipeline
140
155
  this.pipeline = await this._buildPipeline();
141
156
  console.log(` 🔧 Pipeline: ${this.pipeline.middleware.length} middleware`);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * 🦑 Config Manager
3
+ * Hot reload, chat commands, .env support, profiles, validation
4
+ */
5
+
6
+ import { loadConfig, saveConfig, getHome } from '../core/config.js';
7
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { logger } from '../core/logger.js';
10
+
11
+ export class ConfigManager {
12
+ constructor(engine) {
13
+ this.engine = engine;
14
+ this.home = getHome();
15
+ this._loadEnv();
16
+ this._watchConfig();
17
+ }
18
+
19
+ // ── .env support ──
20
+
21
+ _loadEnv() {
22
+ const envPath = join(this.home, '.env');
23
+ if (!existsSync(envPath)) return;
24
+
25
+ try {
26
+ const lines = readFileSync(envPath, 'utf8').split('\n');
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith('#')) continue;
30
+ const eqIdx = trimmed.indexOf('=');
31
+ if (eqIdx === -1) continue;
32
+ const key = trimmed.slice(0, eqIdx).trim();
33
+ const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
34
+ process.env[key] = value;
35
+ }
36
+ logger.info('config', 'Loaded .env file');
37
+ } catch {}
38
+ }
39
+
40
+ // ── Hot reload ──
41
+
42
+ _watchConfig() {
43
+ const configPath = join(this.home, 'config.json');
44
+ try {
45
+ const { watch } = require('fs');
46
+ let debounce = null;
47
+ watch(configPath, () => {
48
+ if (debounce) clearTimeout(debounce);
49
+ debounce = setTimeout(() => {
50
+ try {
51
+ this.engine.config = loadConfig();
52
+ logger.info('config', 'Config hot-reloaded');
53
+ } catch (err) {
54
+ logger.error('config', 'Hot reload failed: ' + err.message);
55
+ }
56
+ }, 1000);
57
+ });
58
+ } catch {
59
+ // fs.watch not available — use polling
60
+ this._pollConfig(configPath);
61
+ }
62
+ }
63
+
64
+ _pollConfig(configPath) {
65
+ let lastMtime = 0;
66
+ try { lastMtime = readFileSync(configPath).length; } catch {}
67
+
68
+ setInterval(() => {
69
+ try {
70
+ const content = readFileSync(configPath, 'utf8');
71
+ if (content.length !== lastMtime) {
72
+ lastMtime = content.length;
73
+ this.engine.config = loadConfig();
74
+ logger.info('config', 'Config hot-reloaded (poll)');
75
+ }
76
+ } catch {}
77
+ }, 5000);
78
+ }
79
+
80
+ // ── Set config value ──
81
+
82
+ set(path, value) {
83
+ const config = loadConfig();
84
+ const parts = path.split('.');
85
+ let obj = config;
86
+
87
+ for (let i = 0; i < parts.length - 1; i++) {
88
+ if (!obj[parts[i]]) obj[parts[i]] = {};
89
+ obj = obj[parts[i]];
90
+ }
91
+
92
+ // Auto-convert types
93
+ if (value === 'true') value = true;
94
+ else if (value === 'false') value = false;
95
+ else if (!isNaN(value) && value !== '') value = Number(value);
96
+
97
+ obj[parts[parts.length - 1]] = value;
98
+ saveConfig(config);
99
+ this.engine.config = config;
100
+
101
+ logger.info('config', `Set ${path} = ${JSON.stringify(value)}`);
102
+ return { path, value };
103
+ }
104
+
105
+ // ── Get config value ──
106
+
107
+ get(path) {
108
+ const config = loadConfig();
109
+ const parts = path.split('.');
110
+ let obj = config;
111
+
112
+ for (const part of parts) {
113
+ if (obj === undefined) return undefined;
114
+ obj = obj[part];
115
+ }
116
+
117
+ return obj;
118
+ }
119
+
120
+ // ── Profiles ──
121
+
122
+ saveProfile(name) {
123
+ const configPath = join(this.home, 'config.json');
124
+ const profilePath = join(this.home, `config.${name}.json`);
125
+ copyFileSync(configPath, profilePath);
126
+ return profilePath;
127
+ }
128
+
129
+ loadProfile(name) {
130
+ const profilePath = join(this.home, `config.${name}.json`);
131
+ if (!existsSync(profilePath)) throw new Error('Profile not found: ' + name);
132
+
133
+ const configPath = join(this.home, 'config.json');
134
+ copyFileSync(profilePath, configPath);
135
+ this.engine.config = loadConfig();
136
+ return true;
137
+ }
138
+
139
+ listProfiles() {
140
+ const { readdirSync } = require('fs');
141
+ return readdirSync(this.home)
142
+ .filter(f => f.startsWith('config.') && f.endsWith('.json') && f !== 'config.json')
143
+ .map(f => f.replace('config.', '').replace('.json', ''));
144
+ }
145
+
146
+ // ── Validation ──
147
+
148
+ async validate() {
149
+ const config = loadConfig();
150
+ const results = [];
151
+
152
+ // Check AI providers
153
+ const providers = config.ai?.providers || {};
154
+ for (const [name, prov] of Object.entries(providers)) {
155
+ if (!prov.key || prov.key === 'local') continue;
156
+
157
+ try {
158
+ const valid = await this._testProvider(name, prov.key);
159
+ results.push({ name, status: valid ? '✅' : '❌', message: valid ? 'Valid' : 'Invalid key' });
160
+ } catch (err) {
161
+ results.push({ name, status: '❌', message: err.message });
162
+ }
163
+ }
164
+
165
+ // Check Telegram
166
+ if (config.channels?.telegram?.token) {
167
+ try {
168
+ const res = await fetch(`https://api.telegram.org/bot${config.channels.telegram.token}/getMe`);
169
+ const data = await res.json();
170
+ results.push({ name: 'Telegram', status: data.ok ? '✅' : '❌', message: data.ok ? '@' + data.result.username : 'Invalid token' });
171
+ } catch (err) {
172
+ results.push({ name: 'Telegram', status: '❌', message: err.message });
173
+ }
174
+ }
175
+
176
+ // Check agents
177
+ const agentsDir = join(this.home, 'agents');
178
+ if (existsSync(agentsDir)) {
179
+ const { readdirSync } = require('fs');
180
+ const count = readdirSync(agentsDir, { withFileTypes: true }).filter(d => d.isDirectory()).length;
181
+ results.push({ name: 'Agents', status: '✅', message: count + ' configured' });
182
+ }
183
+
184
+ return results;
185
+ }
186
+
187
+ async _testProvider(name, key) {
188
+ const urls = {
189
+ anthropic: 'https://api.anthropic.com/v1/models',
190
+ openai: 'https://api.openai.com/v1/models',
191
+ google: `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`,
192
+ groq: 'https://api.groq.com/openai/v1/models',
193
+ };
194
+
195
+ const url = urls[name];
196
+ if (!url) return true; // Can't validate, assume OK
197
+
198
+ const headers = {};
199
+ if (name === 'anthropic') {
200
+ headers['x-api-key'] = key;
201
+ headers['anthropic-version'] = '2023-06-01';
202
+ } else if (name !== 'google') {
203
+ headers['Authorization'] = 'Bearer ' + key;
204
+ }
205
+
206
+ const res = await fetch(url, { headers });
207
+ return res.ok;
208
+ }
209
+
210
+ // ── Chat command parsing ──
211
+
212
+ static parseConfigCommand(message) {
213
+ const lower = message.toLowerCase().trim();
214
+
215
+ // "switch to gpt-4o" / "use gemini"
216
+ const modelMatch = lower.match(/(?:switch|change|use)\s+(?:to\s+)?(?:model\s+)?(.+)/);
217
+ if (modelMatch) {
218
+ const model = modelMatch[1].trim();
219
+ // Map common names to actual model IDs
220
+ const aliases = {
221
+ 'gpt4': 'gpt-4o', 'gpt-4': 'gpt-4o', 'gpt4o': 'gpt-4o',
222
+ 'claude': 'claude-sonnet-4-20250514', 'sonnet': 'claude-sonnet-4-20250514',
223
+ 'opus': 'claude-opus-4', 'haiku': 'claude-haiku-3-5',
224
+ 'gemini': 'gemini-2.5-flash', 'flash': 'gemini-2.5-flash',
225
+ 'llama': 'llama-3.3-70b-versatile', 'deepseek': 'deepseek-chat',
226
+ };
227
+ return { action: 'set_model', model: aliases[model] || model };
228
+ }
229
+
230
+ // "set temperature to 0.8"
231
+ const setMatch = lower.match(/set\s+(\w[\w.]*)\s+(?:to\s+)?(.+)/);
232
+ if (setMatch) {
233
+ return { action: 'set', path: setMatch[1], value: setMatch[2] };
234
+ }
235
+
236
+ return null;
237
+ }
238
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 🦑 Sub-Agents
3
+ * Spawn isolated agent tasks that run in the background
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class SubAgentManager {
9
+ constructor(engine) {
10
+ this.engine = engine;
11
+ this.running = new Map(); // id -> { task, status, result, startedAt }
12
+ this.counter = 0;
13
+ }
14
+
15
+ /**
16
+ * Spawn a sub-agent task
17
+ */
18
+ async spawn(agentId, task, options = {}) {
19
+ const id = 'sub_' + (++this.counter) + '_' + Date.now().toString(36);
20
+ const model = options.model || this.engine.config.ai?.defaultModel;
21
+ const timeout = options.timeout || 60000;
22
+
23
+ this.running.set(id, {
24
+ id,
25
+ task,
26
+ status: 'running',
27
+ result: null,
28
+ startedAt: new Date(),
29
+ agentId,
30
+ });
31
+
32
+ logger.info('sub-agents', `Spawned ${id}: ${task.slice(0, 80)}`);
33
+
34
+ // Run in background
35
+ this._execute(id, agentId, task, model, timeout).catch(err => {
36
+ const sub = this.running.get(id);
37
+ if (sub) {
38
+ sub.status = 'error';
39
+ sub.result = err.message;
40
+ }
41
+ });
42
+
43
+ return id;
44
+ }
45
+
46
+ async _execute(id, agentId, task, model, timeout) {
47
+ const sub = this.running.get(id);
48
+
49
+ try {
50
+ const messages = [
51
+ { role: 'system', content: 'You are a sub-agent. Complete this task concisely and return the result. Do not ask questions.' },
52
+ { role: 'user', content: task },
53
+ ];
54
+
55
+ const response = await Promise.race([
56
+ this.engine.aiGateway.chat(messages, { model }),
57
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout)),
58
+ ]);
59
+
60
+ sub.status = 'complete';
61
+ sub.result = response.content;
62
+ sub.completedAt = new Date();
63
+
64
+ logger.info('sub-agents', `Completed ${id}: ${response.content.slice(0, 50)}`);
65
+ } catch (err) {
66
+ sub.status = 'error';
67
+ sub.result = err.message;
68
+ logger.error('sub-agents', `Failed ${id}: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get sub-agent status
74
+ */
75
+ get(id) {
76
+ return this.running.get(id);
77
+ }
78
+
79
+ /**
80
+ * List all sub-agents
81
+ */
82
+ list(agentId) {
83
+ return Array.from(this.running.values())
84
+ .filter(s => !agentId || s.agentId === agentId)
85
+ .map(s => ({
86
+ id: s.id,
87
+ task: s.task.slice(0, 80),
88
+ status: s.status,
89
+ result: s.result?.slice(0, 200),
90
+ duration: s.completedAt ? (s.completedAt - s.startedAt) / 1000 + 's' : 'running',
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Kill a running sub-agent
96
+ */
97
+ kill(id) {
98
+ const sub = this.running.get(id);
99
+ if (sub) {
100
+ sub.status = 'killed';
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ }
@@ -16,7 +16,11 @@ export async function commandsMiddleware(ctx, next) {
16
16
  '/memories — what I remember about you',
17
17
  '/tasks — your todo list',
18
18
  '/usage — spending report',
19
- '/helpthis message',
19
+ '/configmanage settings',
20
+ '/exec <cmd> — run a shell command',
21
+ '/files — list sandbox files',
22
+ '/subagents — list background tasks',
23
+ '/help — this message',
20
24
  '', 'Just chat normally! 🦑',
21
25
  ].join('\n'));
22
26
  return;
@@ -117,5 +121,116 @@ export async function commandsMiddleware(ctx, next) {
117
121
  return;
118
122
  }
119
123
 
124
+ if (cmd === '/config') {
125
+ const args = msg.slice(8).trim();
126
+ if (!args || args === 'help') {
127
+ await ctx.reply([
128
+ '⚙️ *Config Commands*', '',
129
+ '/config get <path> — show value',
130
+ '/config set <path> <value> — set value',
131
+ '/config test — validate all connections',
132
+ '/config profiles — list saved profiles',
133
+ '/config save <name> — save current config',
134
+ '/config load <name> — switch profile',
135
+ '', 'Or just say: "switch to gemini" 🦑',
136
+ ].join('\n'));
137
+ return;
138
+ }
139
+
140
+ const cm = ctx.engine.configManager;
141
+ if (!cm) { await ctx.reply('❌ Config manager not available'); return; }
142
+
143
+ if (args.startsWith('get ')) {
144
+ const val = cm.get(args.slice(4).trim());
145
+ await ctx.reply('⚙️ ' + args.slice(4).trim() + ' = ' + JSON.stringify(val));
146
+ return;
147
+ }
148
+
149
+ if (args.startsWith('set ')) {
150
+ const parts = args.slice(4).trim().split(/\s+/);
151
+ const path = parts[0];
152
+ const value = parts.slice(1).join(' ');
153
+ cm.set(path, value);
154
+ await ctx.reply('✅ Set *' + path + '* = ' + value);
155
+ return;
156
+ }
157
+
158
+ if (args === 'test' || args === 'validate') {
159
+ await ctx.reply('🔍 Validating connections...');
160
+ const results = await cm.validate();
161
+ const lines = results.map(r => r.status + ' *' + r.name + '* — ' + r.message);
162
+ await ctx.reply('⚙️ *Config Validation*\n\n' + lines.join('\n'));
163
+ return;
164
+ }
165
+
166
+ if (args === 'profiles') {
167
+ const profiles = cm.listProfiles();
168
+ await ctx.reply(profiles.length ? '📋 Profiles: ' + profiles.join(', ') : '📋 No saved profiles');
169
+ return;
170
+ }
171
+
172
+ if (args.startsWith('save ')) {
173
+ cm.saveProfile(args.slice(5).trim());
174
+ await ctx.reply('✅ Profile saved: ' + args.slice(5).trim());
175
+ return;
176
+ }
177
+
178
+ if (args.startsWith('load ')) {
179
+ try {
180
+ cm.loadProfile(args.slice(5).trim());
181
+ await ctx.reply('✅ Profile loaded: ' + args.slice(5).trim() + '\n⚠️ Restart recommended');
182
+ } catch (err) {
183
+ await ctx.reply('❌ ' + err.message);
184
+ }
185
+ return;
186
+ }
187
+ }
188
+
189
+ if (cmd === '/exec') {
190
+ const command = msg.slice(6).trim();
191
+ if (!command) { await ctx.reply('Usage: /exec <command>'); return; }
192
+ try {
193
+ const { ShellTool } = await import('../tools/shell.js');
194
+ const sh = new ShellTool();
195
+ const result = sh.exec(command);
196
+ const output = result.error ? '❌ ' + result.error : '```\n' + (result.output || '(no output)') + '\n```';
197
+ await ctx.reply(output);
198
+ } catch (err) {
199
+ await ctx.reply('❌ ' + err.message);
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (cmd === '/files') {
205
+ try {
206
+ const { ShellTool } = await import('../tools/shell.js');
207
+ const sh = new ShellTool();
208
+ const result = sh.listDir('.');
209
+ const list = result.files?.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n') || 'Empty';
210
+ await ctx.reply('📂 *Sandbox Files*\n\n' + list);
211
+ } catch (err) {
212
+ await ctx.reply('❌ ' + err.message);
213
+ }
214
+ return;
215
+ }
216
+
217
+ if (cmd === '/subagents') {
218
+ if (ctx.engine.subAgents) {
219
+ const list = ctx.engine.subAgents.list(ctx.agentId);
220
+ if (list.length === 0) {
221
+ await ctx.reply('🤖 No sub-agents running');
222
+ } else {
223
+ const lines = list.map(s =>
224
+ (s.status === 'complete' ? '✅' : s.status === 'error' ? '❌' : '⏳') +
225
+ ' ' + s.id + '\n ' + s.task + '\n ' + s.status + (s.duration ? ' (' + s.duration + ')' : '')
226
+ );
227
+ await ctx.reply('🤖 *Sub-Agents*\n\n' + lines.join('\n\n'));
228
+ }
229
+ } else {
230
+ await ctx.reply('🤖 Sub-agents not available');
231
+ }
232
+ return;
233
+ }
234
+
120
235
  await next();
121
236
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Handle config changes via natural language
3
+ * "switch to gemini", "use gpt-4o", "set temperature to 0.8"
4
+ */
5
+ import { logger } from '../core/logger.js';
6
+
7
+ export async function configChatMiddleware(ctx, next) {
8
+ if (!ctx.engine.configManager) { await next(); return; }
9
+
10
+ const { ConfigManager } = await import('../features/config-manager.js');
11
+ const parsed = ConfigManager.parseConfigCommand(ctx.message);
12
+
13
+ if (!parsed) { await next(); return; }
14
+
15
+ try {
16
+ if (parsed.action === 'set_model') {
17
+ ctx.engine.configManager.set('ai.defaultModel', parsed.model);
18
+ // Update agent's model too
19
+ if (ctx.agent) ctx.agent.model = parsed.model;
20
+ await ctx.reply('✅ Model switched to *' + parsed.model + '*\n\nI will use this model from now on 🦑');
21
+ return;
22
+ }
23
+
24
+ if (parsed.action === 'set') {
25
+ ctx.engine.configManager.set(parsed.path, parsed.value);
26
+ await ctx.reply('✅ Set *' + parsed.path + '* = ' + parsed.value);
27
+ return;
28
+ }
29
+ } catch (err) {
30
+ await ctx.reply('❌ Config error: ' + err.message);
31
+ return;
32
+ }
33
+
34
+ await next();
35
+ }
@@ -59,6 +59,25 @@ export class ToolRouter {
59
59
  'Send an email.');
60
60
  }
61
61
 
62
+ tools.push('', '### Run Command',
63
+ '---TOOL:exec:ls -la---',
64
+ 'Execute a shell command. Output is returned. Sandboxed for safety.',
65
+ '', '### Read File',
66
+ '---TOOL:readfile:filename.txt---',
67
+ 'Read contents of a file in the sandbox.',
68
+ '', '### Write File',
69
+ '---TOOL:writefile:filename.txt|file contents here---',
70
+ 'Create or overwrite a file. Use pipe (|) to separate filename from content.',
71
+ '', '### List Files',
72
+ '---TOOL:ls:path---',
73
+ 'List files in a directory.',
74
+ '', '### Spawn Sub-Agent',
75
+ '---TOOL:spawn:Research the latest AI news and summarize top 5 stories---',
76
+ 'Spawn a background task. Runs independently and returns result when done.',
77
+ '', '### Run Python',
78
+ '---TOOL:python:print(2+2)---',
79
+ 'Execute Python code and return output.');
80
+
62
81
  tools.push('', '### Screenshot',
63
82
  '---TOOL:screenshot:https://example.com---',
64
83
  'Take a screenshot of a website. Returns the screenshot as an image.',
@@ -149,6 +168,60 @@ export class ToolRouter {
149
168
  }
150
169
  break;
151
170
  }
171
+ case 'exec':
172
+ case 'shell':
173
+ case 'run': {
174
+ const { ShellTool } = await import('./shell.js');
175
+ const sh = new ShellTool();
176
+ const result = sh.exec(toolArg);
177
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
178
+ break;
179
+ }
180
+ case 'readfile': {
181
+ const { ShellTool } = await import('./shell.js');
182
+ const sh = new ShellTool();
183
+ const result = sh.readFile(toolArg);
184
+ toolResult = result.error || result.content;
185
+ break;
186
+ }
187
+ case 'writefile': {
188
+ const pipeIdx = toolArg.indexOf('|');
189
+ if (pipeIdx === -1) { toolResult = 'Format: filename|content'; break; }
190
+ const fname = toolArg.slice(0, pipeIdx).trim();
191
+ const fcontent = toolArg.slice(pipeIdx + 1);
192
+ const { ShellTool } = await import('./shell.js');
193
+ const sh = new ShellTool();
194
+ const result = sh.writeFile(fname, fcontent);
195
+ toolResult = result.error || 'File written: ' + fname;
196
+ break;
197
+ }
198
+ case 'ls':
199
+ case 'listdir': {
200
+ const { ShellTool } = await import('./shell.js');
201
+ const sh = new ShellTool();
202
+ const result = sh.listDir(toolArg || '.');
203
+ toolResult = result.error || result.files.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n');
204
+ break;
205
+ }
206
+ case 'python':
207
+ case 'py': {
208
+ const { ShellTool } = await import('./shell.js');
209
+ const sh = new ShellTool();
210
+ // Write Python script then execute
211
+ sh.writeFile('_run.py', toolArg);
212
+ const result = sh.exec('python3 _run.py');
213
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
214
+ break;
215
+ }
216
+ case 'spawn': {
217
+ if (this._subAgents) {
218
+ const id = await this._subAgents.spawn(agentId, toolArg);
219
+ toolResult = 'Sub-agent spawned: ' + id + '. It will complete in the background.';
220
+ } else {
221
+ toolResult = 'Sub-agents not available';
222
+ }
223
+ break;
224
+ }
152
225
  case 'screenshot': {
153
226
  try {
154
227
  const { BrowserControl } = await import('./browser-control.js');
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 🦑 Shell & File System
3
+ * Sandboxed command execution and file operations
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'fs';
8
+ import { join, resolve } from 'path';
9
+ import { logger } from '../core/logger.js';
10
+
11
+ // Sandboxed base directory
12
+ const SANDBOX_DIR = process.env.SQUIDCLAW_SANDBOX || '/tmp/squidclaw-sandbox';
13
+
14
+ // Blocked commands for safety
15
+ const BLOCKED = [
16
+ 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb',
17
+ 'chmod 777 /', 'chown', 'passwd', 'userdel', 'useradd',
18
+ 'shutdown', 'reboot', 'halt', 'poweroff',
19
+ 'iptables', 'ufw', 'firewall',
20
+ ];
21
+
22
+ export class ShellTool {
23
+ constructor(home) {
24
+ this.sandbox = SANDBOX_DIR;
25
+ mkdirSync(this.sandbox, { recursive: true });
26
+ }
27
+
28
+ /**
29
+ * Execute a shell command (sandboxed)
30
+ */
31
+ exec(command, options = {}) {
32
+ // Safety check
33
+ const lower = command.toLowerCase();
34
+ for (const blocked of BLOCKED) {
35
+ if (lower.includes(blocked)) {
36
+ return { error: 'Blocked for safety: ' + blocked };
37
+ }
38
+ }
39
+
40
+ const timeout = options.timeout || 10000;
41
+ const cwd = options.cwd || this.sandbox;
42
+
43
+ try {
44
+ const output = execSync(command, {
45
+ cwd,
46
+ timeout,
47
+ maxBuffer: 1024 * 1024,
48
+ encoding: 'utf8',
49
+ env: { ...process.env, HOME: this.sandbox },
50
+ });
51
+ logger.info('shell', `Executed: ${command.slice(0, 80)}`);
52
+ return { output: output.trim().slice(0, 3000), exitCode: 0 };
53
+ } catch (err) {
54
+ return {
55
+ output: (err.stdout || '').trim().slice(0, 1000),
56
+ error: (err.stderr || err.message).trim().slice(0, 1000),
57
+ exitCode: err.status || 1,
58
+ };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read a file (sandboxed)
64
+ */
65
+ readFile(path) {
66
+ const safePath = this._safePath(path);
67
+ if (!existsSync(safePath)) return { error: 'File not found: ' + path };
68
+
69
+ const stat = statSync(safePath);
70
+ if (stat.size > 100000) return { error: 'File too large: ' + (stat.size / 1024).toFixed(0) + ' KB' };
71
+
72
+ return { content: readFileSync(safePath, 'utf8').slice(0, 5000) };
73
+ }
74
+
75
+ /**
76
+ * Write a file (sandboxed)
77
+ */
78
+ writeFile(path, content) {
79
+ const safePath = this._safePath(path);
80
+ const dir = resolve(safePath, '..');
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(safePath, content);
83
+ logger.info('shell', `Wrote file: ${path}`);
84
+ return { success: true, path: safePath };
85
+ }
86
+
87
+ /**
88
+ * List directory (sandboxed)
89
+ */
90
+ listDir(path) {
91
+ const safePath = this._safePath(path || '.');
92
+ if (!existsSync(safePath)) return { error: 'Directory not found' };
93
+
94
+ const entries = readdirSync(safePath, { withFileTypes: true });
95
+ return {
96
+ files: entries.map(e => ({
97
+ name: e.name,
98
+ type: e.isDirectory() ? 'dir' : 'file',
99
+ })),
100
+ };
101
+ }
102
+
103
+ _safePath(p) {
104
+ // Prevent path traversal
105
+ const resolved = resolve(this.sandbox, p);
106
+ if (!resolved.startsWith(this.sandbox)) {
107
+ throw new Error('Path outside sandbox');
108
+ }
109
+ return resolved;
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {