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
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const { mockSpawn, mockSpawnSync } = vi.hoisted(() => ({
3
+ mockSpawn: vi.fn(),
4
+ mockSpawnSync: vi.fn().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
5
+ }));
6
+ vi.mock('child_process', () => ({ spawn: mockSpawn, spawnSync: mockSpawnSync }));
7
+ import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, } from '../sandbox/runtime.js';
8
+ function fakeChild(exitCode, stdout = '', stderr = '', opts) {
9
+ const stdoutCallbacks = [];
10
+ const stderrCallbacks = [];
11
+ const eventCallbacks = {};
12
+ const child = {
13
+ stdout: { on: (_e, cb) => stdoutCallbacks.push(cb) },
14
+ stderr: { on: (_e, cb) => stderrCallbacks.push(cb) },
15
+ stdin: opts?.stdinNeeded ? { write: vi.fn(), end: vi.fn() } : null,
16
+ on(event, cb) {
17
+ if (!eventCallbacks[event])
18
+ eventCallbacks[event] = [];
19
+ eventCallbacks[event].push(cb);
20
+ return child;
21
+ },
22
+ };
23
+ // Emit data and close on next tick (setTimeout ensures listeners are attached first)
24
+ setTimeout(() => {
25
+ if (stdout)
26
+ for (const cb of stdoutCallbacks)
27
+ cb(Buffer.from(stdout));
28
+ if (stderr)
29
+ for (const cb of stderrCallbacks)
30
+ cb(Buffer.from(stderr));
31
+ for (const cb of eventCallbacks['close'] ?? [])
32
+ cb(exitCode);
33
+ }, 0);
34
+ return child;
35
+ }
36
+ describe('sandbox/runtime', () => {
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ resetRuntime();
40
+ setRuntime('container'); // skip auto-detection in tests
41
+ });
42
+ describe('createContainer', () => {
43
+ it('builds correct docker args', async () => {
44
+ mockSpawn.mockReturnValue(fakeChild(0));
45
+ await createContainer('test-ctr', {
46
+ image: 'myimg',
47
+ cpus: 2,
48
+ memory: '1G',
49
+ network: 'none',
50
+ user: '501:20',
51
+ mounts: [{ host: '/a', container: '/b', readOnly: true }],
52
+ });
53
+ const args = mockSpawn.mock.calls[0][1];
54
+ expect(args).toContain('--name');
55
+ expect(args).toContain('test-ctr');
56
+ expect(args).toContain('--cpus');
57
+ expect(args).toContain('2');
58
+ expect(args).toContain('--memory');
59
+ expect(args).toContain('1G');
60
+ expect(args).toContain('--network');
61
+ expect(args).toContain('none');
62
+ expect(args).toContain('--user');
63
+ expect(args).toContain('501:20');
64
+ expect(args.some((a) => a.includes('type=bind,src=/a,dst=/b,ro'))).toBe(true);
65
+ expect(args).toContain('myimg');
66
+ });
67
+ it('throws on non-zero exit', async () => {
68
+ mockSpawn.mockReturnValue(fakeChild(1, '', 'boom'));
69
+ await expect(createContainer('c1', { image: 'img' })).rejects.toThrow('Failed to create container');
70
+ });
71
+ });
72
+ describe('execInContainer', () => {
73
+ it('builds sh -c command', async () => {
74
+ mockSpawn.mockReturnValue(fakeChild(0, 'hello'));
75
+ const result = await execInContainer('ctr', ['echo hello']);
76
+ expect(result.stdout).toBe('hello');
77
+ const args = mockSpawn.mock.calls[0][1];
78
+ expect(args).toContain('sh');
79
+ expect(args).toContain('-c');
80
+ });
81
+ it('handles stdin', async () => {
82
+ mockSpawn.mockReturnValue(fakeChild(0, '', '', { stdinNeeded: true }));
83
+ await execInContainer('ctr', ['cat'], { stdin: 'data' });
84
+ const args = mockSpawn.mock.calls[0][1];
85
+ expect(args).toContain('-i');
86
+ });
87
+ it('handles env vars', async () => {
88
+ mockSpawn.mockReturnValue(fakeChild(0));
89
+ await execInContainer('ctr', ['cmd'], { env: { FOO: 'bar' } });
90
+ const args = mockSpawn.mock.calls[0][1];
91
+ expect(args).toContain('-e');
92
+ expect(args).toContain('FOO=bar');
93
+ });
94
+ });
95
+ describe('removeContainer', () => {
96
+ it('calls stop then rm', async () => {
97
+ mockSpawn.mockImplementation((_cmd, args) => {
98
+ return fakeChild(0);
99
+ });
100
+ await removeContainer('ctr');
101
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
102
+ expect(mockSpawn.mock.calls[0][1]).toContain('stop');
103
+ expect(mockSpawn.mock.calls[1][1]).toContain('rm');
104
+ }, 10_000);
105
+ it('does not throw on failure', async () => {
106
+ mockSpawn.mockImplementation(() => fakeChild(1, '', 'fail'));
107
+ await expect(removeContainer('ctr')).resolves.toBeUndefined();
108
+ }, 10_000);
109
+ });
110
+ describe('isContainerRunning', () => {
111
+ it('returns true when inspect shows running state', async () => {
112
+ mockSpawn.mockReturnValue(fakeChild(0, '{"State": {"Status": "running"}}'));
113
+ expect(await isContainerRunning('ctr')).toBe(true);
114
+ });
115
+ it('returns false otherwise', async () => {
116
+ mockSpawn.mockReturnValue(fakeChild(1));
117
+ expect(await isContainerRunning('ctr')).toBe(false);
118
+ });
119
+ });
120
+ describe('cleanupOrphans', () => {
121
+ it('lists containers, filters by prefix, removes matches', async () => {
122
+ let callCount = 0;
123
+ mockSpawn.mockImplementation(() => {
124
+ callCount++;
125
+ if (callCount === 1) {
126
+ // ps call
127
+ return fakeChild(0, 'skimpyclaw-sbx-abc\nother-ctr\nskimpyclaw-sbx-def\n');
128
+ }
129
+ // stop/rm calls
130
+ return fakeChild(0);
131
+ });
132
+ const count = await cleanupOrphans();
133
+ expect(count).toBe(2);
134
+ });
135
+ it('returns 0 on ps failure', async () => {
136
+ mockSpawn.mockImplementation(() => fakeChild(1));
137
+ expect(await cleanupOrphans()).toBe(0);
138
+ });
139
+ });
140
+ });
@@ -79,7 +79,7 @@ describe('setup config generation', () => {
79
79
  agentName: 'Claw',
80
80
  selectedProviders: new Set(['anthropic-api']),
81
81
  providerSecrets: { anthropicKey: 'sk-ant-test' },
82
- features: { browser: false, voice: false, mcp: false },
82
+ features: { browser: false, voice: false, mcp: false, sandbox: false },
83
83
  });
84
84
  expect(config.heartbeat.tools.browser.enabled).toBe(false);
85
85
  expect(config.voice).toBeUndefined();
@@ -92,7 +92,7 @@ describe('setup config generation', () => {
92
92
  agentName: 'Claw',
93
93
  selectedProviders: new Set(['anthropic-api']),
94
94
  providerSecrets: { anthropicKey: 'sk-ant-test' },
95
- features: { browser: true, voice: true, mcp: false },
95
+ features: { browser: true, voice: true, mcp: false, sandbox: false },
96
96
  });
97
97
  expect(config.heartbeat.tools.browser.enabled).toBe(true);
98
98
  expect(config.voice).toBeDefined();
@@ -115,4 +115,30 @@ describe('setup config generation', () => {
115
115
  // parseInt('not-a-number') is NaN, so || falls through to string
116
116
  expect(config.channels.telegram.allowFrom).toEqual(['not-a-number']);
117
117
  });
118
+ it('includes starter cron jobs and skills when requested', () => {
119
+ const config = buildSetupConfig({
120
+ workspaceDir: '/tmp/workspace',
121
+ telegramId: '12345',
122
+ telegramToken: 'tg-token',
123
+ agentName: 'Claw',
124
+ selectedProviders: new Set(['anthropic-api']),
125
+ providerSecrets: { anthropicKey: 'sk-ant-test' },
126
+ starters: {
127
+ cronTechNews: true,
128
+ cronWeather: true,
129
+ timezone: 'America/New_York',
130
+ weatherLocation: 'Austin, TX',
131
+ skillCodeReview: true,
132
+ skillDailyNotes: true,
133
+ },
134
+ });
135
+ expect(config.cron.jobs).toHaveLength(2);
136
+ expect(config.cron.jobs[0].id).toBe('starter-tech-news-hn');
137
+ expect(config.cron.jobs[1].id).toBe('starter-weather-7am');
138
+ expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
139
+ expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
140
+ expect(config.skills.enabled).toBe(true);
141
+ expect(config.skills.entries['code-review']).toBe(true);
142
+ expect(config.skills.entries['daily-notes']).toBe(true);
143
+ });
118
144
  });
@@ -313,21 +313,12 @@ describe('formatSkillsPrompt', () => {
313
313
  expect(result).toContain('### a');
314
314
  expect(result).toContain('### b');
315
315
  });
316
- it('respects token budget', () => {
317
- // With maxTokens=10 (40 chars), only header + maybe first tiny skill fits
318
- const result = formatSkillsPrompt([
319
- makeSkill('big', 'x'.repeat(200)),
320
- ], 10);
321
- // The header alone is ~18 chars, the skill section would be ~210 chars
322
- // Total exceeds 40 chars budget, so skill gets skipped
323
- expect(result).toBe('');
324
- });
325
- it('includes skills that fit within budget', () => {
316
+ it('does not skip skills based on prompt budget argument', () => {
326
317
  const result = formatSkillsPrompt([
327
318
  makeSkill('small', 'tiny'),
328
319
  makeSkill('big', 'x'.repeat(50000)),
329
320
  ], 100);
330
321
  expect(result).toContain('### small');
331
- expect(result).not.toContain('### big');
322
+ expect(result).toContain('### big');
332
323
  });
333
324
  });
@@ -180,9 +180,14 @@ describe('bash', () => {
180
180
  expect(result).toContain('Error: Working directory not in allowed paths');
181
181
  });
182
182
  it('returns stderr on failure', async () => {
183
- const result = await executeTool('Bash', { command: 'cat /nonexistent_file_xyz' }, toolConfig);
183
+ const nonexistent = join(TEST_DIR, 'nonexistent_file_xyz');
184
+ const result = await executeTool('Bash', { command: `cat "${nonexistent}"` }, toolConfig);
184
185
  expect(result).toContain('No such file');
185
186
  });
187
+ it('blocks commands referencing paths outside allowed dirs', async () => {
188
+ const result = await executeTool('Bash', { command: 'cat /etc/passwd' }, toolConfig);
189
+ expect(result).toContain('Error: Command references paths outside allowed directories');
190
+ });
186
191
  it('handles unknown tools', async () => {
187
192
  const result = await executeTool('delete_everything', {}, toolConfig);
188
193
  expect(result).toContain('Error: Unknown tool');
package/dist/agent.js CHANGED
@@ -153,6 +153,8 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
153
153
  channelTargetId,
154
154
  approverUserId: context?.userId,
155
155
  approverUsername: context?.metadata?.username,
156
+ sandboxConfig: config.sandbox,
157
+ sessionId: context?.sessionId || String(chatIdNum ?? 'default'),
156
158
  };
157
159
  const runTurn = async () => {
158
160
  if (toolConfig?.enabled) {
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Dashboard API endpoints
2
2
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, rmSync } from 'fs';
3
+ import { timingSafeEqual } from 'crypto';
3
4
  import { join, basename, resolve } from 'path';
4
5
  import { homedir } from 'os';
5
6
  import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, listMemoryFiles, readMemoryFile, } from './config.js';
@@ -102,7 +103,10 @@ export function registerDashboardAPI(fastify, config) {
102
103
  return reply.code(401).send({ error: 'Unauthorized: Bearer token required' });
103
104
  }
104
105
  const providedToken = authHeader.slice(7);
105
- if (providedToken !== token) {
106
+ // Timing-safe comparison to prevent token extraction via timing attacks
107
+ const tokenBuf = Buffer.from(token, 'utf8');
108
+ const providedBuf = Buffer.from(providedToken, 'utf8');
109
+ if (tokenBuf.length !== providedBuf.length || !timingSafeEqual(tokenBuf, providedBuf)) {
106
110
  return reply.code(401).send({ error: 'Unauthorized: Invalid token' });
107
111
  }
108
112
  });
@@ -114,8 +114,8 @@ export function getDefaultTelegramToolConfig(cfg) {
114
114
  }
115
115
  return {
116
116
  enabled: true,
117
- allowedPaths: [join(homedir(), '.skimpyclaw'), process.cwd()],
118
- maxIterations: 100,
117
+ allowedPaths: [join(homedir(), '.skimpyclaw')],
118
+ maxIterations: 30,
119
119
  bashTimeout: 15000,
120
120
  };
121
121
  }
package/dist/cli.js CHANGED
@@ -43,6 +43,10 @@ Commands:
43
43
  tools list List available tools (built-in + MCP)
44
44
  tools install <name> Add MCP server (--command <cmd> [--args ...] or --url <url>)
45
45
  tools remove <name> Remove MCP server
46
+ sandbox status Show active sandbox containers
47
+ sandbox prune Force-prune all sandbox containers
48
+ sandbox init Auto-setup sandbox runtime/image/config (supports --profile)
49
+ sandbox doctor Sandbox-specific diagnostics and hints
46
50
  help Show this help
47
51
  `);
48
52
  }
@@ -724,6 +728,211 @@ async function commandTools(args) {
724
728
  console.error('Usage: skimpyclaw tools <list|install|remove>');
725
729
  return 1;
726
730
  }
731
+ const SANDBOX_CLI_BY_PROFILE = {
732
+ minimal: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm'],
733
+ dev: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make'],
734
+ full: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make', 'pip3', 'sqlite3'],
735
+ };
736
+ function defaultSandboxNetwork(runtime) {
737
+ return runtime === 'container' ? 'default' : 'bridge';
738
+ }
739
+ function detectSandboxRuntime(preferred) {
740
+ if (preferred === 'container' || preferred === 'docker') {
741
+ return spawnSync(preferred, ['--version'], { encoding: 'utf-8' }).status === 0 ? preferred : null;
742
+ }
743
+ if (spawnSync('container', ['--version'], { encoding: 'utf-8' }).status === 0) {
744
+ return 'container';
745
+ }
746
+ if (spawnSync('docker', ['--version'], { encoding: 'utf-8' }).status === 0) {
747
+ return 'docker';
748
+ }
749
+ return null;
750
+ }
751
+ function isSandboxRuntimeRunning(runtime) {
752
+ if (runtime === 'container') {
753
+ return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
754
+ }
755
+ return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
756
+ }
757
+ function sandboxNetworkExists(runtime, network) {
758
+ if (runtime === 'container') {
759
+ const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
760
+ if (result.status !== 0)
761
+ return false;
762
+ return result.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === network);
763
+ }
764
+ const result = spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' });
765
+ return result.status === 0;
766
+ }
767
+ function sandboxImageExists(runtime, image) {
768
+ return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
769
+ }
770
+ function resolveSandboxDir() {
771
+ const cwdSandbox = join(process.cwd(), 'sandbox');
772
+ if (existsSync(join(cwdSandbox, 'Dockerfile'))) {
773
+ return cwdSandbox;
774
+ }
775
+ return null;
776
+ }
777
+ function parseSandboxOption(args, flag) {
778
+ const idx = args.indexOf(flag);
779
+ if (idx === -1 || idx + 1 >= args.length)
780
+ return undefined;
781
+ return args[idx + 1];
782
+ }
783
+ function runSandboxImageCheck(runtime, image, network, cmd) {
784
+ const result = spawnSync(runtime, ['run', '--rm', '--network', network, image, 'sh', '-lc', cmd], { encoding: 'utf-8' });
785
+ if (result.status === 0) {
786
+ return { ok: true, detail: (result.stdout || '').trim() || 'ok' };
787
+ }
788
+ const detail = `${(result.stderr || '').trim()} ${(result.stdout || '').trim()}`.trim() || `exit ${result.status ?? 1}`;
789
+ return { ok: false, detail };
790
+ }
791
+ function printSandboxCheck(ok, name, detail, hint) {
792
+ const prefix = ok ? '✓' : '✗';
793
+ console.log(`${prefix} ${name}: ${detail}`);
794
+ if (!ok && hint) {
795
+ console.log(` → ${hint}`);
796
+ }
797
+ }
798
+ async function commandSandbox(args) {
799
+ const sub = args[0];
800
+ if (sub === 'status') {
801
+ const rt = detectSandboxRuntime();
802
+ if (!rt) {
803
+ console.log('No container runtime found (install Docker or Apple Containers).');
804
+ return 1;
805
+ }
806
+ const result = spawnSync(rt, ['ps', '--format', '{{.Names}}'], { encoding: 'utf-8' });
807
+ const lines = (result.stdout || '').trim().split('\n').filter(Boolean);
808
+ const containers = lines.filter((line) => line.includes('skimpyclaw-sbx'));
809
+ if (containers.length === 0) {
810
+ console.log('No active sandbox containers.');
811
+ }
812
+ else {
813
+ console.log(`Active sandbox containers (${containers.length}):`);
814
+ containers.forEach((c) => console.log(` ${c}`));
815
+ }
816
+ return 0;
817
+ }
818
+ if (sub === 'prune') {
819
+ const { cleanupOrphans } = await import('./sandbox/index.js');
820
+ const count = await cleanupOrphans();
821
+ console.log(`Pruned ${count} sandbox container(s).`);
822
+ return 0;
823
+ }
824
+ if (sub === 'init') {
825
+ const runtimeFlag = parseSandboxOption(args, '--runtime');
826
+ const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
827
+ const imageFlag = parseSandboxOption(args, '--image');
828
+ const networkFlag = parseSandboxOption(args, '--network');
829
+ const validProfiles = ['minimal', 'dev', 'full'];
830
+ if (!validProfiles.includes(profileFlag)) {
831
+ console.error(`Invalid profile "${profileFlag}". Use one of: minimal, dev, full`);
832
+ return 1;
833
+ }
834
+ const profile = profileFlag;
835
+ const runtime = detectSandboxRuntime(runtimeFlag);
836
+ if (!runtime) {
837
+ console.error('No supported runtime found. Install Apple Containers or Docker.');
838
+ return 1;
839
+ }
840
+ const network = networkFlag || defaultSandboxNetwork(runtime);
841
+ const image = imageFlag || 'skimpyclaw-sandbox:latest';
842
+ if (!isSandboxRuntimeRunning(runtime)) {
843
+ const hint = runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop (or run `docker info`).';
844
+ console.error(`Runtime "${runtime}" is not running.`);
845
+ console.error(hint);
846
+ return 1;
847
+ }
848
+ if (!sandboxNetworkExists(runtime, network)) {
849
+ const hint = runtime === 'container'
850
+ ? 'Create/list networks with `container network ls`.'
851
+ : 'Create/list networks with `docker network ls`.';
852
+ console.error(`Sandbox network "${network}" not found for runtime "${runtime}".`);
853
+ console.error(hint);
854
+ return 1;
855
+ }
856
+ const sandboxDir = resolveSandboxDir();
857
+ if (!sandboxDir) {
858
+ console.error('Could not find sandbox/Dockerfile from current directory.');
859
+ console.error('Run from repo root (contains ./sandbox) or build image manually.');
860
+ return 1;
861
+ }
862
+ console.log(`Building sandbox image "${image}" (runtime=${runtime}, profile=${profile})...`);
863
+ const build = spawnSync(runtime, ['build', '--build-arg', `SKIMPY_PROFILE=${profile}`, '-t', image, sandboxDir], { stdio: 'inherit' });
864
+ if (build.status !== 0) {
865
+ console.error('Sandbox image build failed.');
866
+ return 1;
867
+ }
868
+ const raw = loadRawConfig();
869
+ const sandbox = raw.sandbox ?? {};
870
+ sandbox.enabled = true;
871
+ sandbox.runtime = runtime;
872
+ sandbox.network = network;
873
+ sandbox.image = image;
874
+ raw.sandbox = sandbox;
875
+ saveConfig(raw);
876
+ console.log('Updated config: sandbox.enabled=true');
877
+ console.log(`Updated config: sandbox.runtime="${runtime}"`);
878
+ console.log(`Updated config: sandbox.network="${network}"`);
879
+ console.log(`Updated config: sandbox.image="${image}"`);
880
+ const required = SANDBOX_CLI_BY_PROFILE[profile];
881
+ const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
882
+ const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
883
+ const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://example.com >/dev/null && echo net-ok');
884
+ const hostCheck = runSandboxImageCheck(runtime, image, network, 'hostname');
885
+ printSandboxCheck(hostCheck.ok, 'sandbox_hostname', hostCheck.detail);
886
+ printSandboxCheck(cliCheck.ok, 'sandbox_tools', cliCheck.detail, 'Rebuild image or choose a lighter profile.');
887
+ printSandboxCheck(netCheck.ok, 'sandbox_network_egress', netCheck.detail, 'Try a different sandbox.network or check runtime DNS/network settings.');
888
+ if (!hostCheck.ok || !cliCheck.ok || !netCheck.ok) {
889
+ return 1;
890
+ }
891
+ console.log('\nSandbox init complete. Restart Skimpy to apply runtime config.');
892
+ return 0;
893
+ }
894
+ if (sub === 'doctor') {
895
+ const config = loadConfig();
896
+ const runtime = detectSandboxRuntime(config.sandbox?.runtime);
897
+ const image = config.sandbox?.image || 'skimpyclaw-sandbox:latest';
898
+ const network = config.sandbox?.network || (runtime ? defaultSandboxNetwork(runtime) : 'unknown');
899
+ const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
900
+ const profile = (['minimal', 'dev', 'full'].includes(profileFlag) ? profileFlag : 'minimal');
901
+ let failed = false;
902
+ printSandboxCheck(config.sandbox?.enabled === true, 'sandbox_enabled', config.sandbox?.enabled ? 'enabled' : 'disabled', 'Run: skimpyclaw sandbox init');
903
+ if (!config.sandbox?.enabled)
904
+ failed = true;
905
+ printSandboxCheck(!!runtime, 'runtime_detected', runtime || 'none', 'Install Docker or Apple Containers.');
906
+ if (!runtime)
907
+ return 1;
908
+ printSandboxCheck(isSandboxRuntimeRunning(runtime), 'runtime_running', runtime, runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop.');
909
+ if (!isSandboxRuntimeRunning(runtime))
910
+ failed = true;
911
+ const networkOk = sandboxNetworkExists(runtime, network);
912
+ printSandboxCheck(networkOk, 'network_exists', network, `Use "${runtime === 'container' ? 'container' : 'docker'} network ls" and update sandbox.network.`);
913
+ if (!networkOk)
914
+ failed = true;
915
+ const imageOk = sandboxImageExists(runtime, image);
916
+ printSandboxCheck(imageOk, 'image_exists', image, `Build image: ${runtime} build -t ${image} sandbox/`);
917
+ if (!imageOk)
918
+ failed = true;
919
+ if (imageOk && networkOk) {
920
+ const required = SANDBOX_CLI_BY_PROFILE[profile];
921
+ const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
922
+ const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
923
+ printSandboxCheck(cliCheck.ok, 'image_toolchain', cliCheck.detail, 'Rebuild with: skimpyclaw sandbox init --profile dev');
924
+ if (!cliCheck.ok)
925
+ failed = true;
926
+ const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://api.duckduckgo.com/?q=skimpyclaw&format=json >/dev/null && echo net-ok');
927
+ printSandboxCheck(netCheck.ok, 'network_egress', netCheck.detail, 'Some sources may timeout; verify DNS/network in runtime.');
928
+ if (!netCheck.ok)
929
+ failed = true;
930
+ }
931
+ return failed ? 1 : 0;
932
+ }
933
+ console.log('Usage: skimpyclaw sandbox <status|prune|init|doctor>');
934
+ return 1;
935
+ }
727
936
  export async function runCli(argv = process.argv.slice(2)) {
728
937
  const [command, ...args] = argv;
729
938
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -787,6 +996,9 @@ export async function runCli(argv = process.argv.slice(2)) {
787
996
  if (command === 'tools') {
788
997
  return await commandTools(args);
789
998
  }
999
+ if (command === 'sandbox') {
1000
+ return await commandSandbox(args);
1001
+ }
790
1002
  console.error(`Unknown command: ${command}`);
791
1003
  printHelp();
792
1004
  return 1;
@@ -10,6 +10,7 @@ import { buildCodeAgentArgs, notifyCodeAgentResult } from './utils.js';
10
10
  import { parseStreamJsonForLive, parseClaudeOutput, parseCodexOutput } from './parser.js';
11
11
  import { startTrace, addEvent, endTrace } from '../audit.js';
12
12
  import { buildUsageRecord, recordUsage } from '../usage.js';
13
+ import { ensureContainer, SANDBOX_DEFAULTS, getRuntime } from '../sandbox/index.js';
13
14
  const CANCELLED_MESSAGE = 'Cancelled by user';
14
15
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
15
16
  export function runValidation(workdir) {
@@ -87,6 +88,13 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
87
88
  logStream.write(`Task: ${task.slice(0, 500)}\n`);
88
89
  logStream.write(`Workdir: ${workdir}\n\n`);
89
90
  try {
91
+ // Resolve sandbox container name if enabled (used for spawn wrapping)
92
+ let sandboxContainer;
93
+ if (options?.sandboxConfig?.enabled) {
94
+ const merged = { ...SANDBOX_DEFAULTS, ...options.sandboxConfig };
95
+ sandboxContainer = await ensureContainer(`code-${id}`, merged, options.allowedPaths || [workdir]);
96
+ console.log(`[code-agent] Running in sandbox container: ${sandboxContainer}`);
97
+ }
90
98
  ensureNotCancelled();
91
99
  const exitCode = await new Promise((resolvePromise, reject) => {
92
100
  const spawnEnv = { ...process.env };
@@ -94,8 +102,11 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
94
102
  // Apply extra env vars (e.g. team mode feature flag)
95
103
  if (options?.env)
96
104
  Object.assign(spawnEnv, options.env);
97
- const proc = spawn(cmd, args, {
98
- cwd: workdir,
105
+ // When sandbox is enabled, wrap the spawn: container exec <name> <cmd> <args>
106
+ const spawnCmd = sandboxContainer ? getRuntime() : cmd;
107
+ const spawnArgs = sandboxContainer ? ['exec', sandboxContainer, cmd, ...args] : args;
108
+ const proc = spawn(spawnCmd, spawnArgs, {
109
+ cwd: sandboxContainer ? undefined : workdir, // container has its own cwd
99
110
  stdio: ['ignore', 'pipe', 'pipe'],
100
111
  env: spawnEnv,
101
112
  });
@@ -277,8 +288,10 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
277
288
  const retryExitCode = await new Promise((resolveRetry, rejectRetry) => {
278
289
  const spawnEnv = { ...process.env };
279
290
  delete spawnEnv.CLAUDECODE;
280
- const retryProc = spawn(retryCmd, retryArgs, {
281
- cwd: workdir,
291
+ const retrySpawnCmd = sandboxContainer ? getRuntime() : retryCmd;
292
+ const retrySpawnArgs = sandboxContainer ? ['exec', sandboxContainer, retryCmd, ...retryArgs] : retryArgs;
293
+ const retryProc = spawn(retrySpawnCmd, retrySpawnArgs, {
294
+ cwd: sandboxContainer ? undefined : workdir,
282
295
  stdio: ['ignore', 'pipe', 'pipe'],
283
296
  env: spawnEnv,
284
297
  });
@@ -1,3 +1,4 @@
1
+ import type { SandboxConfig } from '../types.js';
1
2
  export interface CodeAgentTask {
2
3
  id: string;
3
4
  agent: string;
@@ -44,6 +45,10 @@ export interface CodeAgentBackgroundOptions {
44
45
  maxTimeoutMinutes?: number;
45
46
  /** Skip sending notification on completion (parent handles it) */
46
47
  skipNotification?: boolean;
48
+ /** Sandbox configuration — when enabled, run CLI inside container */
49
+ sandboxConfig?: SandboxConfig;
50
+ /** Paths to mount into the sandbox container */
51
+ allowedPaths?: string[];
47
52
  }
48
53
  export interface BuildCodeAgentArgsInput {
49
54
  task: string;
package/dist/cron.js CHANGED
@@ -9,6 +9,7 @@ import { startTrace, addEvent, endTrace } from './audit.js';
9
9
  import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
10
10
  import { parseAndSaveDigest } from './digests.js';
11
11
  import { synthesizeSpeech } from './voice.js';
12
+ import { ensureContainer, SANDBOX_DEFAULTS, sandboxBash } from './sandbox/index.js';
12
13
  const scheduledJobs = new Map();
13
14
  let configWatcher = null;
14
15
  function getCronLogDir() {
@@ -189,7 +190,7 @@ async function executeJobPayload(jobDef, config) {
189
190
  });
190
191
  appendCronLogLine(jobDef.id, `Script started: ${(jobDef.payload.script || '').slice(0, 100)}`);
191
192
  try {
192
- const output = await executeScript(jobDef);
193
+ const output = await executeScript(jobDef, config);
193
194
  logEntry.output = output.slice(0, 50000);
194
195
  appendCronLogLine(jobDef.id, `Script completed (${output.length} chars)`);
195
196
  addEvent(scriptTraceId, {
@@ -248,7 +249,7 @@ async function executeJobPayload(jobDef, config) {
248
249
  }
249
250
  }
250
251
  }
251
- async function executeScript(jobDef) {
252
+ async function executeScript(jobDef, config) {
252
253
  const script = expandVariables(jobDef.payload.script || '');
253
254
  if (!script) {
254
255
  throw new Error(`Script payload is empty for job: ${jobDef.id}`);
@@ -258,6 +259,19 @@ async function executeScript(jobDef) {
258
259
  throw new Error(`Working directory does not exist: ${cwd}`);
259
260
  }
260
261
  const timeoutMs = jobDef.payload.timeoutMs || 600000; // 10 min default
262
+ // Sandbox routing for script payloads
263
+ const sandboxCfg = config.sandbox;
264
+ if (sandboxCfg?.enabled) {
265
+ const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
266
+ const containerName = await ensureContainer(`cron-${jobDef.id}`, merged, jobDef.payload.tools?.allowedPaths || []);
267
+ console.log(`[cron:script] Running in sandbox container: ${containerName}`);
268
+ console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
269
+ if (cwd)
270
+ console.log(`[cron:script] cwd: ${cwd}`);
271
+ const output = await sandboxBash(containerName, script, cwd, timeoutMs);
272
+ console.log(`[cron:script] Sandbox completed (${output.length} chars)`);
273
+ return output;
274
+ }
261
275
  return new Promise((resolve, reject) => {
262
276
  const startTime = Date.now();
263
277
  console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
package/dist/discord.js CHANGED
@@ -47,8 +47,8 @@ const chatHistory = new Map();
47
47
  const loadedFromDisk = new Set();
48
48
  const DEFAULT_DISCORD_TOOLS = {
49
49
  enabled: true,
50
- allowedPaths: [join(homedir(), '.skimpyclaw'), process.cwd()],
51
- maxIterations: 100,
50
+ allowedPaths: [join(homedir(), '.skimpyclaw')],
51
+ maxIterations: 30,
52
52
  bashTimeout: 15000,
53
53
  };
54
54
  let client = null;
@@ -15,4 +15,5 @@ export declare function checkVoiceDependencies(config: Config): Promise<DoctorCh
15
15
  export declare function checkMcpConfig(config: Config): Promise<DoctorCheckResult>;
16
16
  export declare function checkGatewayHostBindable(host: string): Promise<DoctorCheckResult>;
17
17
  export declare function checkSkimpyclawDirWritable(): Promise<DoctorCheckResult>;
18
+ export declare function checkSandboxAvailable(config: Config): Promise<DoctorCheckResult>;
18
19
  export declare function checkPortAvailability(port: number): Promise<DoctorCheckResult>;