skimpyclaw 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
  2. package/dist/__tests__/bash-path-validation.test.js +164 -0
  3. package/dist/__tests__/doctor.runner.test.js +5 -1
  4. package/dist/__tests__/heartbeat.test.js +30 -3
  5. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  6. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  7. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  8. package/dist/__tests__/sandbox-manager.test.js +119 -0
  9. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  10. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  11. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  12. package/dist/__tests__/sandbox-runtime.test.js +140 -0
  13. package/dist/__tests__/setup.test.js +32 -3
  14. package/dist/__tests__/skills.test.js +2 -11
  15. package/dist/__tests__/tools.test.js +6 -1
  16. package/dist/__tests__/voice.test.js +12 -0
  17. package/dist/agent.js +2 -0
  18. package/dist/api.js +5 -1
  19. package/dist/channels/telegram/utils.js +2 -2
  20. package/dist/cli.js +212 -0
  21. package/dist/code-agents/executor.js +17 -4
  22. package/dist/code-agents/types.d.ts +5 -0
  23. package/dist/cron.js +16 -2
  24. package/dist/discord.js +2 -2
  25. package/dist/doctor/checks.d.ts +1 -0
  26. package/dist/doctor/checks.js +47 -0
  27. package/dist/doctor/runner.js +2 -1
  28. package/dist/exec-approval.d.ts +4 -0
  29. package/dist/exec-approval.js +4 -4
  30. package/dist/gateway.js +33 -2
  31. package/dist/heartbeat.js +7 -3
  32. package/dist/providers/openai.js +1 -1
  33. package/dist/sandbox/bridge.d.ts +5 -0
  34. package/dist/sandbox/bridge.js +63 -0
  35. package/dist/sandbox/index.d.ts +5 -0
  36. package/dist/sandbox/index.js +4 -0
  37. package/dist/sandbox/manager.d.ts +7 -0
  38. package/dist/sandbox/manager.js +89 -0
  39. package/dist/sandbox/mount-security.d.ts +12 -0
  40. package/dist/sandbox/mount-security.js +118 -0
  41. package/dist/sandbox/runtime.d.ts +33 -0
  42. package/dist/sandbox/runtime.js +167 -0
  43. package/dist/service.js +17 -0
  44. package/dist/setup.d.ts +11 -0
  45. package/dist/setup.js +336 -23
  46. package/dist/skills.d.ts +1 -2
  47. package/dist/skills.js +1 -13
  48. package/dist/tools/bash-path-validation.d.ts +22 -0
  49. package/dist/tools/bash-path-validation.js +130 -0
  50. package/dist/tools/bash-tool.js +23 -1
  51. package/dist/tools/definitions.d.ts +0 -7
  52. package/dist/tools/definitions.js +0 -5
  53. package/dist/tools/execute-context.d.ts +4 -0
  54. package/dist/tools/path-utils.js +16 -2
  55. package/dist/tools.js +84 -2
  56. package/dist/types.d.ts +10 -0
  57. package/dist/voice.js +5 -1
  58. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ // Bash argument path validation — extracts file paths from command tokens
2
+ // and validates them against the allowedPaths list.
3
+ import { resolve } from 'path';
4
+ import { homedir } from 'os';
5
+ import { getCommandSegments, getSegmentCommandIndex, getExecutableName, } from '../exec-approval.js';
6
+ import { isPathAllowed } from './path-utils.js';
7
+ // --- Path-like token detection ---
8
+ /**
9
+ * Returns true if a shell token looks like a file/directory path.
10
+ * Matches: /foo, ./foo, ../foo, ~/foo
11
+ * Does NOT match: flags (-f, --file), bare words (foo), URLs (https://...)
12
+ */
13
+ export function isPathLikeToken(token) {
14
+ const t = token.trim();
15
+ if (!t || t === '-' || t === '--')
16
+ return false;
17
+ // Absolute path
18
+ if (t.startsWith('/'))
19
+ return true;
20
+ // Relative paths
21
+ if (t.startsWith('./') || t.startsWith('../') || t === '.' || t === '..')
22
+ return true;
23
+ // Home-relative path
24
+ if (t.startsWith('~/') || t === '~')
25
+ return true;
26
+ return false;
27
+ }
28
+ // --- Interpreter script target extraction ---
29
+ const INTERPRETERS = new Set([
30
+ 'python', 'python3', 'python3.11', 'python3.12', 'python3.13',
31
+ 'node', 'deno', 'bun',
32
+ 'perl', 'ruby', 'php', 'lua',
33
+ 'bash', 'sh', 'zsh', 'fish',
34
+ ]);
35
+ /** Flags that consume the next argument (so it's not a script path). */
36
+ const INTERPRETER_FLAGS_WITH_VALUE = new Set([
37
+ '-c', '-e', '-m', '-W', '-X', '-O',
38
+ '--eval', '--execute', '--require',
39
+ ]);
40
+ /** Flags that mean "read from stdin" or "inline code follows" — stop looking for script path. */
41
+ const INTERPRETER_INLINE_FLAGS = new Set([
42
+ '-c', '-e', '--eval', '--execute', '-r', '-Command',
43
+ ]);
44
+ /**
45
+ * For an interpreter command segment, extract the script file path if present.
46
+ * Returns null if the command uses inline execution (-c, -e) or reads from stdin.
47
+ */
48
+ export function extractScriptTarget(segment) {
49
+ const cmdIdx = getSegmentCommandIndex(segment);
50
+ if (cmdIdx >= segment.length)
51
+ return null;
52
+ const cmd = getExecutableName(segment[cmdIdx]);
53
+ if (!INTERPRETERS.has(cmd))
54
+ return null;
55
+ const args = segment.slice(cmdIdx + 1);
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i];
58
+ // Inline execution — no script file to validate
59
+ if (INTERPRETER_INLINE_FLAGS.has(arg))
60
+ return null;
61
+ // Flag that consumes next arg — skip both
62
+ if (INTERPRETER_FLAGS_WITH_VALUE.has(arg)) {
63
+ i++;
64
+ continue;
65
+ }
66
+ // Skip other flags (single or double dash)
67
+ if (arg.startsWith('-'))
68
+ continue;
69
+ // First non-flag argument is the script path
70
+ return arg;
71
+ }
72
+ return null;
73
+ }
74
+ // --- Path extraction from full command ---
75
+ /**
76
+ * Extract all file-path-like tokens from a shell command.
77
+ * Handles piped/chained commands. Resolves ~ and relative paths using cwd.
78
+ * Also extracts script targets from interpreter commands.
79
+ */
80
+ export function extractPathsFromCommand(command, cwd) {
81
+ const segments = getCommandSegments(command);
82
+ const paths = [];
83
+ const baseCwd = cwd || process.cwd();
84
+ for (const segment of segments) {
85
+ const cmdIdx = getSegmentCommandIndex(segment);
86
+ // Check for interpreter script target
87
+ const scriptTarget = extractScriptTarget(segment);
88
+ if (scriptTarget) {
89
+ paths.push(resolvePath(scriptTarget, baseCwd));
90
+ }
91
+ // Check all non-command tokens for path-like values
92
+ for (let i = cmdIdx + 1; i < segment.length; i++) {
93
+ const token = segment[i];
94
+ if (isPathLikeToken(token)) {
95
+ paths.push(resolvePath(token, baseCwd));
96
+ }
97
+ }
98
+ }
99
+ // Deduplicate
100
+ return [...new Set(paths)];
101
+ }
102
+ /**
103
+ * Resolve a token to an absolute path, expanding ~ and relative paths.
104
+ */
105
+ function resolvePath(token, cwd) {
106
+ if (token.startsWith('~/') || token === '~') {
107
+ return resolve(homedir(), token.slice(2) || '.');
108
+ }
109
+ return resolve(cwd, token);
110
+ }
111
+ // --- Validation ---
112
+ /**
113
+ * Validate that all file paths in a bash command are within allowedPaths.
114
+ * Returns null if all paths are valid, or an error message string if any are outside.
115
+ */
116
+ export function validateBashPaths(command, cwd, allowedPaths) {
117
+ // If no allowedPaths configured, skip validation (permissive mode)
118
+ if (!allowedPaths || allowedPaths.length === 0)
119
+ return null;
120
+ const paths = extractPathsFromCommand(command, cwd);
121
+ const blocked = [];
122
+ for (const p of paths) {
123
+ if (!isPathAllowed(p, allowedPaths)) {
124
+ blocked.push(p);
125
+ }
126
+ }
127
+ if (blocked.length === 0)
128
+ return null;
129
+ return `Error: Command references paths outside allowed directories: ${blocked.join(', ')}`;
130
+ }
@@ -2,6 +2,23 @@ import { exec } from 'child_process';
2
2
  import { isBashCommandSafe } from '../security.js';
3
3
  import { classifyCommandRisk, requiresApproval, createApprovalRequest, waitForApproval, } from '../exec-approval.js';
4
4
  import { isPathAllowed } from './path-utils.js';
5
+ import { validateBashPaths } from './bash-path-validation.js';
6
+ /** Env var name patterns that should never be exposed to model-executed commands. */
7
+ const SENSITIVE_ENV_PATTERNS = [
8
+ /api.?key/i, /token/i, /secret/i, /password/i, /credential/i,
9
+ /^ANTHROPIC_/i, /^OPENAI_/i, /^CLAUDE/i, /^CODEX_/i, /^MINIMAX_/i,
10
+ /^KIMI_/i, /^TOGETHER_/i, /^GROQ_/i, /^OPENROUTER_/i,
11
+ ];
12
+ /** Create a sanitized copy of process.env with secrets stripped. */
13
+ function sanitizeEnv() {
14
+ const env = { ...process.env };
15
+ for (const key of Object.keys(env)) {
16
+ if (SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) {
17
+ delete env[key];
18
+ }
19
+ }
20
+ return env;
21
+ }
5
22
  export async function executeBash(command, cwd, config, context) {
6
23
  // Hard block: existing safety filter (always enforced)
7
24
  if (!isBashCommandSafe(command)) {
@@ -10,6 +27,11 @@ export async function executeBash(command, cwd, config, context) {
10
27
  if (cwd && !isPathAllowed(cwd, config.allowedPaths)) {
11
28
  return Promise.resolve('Error: Working directory not in allowed paths.');
12
29
  }
30
+ // Validate file paths referenced in command arguments
31
+ const pathError = validateBashPaths(command, cwd, config.allowedPaths);
32
+ if (pathError) {
33
+ return Promise.resolve(pathError);
34
+ }
13
35
  // Exec approval gate: classify risk and check if approval is needed
14
36
  const approvalConfig = config.execApproval;
15
37
  if (approvalConfig?.enabled !== false) {
@@ -44,7 +66,7 @@ export async function executeBash(command, cwd, config, context) {
44
66
  exec(command, {
45
67
  cwd: cwd || undefined,
46
68
  timeout,
47
- env: { ...process.env },
69
+ env: sanitizeEnv(),
48
70
  maxBuffer: 5 * 1024 * 1024,
49
71
  }, (error, stdout, stderr) => {
50
72
  if (error) {
@@ -328,13 +328,6 @@ export declare const SPAWN_SUBAGENT_TOOL: {
328
328
  type: string;
329
329
  description: string;
330
330
  };
331
- allowedPaths: {
332
- type: string;
333
- items: {
334
- type: string;
335
- };
336
- description: string;
337
- };
338
331
  };
339
332
  required: string[];
340
333
  };
@@ -126,11 +126,6 @@ export const SPAWN_SUBAGENT_TOOL = {
126
126
  },
127
127
  model: { type: 'string', description: 'Optional model override (e.g. claude-opus, claude-think)' },
128
128
  label: { type: 'string', description: 'Short label for status display (e.g. "write tests", "check logs")' },
129
- allowedPaths: {
130
- type: 'array',
131
- items: { type: 'string' },
132
- description: 'Additional file paths the subagent can access beyond defaults',
133
- },
134
129
  },
135
130
  required: ['task', 'type'],
136
131
  },
@@ -23,4 +23,8 @@ export interface ExecuteToolContext {
23
23
  trigger?: string;
24
24
  /** Agent ID for usage tracking */
25
25
  agentId?: string;
26
+ /** Sandbox configuration for containerized tool execution */
27
+ sandboxConfig?: import('../types.js').SandboxConfig;
28
+ /** Session ID for sandbox container mapping */
29
+ sessionId?: string;
26
30
  }
@@ -1,8 +1,22 @@
1
1
  import { resolve, sep } from 'path';
2
+ import { realpathSync } from 'fs';
3
+ /**
4
+ * Resolve a path to its real location, following symlinks.
5
+ * Falls back to path.resolve() if the path doesn't exist yet (e.g. for writes).
6
+ */
7
+ function safeRealpath(filePath) {
8
+ try {
9
+ return realpathSync(filePath);
10
+ }
11
+ catch {
12
+ // Path doesn't exist yet — resolve logically (normalizes .. but can't follow symlinks)
13
+ return resolve(filePath);
14
+ }
15
+ }
2
16
  export function isPathAllowed(filePath, allowedPaths) {
3
- const resolved = resolve(filePath);
17
+ const resolved = safeRealpath(filePath);
4
18
  return allowedPaths.some((allowed) => {
5
- const allowedRoot = resolve(allowed);
19
+ const allowedRoot = safeRealpath(allowed);
6
20
  return resolved === allowedRoot || resolved.startsWith(`${allowedRoot}${sep}`);
7
21
  });
8
22
  }
package/dist/tools.js CHANGED
@@ -6,6 +6,8 @@ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER
6
6
  import { executeReadFile, executeWriteFileLocked, executeListDirectory } from './tools/file-tools.js';
7
7
  import { executeBash } from './tools/bash-tool.js';
8
8
  import { executeBrowser, cleanupBrowser } from './tools/browser-tool.js';
9
+ import { ensureContainer, SANDBOX_DEFAULTS, translatePath, validateMountPaths } from './sandbox/index.js';
10
+ import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './sandbox/index.js';
9
11
  // Re-export from code-agents module for backward compatibility
10
12
  export {
11
13
  // Registry functions
@@ -240,7 +242,23 @@ async function executeWebSearch(query) {
240
242
  export async function executeTool(name, input, config, context) {
241
243
  try {
242
244
  // Route MCP tools BEFORE normalization to preserve server/tool name casing
245
+ // NOTE: MCP servers run as external processes with full host access.
246
+ // We validate path-like arguments as a best-effort check, but MCP servers
247
+ // are a trusted boundary — only configure servers you trust.
243
248
  if (name.startsWith('mcp__')) {
249
+ if (config.allowedPaths?.length) {
250
+ const { isPathAllowed } = await import('./tools/path-utils.js');
251
+ for (const [key, value] of Object.entries(input)) {
252
+ if (typeof value === 'string' && (value.startsWith('/') || value.startsWith('~/') || value.startsWith('./'))) {
253
+ const { resolve } = await import('path');
254
+ const { homedir } = await import('os');
255
+ const resolved = value.startsWith('~/') ? resolve(homedir(), value.slice(2)) : resolve(value);
256
+ if (!isPathAllowed(resolved, config.allowedPaths)) {
257
+ return `Error: MCP tool argument "${key}" references path outside allowed directories: ${value}`;
258
+ }
259
+ }
260
+ }
261
+ }
244
262
  return await executeMcpToolGeneric(name, input);
245
263
  }
246
264
  // Route spawn_subagent
@@ -264,6 +282,69 @@ export async function executeTool(name, input, config, context) {
264
282
  }
265
283
  // Map Claude Code names to internal names for built-in tools
266
284
  const normalized = fromClaudeCodeName(name).toLowerCase().replace(/-/g, '_');
285
+ // --- Sandbox routing ---
286
+ const sandboxCfg = context?.sandboxConfig;
287
+ if (sandboxCfg?.enabled) {
288
+ const SANDBOXED_TOOLS = new Set(['bash', 'read_file', 'write_file', 'list_directory', 'glob']);
289
+ // macOS-only commands that must run on the host (not available in Linux containers)
290
+ const MACOS_HOST_COMMANDS = new Set([
291
+ 'osascript', 'open', 'say', 'pbcopy', 'pbpaste', 'defaults',
292
+ 'icalBuddy', 'shortcuts', 'caffeinate', 'networksetup', 'launchctl',
293
+ 'security', 'xattr', 'ditto', 'hdiutil', 'diskutil', 'sw_vers',
294
+ ]);
295
+ const needsHost = normalized === 'bash' && input.command &&
296
+ MACOS_HOST_COMMANDS.has(input.command.trim().split(/[\s;|&]/)[0]);
297
+ if (SANDBOXED_TOOLS.has(normalized) && !needsHost) {
298
+ const sessionId = context?.sessionId || context?.chatId?.toString() || 'default';
299
+ const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
300
+ const containerName = await ensureContainer(sessionId, merged, config.allowedPaths);
301
+ const mounts = validateMountPaths(config.allowedPaths);
302
+ const tp = (p) => translatePath(p, mounts);
303
+ // Translate host paths in bash commands so they resolve inside the container
304
+ const translateBashPaths = (cmd) => {
305
+ let translated = cmd;
306
+ // Sort mounts by host path length descending to match most specific first
307
+ const sorted = [...mounts].sort((a, b) => b.host.length - a.host.length);
308
+ for (const mount of sorted) {
309
+ translated = translated.replaceAll(mount.host, mount.container);
310
+ }
311
+ // Also translate ~ and $HOME references to /workspace/config
312
+ const home = homedir();
313
+ const homeMounts = sorted.filter(m => m.host.startsWith(home));
314
+ for (const mount of homeMounts) {
315
+ const tildeForm = '~' + mount.host.slice(home.length);
316
+ translated = translated.replaceAll(tildeForm, mount.container);
317
+ const envForm = '$HOME' + mount.host.slice(home.length);
318
+ translated = translated.replaceAll(envForm, mount.container);
319
+ }
320
+ return translated;
321
+ };
322
+ // Reverse-translate container paths back to host paths in file content.
323
+ // Prevents the agent from writing /workspace/... paths into config files.
324
+ const reverseTranslatePaths = (content) => {
325
+ let reversed = content;
326
+ const sorted = [...mounts].sort((a, b) => b.container.length - a.container.length);
327
+ for (const mount of sorted) {
328
+ reversed = reversed.replaceAll(mount.container, mount.host);
329
+ }
330
+ return reversed;
331
+ };
332
+ switch (normalized) {
333
+ case 'bash':
334
+ return await sandboxBash(containerName, translateBashPaths(input.command), input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
335
+ case 'read_file':
336
+ return await sandboxReadFile(containerName, tp(input.file_path || input.path));
337
+ case 'write_file':
338
+ return await sandboxWriteFile(containerName, tp(input.file_path || input.path), reverseTranslatePaths(input.content));
339
+ case 'list_directory':
340
+ return await sandboxListDir(containerName, tp(input.path));
341
+ case 'glob':
342
+ return await sandboxGlob(containerName, tp(input.base || input.path || '/workspace'), input.pattern || '*');
343
+ default:
344
+ break; // fall through
345
+ }
346
+ }
347
+ }
267
348
  switch (normalized) {
268
349
  case '$web_search':
269
350
  case 'web_search':
@@ -300,7 +381,8 @@ async function executeSpawnSubagent(input, context) {
300
381
  const type = input.type;
301
382
  const model = input.model;
302
383
  const label = input.label;
303
- const allowedPaths = input.allowedPaths;
384
+ // NOTE: allowedPaths deliberately NOT accepted from model input (security — prevents path escalation).
385
+ // Subagents inherit their preset's allowedPaths from config.
304
386
  if (!task || !type) {
305
387
  return 'Error: task and type are required';
306
388
  }
@@ -308,7 +390,7 @@ async function executeSpawnSubagent(input, context) {
308
390
  return `Error: Invalid type "${type}". Must be coding or research.`;
309
391
  }
310
392
  try {
311
- const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label, allowedPaths });
393
+ const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label });
312
394
  const labelStr = label ? ` "${label}"` : '';
313
395
  return JSON.stringify({
314
396
  status: 'accepted',
package/dist/types.d.ts CHANGED
@@ -79,6 +79,7 @@ export interface Config {
79
79
  /** Named project paths. Keys are short names (e.g. "skimpyclaw"), values are absolute paths.
80
80
  * Project paths are automatically added to tool allowedPaths and available to code_with_agent by name. */
81
81
  projects?: Record<string, string>;
82
+ sandbox?: SandboxConfig;
82
83
  }
83
84
  export interface AgentConfig {
84
85
  identity: {
@@ -134,6 +135,15 @@ export interface CronPayload {
134
135
  tools?: ToolConfig;
135
136
  sendAsVoice?: boolean;
136
137
  }
138
+ export interface SandboxConfig {
139
+ enabled: boolean;
140
+ runtime?: 'container' | 'docker';
141
+ image?: string;
142
+ cpus?: number;
143
+ memory?: string;
144
+ network?: string;
145
+ idleTimeoutMs?: number;
146
+ }
137
147
  export interface ToolConfig {
138
148
  enabled: boolean;
139
149
  allowedPaths: string[];
package/dist/voice.js CHANGED
@@ -191,7 +191,8 @@ function getSTTProvider(config) {
191
191
  if (!provider)
192
192
  return false;
193
193
  // macOS voice provider is TTS-only and must never be used for transcription.
194
- if (name === 'macos')
194
+ const normalizedName = name.trim().toLowerCase();
195
+ if (normalizedName === 'macos')
195
196
  return false;
196
197
  return Boolean(provider.stt || provider.apiKey);
197
198
  };
@@ -275,6 +276,9 @@ export async function transcribeAudio(audioPath, config) {
275
276
  // No local whisper — try API provider directly
276
277
  const sttProvider = getSTTProvider(config);
277
278
  if (!sttProvider) {
279
+ if (config.providers?.macos) {
280
+ throw new Error('No voice transcription provider configured. "macos" is TTS-only. Install local whisper or configure an API STT provider (e.g. openai.stt).');
281
+ }
278
282
  throw new Error('No voice transcription available. Install whisper (pip install openai-whisper) or configure an API provider.');
279
283
  }
280
284
  return transcribeWithAPI(audioPath, sttProvider.name, sttProvider.provider);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",