skimpyclaw 0.1.9 → 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 (56) 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 +5 -5
  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 +28 -2
  14. package/dist/__tests__/skills.test.js +2 -11
  15. package/dist/__tests__/tools.test.js +6 -1
  16. package/dist/agent.js +2 -0
  17. package/dist/api.js +5 -1
  18. package/dist/channels/telegram/utils.js +2 -2
  19. package/dist/cli.js +212 -0
  20. package/dist/code-agents/executor.js +17 -4
  21. package/dist/code-agents/types.d.ts +5 -0
  22. package/dist/cron.js +16 -2
  23. package/dist/discord.js +2 -2
  24. package/dist/doctor/checks.d.ts +1 -0
  25. package/dist/doctor/checks.js +47 -0
  26. package/dist/doctor/runner.js +2 -1
  27. package/dist/exec-approval.d.ts +4 -0
  28. package/dist/exec-approval.js +4 -4
  29. package/dist/gateway.js +33 -2
  30. package/dist/heartbeat.js +3 -0
  31. package/dist/providers/openai.js +1 -1
  32. package/dist/sandbox/bridge.d.ts +5 -0
  33. package/dist/sandbox/bridge.js +63 -0
  34. package/dist/sandbox/index.d.ts +5 -0
  35. package/dist/sandbox/index.js +4 -0
  36. package/dist/sandbox/manager.d.ts +7 -0
  37. package/dist/sandbox/manager.js +89 -0
  38. package/dist/sandbox/mount-security.d.ts +12 -0
  39. package/dist/sandbox/mount-security.js +118 -0
  40. package/dist/sandbox/runtime.d.ts +33 -0
  41. package/dist/sandbox/runtime.js +167 -0
  42. package/dist/service.js +17 -0
  43. package/dist/setup.d.ts +11 -0
  44. package/dist/setup.js +335 -13
  45. package/dist/skills.d.ts +1 -2
  46. package/dist/skills.js +1 -13
  47. package/dist/tools/bash-path-validation.d.ts +22 -0
  48. package/dist/tools/bash-path-validation.js +130 -0
  49. package/dist/tools/bash-tool.js +23 -1
  50. package/dist/tools/definitions.d.ts +0 -7
  51. package/dist/tools/definitions.js +0 -5
  52. package/dist/tools/execute-context.d.ts +4 -0
  53. package/dist/tools/path-utils.js +16 -2
  54. package/dist/tools.js +84 -2
  55. package/dist/types.d.ts +10 -0
  56. package/package.json +1 -1
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.1.9",
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",