skimpyclaw 0.1.9 → 0.3.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 (61) 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__/cron.test.js +51 -1
  4. package/dist/__tests__/doctor.runner.test.js +5 -1
  5. package/dist/__tests__/heartbeat.test.js +5 -5
  6. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  7. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  8. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  9. package/dist/__tests__/sandbox-manager.test.js +119 -0
  10. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  11. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  12. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  13. package/dist/__tests__/sandbox-runtime.test.js +176 -0
  14. package/dist/__tests__/setup.test.js +28 -2
  15. package/dist/__tests__/skills.test.js +2 -11
  16. package/dist/__tests__/tools.test.js +6 -1
  17. package/dist/agent.js +3 -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.d.ts +6 -0
  24. package/dist/cron.js +59 -3
  25. package/dist/discord.js +2 -2
  26. package/dist/doctor/checks.d.ts +1 -0
  27. package/dist/doctor/checks.js +47 -0
  28. package/dist/doctor/runner.js +2 -1
  29. package/dist/exec-approval.d.ts +4 -0
  30. package/dist/exec-approval.js +4 -4
  31. package/dist/gateway.js +33 -2
  32. package/dist/heartbeat.js +3 -0
  33. package/dist/providers/anthropic.js +1 -1
  34. package/dist/providers/codex.js +1 -1
  35. package/dist/providers/openai.js +2 -2
  36. package/dist/sandbox/bridge.d.ts +5 -0
  37. package/dist/sandbox/bridge.js +63 -0
  38. package/dist/sandbox/index.d.ts +5 -0
  39. package/dist/sandbox/index.js +4 -0
  40. package/dist/sandbox/manager.d.ts +7 -0
  41. package/dist/sandbox/manager.js +89 -0
  42. package/dist/sandbox/mount-security.d.ts +12 -0
  43. package/dist/sandbox/mount-security.js +118 -0
  44. package/dist/sandbox/runtime.d.ts +38 -0
  45. package/dist/sandbox/runtime.js +187 -0
  46. package/dist/service.js +25 -0
  47. package/dist/setup.d.ts +11 -0
  48. package/dist/setup.js +335 -13
  49. package/dist/skills.d.ts +1 -2
  50. package/dist/skills.js +1 -13
  51. package/dist/tools/bash-path-validation.d.ts +22 -0
  52. package/dist/tools/bash-path-validation.js +130 -0
  53. package/dist/tools/bash-tool.js +23 -1
  54. package/dist/tools/definitions.d.ts +0 -7
  55. package/dist/tools/definitions.js +0 -5
  56. package/dist/tools/execute-context.d.ts +6 -0
  57. package/dist/tools/path-utils.js +16 -2
  58. package/dist/tools.js +84 -2
  59. package/dist/types.d.ts +10 -0
  60. package/dist/voice.js +9 -2
  61. package/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('fs', async () => {
3
+ const actual = await vi.importActual('fs');
4
+ return {
5
+ ...actual,
6
+ existsSync: vi.fn(),
7
+ realpathSync: vi.fn(),
8
+ };
9
+ });
10
+ import { existsSync, realpathSync } from 'fs';
11
+ import { isBlockedPath, validateMountPaths, translatePath } from '../sandbox/mount-security.js';
12
+ const mockExistsSync = existsSync;
13
+ const mockRealpathSync = realpathSync;
14
+ describe('sandbox/mount-security', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+ describe('isBlockedPath', () => {
19
+ it('blocks .ssh', () => {
20
+ expect(isBlockedPath('/home/user/.ssh')).toBe(true);
21
+ expect(isBlockedPath('/home/user/.ssh/id_rsa')).toBe(true);
22
+ });
23
+ it('blocks .gnupg', () => {
24
+ expect(isBlockedPath('/home/user/.gnupg')).toBe(true);
25
+ });
26
+ it('blocks .aws', () => {
27
+ expect(isBlockedPath('/home/user/.aws')).toBe(true);
28
+ });
29
+ it('blocks credentials', () => {
30
+ expect(isBlockedPath('/some/path/credentials')).toBe(true);
31
+ });
32
+ it('blocks .env', () => {
33
+ expect(isBlockedPath('/project/.env')).toBe(true);
34
+ });
35
+ it('blocks .kube', () => {
36
+ expect(isBlockedPath('/home/user/.kube/config')).toBe(true);
37
+ });
38
+ it('blocks .docker/config.json compound pattern', () => {
39
+ expect(isBlockedPath('/home/user/.docker/config.json')).toBe(true);
40
+ });
41
+ it('allows normal paths', () => {
42
+ expect(isBlockedPath('/home/user/project')).toBe(false);
43
+ expect(isBlockedPath('/workspace/src/index.ts')).toBe(false);
44
+ expect(isBlockedPath('/tmp/test')).toBe(false);
45
+ });
46
+ });
47
+ describe('validateMountPaths', () => {
48
+ beforeEach(() => {
49
+ mockExistsSync.mockReturnValue(true);
50
+ mockRealpathSync.mockImplementation((p) => p);
51
+ });
52
+ it('maps to /workspace/<basename>', () => {
53
+ const mounts = validateMountPaths(['/home/user/myproject']);
54
+ const projectMount = mounts.find((m) => m.host === '/home/user/myproject');
55
+ expect(projectMount).toBeDefined();
56
+ expect(projectMount.container).toBe('/workspace/myproject');
57
+ expect(projectMount.readOnly).toBe(false);
58
+ });
59
+ it('deduplicates basenames with suffix', () => {
60
+ const mounts = validateMountPaths(['/a/project', '/b/project']);
61
+ const containers = mounts.filter((m) => m.host.endsWith('/project')).map((m) => m.container);
62
+ expect(containers).toContain('/workspace/project');
63
+ expect(containers).toContain('/workspace/project_1');
64
+ });
65
+ it('adds ~/.skimpyclaw at /workspace/config', () => {
66
+ const mounts = validateMountPaths(['/home/user/project']);
67
+ const configMount = mounts.find((m) => m.container === '/workspace/config');
68
+ expect(configMount).toBeDefined();
69
+ });
70
+ it('throws on blocked paths', () => {
71
+ expect(() => validateMountPaths(['/home/user/.ssh'])).toThrow('Blocked path');
72
+ });
73
+ it('skips missing paths', () => {
74
+ mockExistsSync.mockImplementation((p) => {
75
+ // Only ~/.skimpyclaw exists for the auto-add
76
+ return typeof p === 'string' && p.includes('.skimpyclaw');
77
+ });
78
+ const mounts = validateMountPaths(['/nonexistent/path']);
79
+ // Should only have the auto-added config mount
80
+ const nonConfig = mounts.filter((m) => m.container !== '/workspace/config');
81
+ expect(nonConfig).toHaveLength(0);
82
+ });
83
+ it('does not duplicate ~/.skimpyclaw if already in list', () => {
84
+ // Simulate ~/.skimpyclaw being passed explicitly
85
+ const home = process.env.HOME || '/Users/katre';
86
+ const skDir = `${home}/.skimpyclaw`;
87
+ mockRealpathSync.mockImplementation((p) => p);
88
+ // .skimpyclaw is NOT blocked, so it should be mounted
89
+ // But it should not appear twice
90
+ const mounts = validateMountPaths([skDir]);
91
+ const configMounts = mounts.filter((m) => m.host === skDir);
92
+ expect(configMounts).toHaveLength(1);
93
+ });
94
+ });
95
+ describe('translatePath', () => {
96
+ const mounts = [
97
+ { host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
98
+ { host: '/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
99
+ ];
100
+ it('translates host path to container path', () => {
101
+ expect(translatePath('/Users/katre/Sites/skimpyclaw/src/index.ts', mounts))
102
+ .toBe('/workspace/skimpyclaw/src/index.ts');
103
+ });
104
+ it('translates exact mount root', () => {
105
+ expect(translatePath('/Users/katre/Sites/skimpyclaw', mounts))
106
+ .toBe('/workspace/skimpyclaw');
107
+ });
108
+ it('translates config path', () => {
109
+ expect(translatePath('/Users/katre/.skimpyclaw/config.json', mounts))
110
+ .toBe('/workspace/config/config.json');
111
+ });
112
+ it('translates /Users path to /System/Volumes/Data mount on macOS', () => {
113
+ const macMounts = [
114
+ { host: '/System/Volumes/Data/Users/katre/.skimpyclaw', container: '/workspace/config', readOnly: false },
115
+ ];
116
+ expect(translatePath('/Users/katre/.skimpyclaw/state/japan-flight-watch.json', macMounts))
117
+ .toBe('/workspace/config/state/japan-flight-watch.json');
118
+ });
119
+ it('returns original path if no mount matches', () => {
120
+ expect(translatePath('/tmp/random/file', mounts)).toBe('/tmp/random/file');
121
+ });
122
+ it('matches most specific mount first', () => {
123
+ const nestedMounts = [
124
+ { host: '/Users/katre', container: '/workspace/home', readOnly: false },
125
+ { host: '/Users/katre/Sites/skimpyclaw', container: '/workspace/skimpyclaw', readOnly: false },
126
+ ];
127
+ expect(translatePath('/Users/katre/Sites/skimpyclaw/src/file.ts', nestedMounts))
128
+ .toBe('/workspace/skimpyclaw/src/file.ts');
129
+ });
130
+ });
131
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
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, probeRuntime, } 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('probeRuntime', () => {
121
+ it('returns preferred runtime when available', () => {
122
+ mockSpawnSync.mockReturnValue({ status: 0 });
123
+ expect(probeRuntime('docker')).toBe('docker');
124
+ expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], { stdio: 'ignore' });
125
+ });
126
+ it('falls back to auto-detect when preferred is unavailable', () => {
127
+ mockSpawnSync.mockImplementation((cmd) => {
128
+ // preferred 'docker' fails, but 'container' succeeds
129
+ if (cmd === 'docker')
130
+ return { status: 1 };
131
+ if (cmd === 'container')
132
+ return { status: 0 };
133
+ return { status: 1 };
134
+ });
135
+ expect(probeRuntime('docker')).toBe('container');
136
+ });
137
+ it('returns null when no runtime is available', () => {
138
+ mockSpawnSync.mockReturnValue({ status: 1 });
139
+ expect(probeRuntime('docker')).toBeNull();
140
+ });
141
+ it('auto-detects without preferred runtime', () => {
142
+ mockSpawnSync.mockImplementation((cmd) => {
143
+ if (cmd === 'container')
144
+ return { status: 0 };
145
+ return { status: 1 };
146
+ });
147
+ expect(probeRuntime()).toBe('container');
148
+ });
149
+ it('prefers container over docker in auto-detect', () => {
150
+ mockSpawnSync.mockReturnValue({ status: 0 });
151
+ expect(probeRuntime()).toBe('container');
152
+ // First call should be to 'container'
153
+ expect(mockSpawnSync.mock.calls[0][0]).toBe('container');
154
+ });
155
+ });
156
+ describe('cleanupOrphans', () => {
157
+ it('lists containers, filters by prefix, removes matches', async () => {
158
+ let callCount = 0;
159
+ mockSpawn.mockImplementation(() => {
160
+ callCount++;
161
+ if (callCount === 1) {
162
+ // ps call
163
+ return fakeChild(0, 'skimpyclaw-sbx-abc\nother-ctr\nskimpyclaw-sbx-def\n');
164
+ }
165
+ // stop/rm calls
166
+ return fakeChild(0);
167
+ });
168
+ const count = await cleanupOrphans();
169
+ expect(count).toBe(2);
170
+ });
171
+ it('returns 0 on ps failure', async () => {
172
+ mockSpawn.mockImplementation(() => fakeChild(1));
173
+ expect(await cleanupOrphans()).toBe(0);
174
+ });
175
+ });
176
+ });
@@ -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('tech-digest');
137
+ expect(config.cron.jobs[1].id).toBe('weather');
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,9 @@ 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'),
158
+ isCronJob: context?.metadata?.isCronJob === true,
156
159
  };
157
160
  const runTurn = async () => {
158
161
  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
  }