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.
- package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bash-path-validation.test.js +164 -0
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +5 -5
- package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
- package/dist/__tests__/sandbox-bridge.test.js +116 -0
- package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
- package/dist/__tests__/sandbox-manager.test.js +119 -0
- package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
- package/dist/__tests__/sandbox-mount-security.test.js +131 -0
- package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
- package/dist/__tests__/sandbox-runtime.test.js +140 -0
- package/dist/__tests__/setup.test.js +28 -2
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/agent.js +2 -0
- package/dist/api.js +5 -1
- package/dist/channels/telegram/utils.js +2 -2
- package/dist/cli.js +212 -0
- package/dist/code-agents/executor.js +17 -4
- package/dist/code-agents/types.d.ts +5 -0
- package/dist/cron.js +16 -2
- package/dist/discord.js +2 -2
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.js +47 -0
- package/dist/doctor/runner.js +2 -1
- package/dist/exec-approval.d.ts +4 -0
- package/dist/exec-approval.js +4 -4
- package/dist/gateway.js +33 -2
- package/dist/heartbeat.js +3 -0
- package/dist/providers/openai.js +1 -1
- package/dist/sandbox/bridge.d.ts +5 -0
- package/dist/sandbox/bridge.js +63 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/manager.d.ts +7 -0
- package/dist/sandbox/manager.js +89 -0
- package/dist/sandbox/mount-security.d.ts +12 -0
- package/dist/sandbox/mount-security.js +118 -0
- package/dist/sandbox/runtime.d.ts +33 -0
- package/dist/sandbox/runtime.js +167 -0
- package/dist/service.js +17 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +335 -13
- package/dist/skills.d.ts +1 -2
- package/dist/skills.js +1 -13
- package/dist/tools/bash-path-validation.d.ts +22 -0
- package/dist/tools/bash-path-validation.js +130 -0
- package/dist/tools/bash-tool.js +23 -1
- package/dist/tools/definitions.d.ts +0 -7
- package/dist/tools/definitions.js +0 -5
- package/dist/tools/execute-context.d.ts +4 -0
- package/dist/tools/path-utils.js +16 -2
- package/dist/tools.js +84 -2
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isPathLikeToken, extractScriptTarget, extractPathsFromCommand, validateBashPaths, } from '../tools/bash-path-validation.js';
|
|
3
|
+
describe('isPathLikeToken', () => {
|
|
4
|
+
it('detects absolute paths', () => {
|
|
5
|
+
expect(isPathLikeToken('/etc/passwd')).toBe(true);
|
|
6
|
+
expect(isPathLikeToken('/home/user/file.txt')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
it('detects relative paths', () => {
|
|
9
|
+
expect(isPathLikeToken('./foo')).toBe(true);
|
|
10
|
+
expect(isPathLikeToken('../bar')).toBe(true);
|
|
11
|
+
expect(isPathLikeToken('.')).toBe(true);
|
|
12
|
+
expect(isPathLikeToken('..')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('detects home-relative paths', () => {
|
|
15
|
+
expect(isPathLikeToken('~/Documents')).toBe(true);
|
|
16
|
+
expect(isPathLikeToken('~')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('rejects flags', () => {
|
|
19
|
+
expect(isPathLikeToken('-f')).toBe(false);
|
|
20
|
+
expect(isPathLikeToken('--file')).toBe(false);
|
|
21
|
+
expect(isPathLikeToken('-')).toBe(false);
|
|
22
|
+
expect(isPathLikeToken('--')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it('rejects bare words', () => {
|
|
25
|
+
expect(isPathLikeToken('foo')).toBe(false);
|
|
26
|
+
expect(isPathLikeToken('bar.txt')).toBe(false);
|
|
27
|
+
expect(isPathLikeToken('some-command')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('rejects empty/whitespace', () => {
|
|
30
|
+
expect(isPathLikeToken('')).toBe(false);
|
|
31
|
+
expect(isPathLikeToken(' ')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('extractScriptTarget', () => {
|
|
35
|
+
it('extracts script path from python command', () => {
|
|
36
|
+
expect(extractScriptTarget(['python3', 'script.py'])).toBe('script.py');
|
|
37
|
+
expect(extractScriptTarget(['python3', '-u', 'script.py'])).toBe('script.py');
|
|
38
|
+
expect(extractScriptTarget(['python', '/home/user/run.py'])).toBe('/home/user/run.py');
|
|
39
|
+
});
|
|
40
|
+
it('extracts script path from node command', () => {
|
|
41
|
+
expect(extractScriptTarget(['node', 'app.js'])).toBe('app.js');
|
|
42
|
+
expect(extractScriptTarget(['node', '--experimental-modules', 'app.js'])).toBe('app.js');
|
|
43
|
+
});
|
|
44
|
+
it('extracts script path from ruby/perl', () => {
|
|
45
|
+
expect(extractScriptTarget(['ruby', 'script.rb'])).toBe('script.rb');
|
|
46
|
+
expect(extractScriptTarget(['perl', 'script.pl'])).toBe('script.pl');
|
|
47
|
+
});
|
|
48
|
+
it('extracts script path from shell interpreters', () => {
|
|
49
|
+
expect(extractScriptTarget(['bash', 'script.sh'])).toBe('script.sh');
|
|
50
|
+
expect(extractScriptTarget(['sh', './run.sh'])).toBe('./run.sh');
|
|
51
|
+
});
|
|
52
|
+
it('returns null for inline execution (-c, -e)', () => {
|
|
53
|
+
expect(extractScriptTarget(['python3', '-c', 'print("hi")'])).toBeNull();
|
|
54
|
+
expect(extractScriptTarget(['node', '-e', 'console.log(1)'])).toBeNull();
|
|
55
|
+
expect(extractScriptTarget(['perl', '-e', 'print 1'])).toBeNull();
|
|
56
|
+
expect(extractScriptTarget(['bash', '-c', 'echo hello'])).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('returns null for module execution (-m)', () => {
|
|
59
|
+
expect(extractScriptTarget(['python3', '-m', 'http.server'])).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('returns null for non-interpreter commands', () => {
|
|
62
|
+
expect(extractScriptTarget(['ls', '-la'])).toBeNull();
|
|
63
|
+
expect(extractScriptTarget(['grep', 'pattern', 'file.txt'])).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('handles env var prefix in segment', () => {
|
|
66
|
+
expect(extractScriptTarget(['NODE_ENV=prod', 'node', 'app.js'])).toBe('app.js');
|
|
67
|
+
});
|
|
68
|
+
it('skips flags with values', () => {
|
|
69
|
+
expect(extractScriptTarget(['python3', '-W', 'ignore', 'script.py'])).toBe('script.py');
|
|
70
|
+
});
|
|
71
|
+
it('returns null for no arguments', () => {
|
|
72
|
+
expect(extractScriptTarget(['python3'])).toBeNull();
|
|
73
|
+
expect(extractScriptTarget([])).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('extractPathsFromCommand', () => {
|
|
77
|
+
const cwd = '/home/user/project';
|
|
78
|
+
it('extracts absolute paths from simple commands', () => {
|
|
79
|
+
const paths = extractPathsFromCommand('cat /etc/passwd', cwd);
|
|
80
|
+
expect(paths).toContain('/etc/passwd');
|
|
81
|
+
});
|
|
82
|
+
it('extracts relative paths and resolves them', () => {
|
|
83
|
+
const paths = extractPathsFromCommand('cat ./data/file.txt', cwd);
|
|
84
|
+
expect(paths).toContain('/home/user/project/data/file.txt');
|
|
85
|
+
});
|
|
86
|
+
it('extracts home-relative paths', () => {
|
|
87
|
+
const paths = extractPathsFromCommand('cat ~/secret.txt', cwd);
|
|
88
|
+
expect(paths.length).toBe(1);
|
|
89
|
+
expect(paths[0]).toMatch(/secret\.txt$/);
|
|
90
|
+
});
|
|
91
|
+
it('extracts paths from piped commands', () => {
|
|
92
|
+
const paths = extractPathsFromCommand('cat /etc/hosts | grep /var/log/syslog', cwd);
|
|
93
|
+
expect(paths).toContain('/etc/hosts');
|
|
94
|
+
expect(paths).toContain('/var/log/syslog');
|
|
95
|
+
});
|
|
96
|
+
it('extracts paths from chained commands', () => {
|
|
97
|
+
const paths = extractPathsFromCommand('ls /tmp && cat /etc/passwd', cwd);
|
|
98
|
+
expect(paths).toContain('/tmp');
|
|
99
|
+
expect(paths).toContain('/etc/passwd');
|
|
100
|
+
});
|
|
101
|
+
it('extracts interpreter script targets', () => {
|
|
102
|
+
const paths = extractPathsFromCommand('python3 /opt/scripts/exploit.py', cwd);
|
|
103
|
+
expect(paths).toContain('/opt/scripts/exploit.py');
|
|
104
|
+
});
|
|
105
|
+
it('extracts both script target and path args', () => {
|
|
106
|
+
const paths = extractPathsFromCommand('python3 ./script.py /data/input.csv', cwd);
|
|
107
|
+
expect(paths).toContain('/home/user/project/script.py');
|
|
108
|
+
expect(paths).toContain('/data/input.csv');
|
|
109
|
+
});
|
|
110
|
+
it('deduplicates paths', () => {
|
|
111
|
+
const paths = extractPathsFromCommand('cat /etc/passwd /etc/passwd', cwd);
|
|
112
|
+
expect(paths.length).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
it('returns empty for commands with no path args', () => {
|
|
115
|
+
const paths = extractPathsFromCommand('echo hello world', cwd);
|
|
116
|
+
expect(paths).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
it('returns empty for inline interpreter execution', () => {
|
|
119
|
+
const paths = extractPathsFromCommand('python3 -c "print(1)"', cwd);
|
|
120
|
+
expect(paths).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
it('handles bare words that are not paths', () => {
|
|
123
|
+
const paths = extractPathsFromCommand('git status', cwd);
|
|
124
|
+
expect(paths).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('validateBashPaths', () => {
|
|
128
|
+
// Use /home/user paths to avoid macOS /tmp → /private/tmp symlink issues
|
|
129
|
+
const allowedPaths = ['/home/user/project', '/home/user/data'];
|
|
130
|
+
it('returns null when all paths are allowed', () => {
|
|
131
|
+
expect(validateBashPaths('cat /home/user/project/file.txt', undefined, allowedPaths)).toBeNull();
|
|
132
|
+
expect(validateBashPaths('ls /home/user/data/stuff', undefined, allowedPaths)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
it('returns error when path is outside allowed dirs', () => {
|
|
135
|
+
const result = validateBashPaths('cat /etc/passwd', undefined, allowedPaths);
|
|
136
|
+
expect(result).toContain('Error');
|
|
137
|
+
expect(result).toContain('/etc/passwd');
|
|
138
|
+
});
|
|
139
|
+
it('blocks interpreter commands targeting outside paths', () => {
|
|
140
|
+
const result = validateBashPaths('python3 /opt/evil.py', undefined, allowedPaths);
|
|
141
|
+
expect(result).toContain('Error');
|
|
142
|
+
expect(result).toContain('/opt/evil.py');
|
|
143
|
+
});
|
|
144
|
+
it('allows interpreter commands targeting allowed paths', () => {
|
|
145
|
+
expect(validateBashPaths('python3 /home/user/project/run.py', undefined, allowedPaths)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
it('returns null when no allowedPaths configured (permissive)', () => {
|
|
148
|
+
expect(validateBashPaths('cat /etc/passwd', undefined, [])).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
it('blocks paths in chained commands', () => {
|
|
151
|
+
const result = validateBashPaths('ls /home/user/data && cat /etc/shadow', undefined, allowedPaths);
|
|
152
|
+
expect(result).toContain('Error');
|
|
153
|
+
expect(result).toContain('/etc/shadow');
|
|
154
|
+
});
|
|
155
|
+
it('lists all blocked paths in error', () => {
|
|
156
|
+
const result = validateBashPaths('cat /etc/passwd /var/secret', undefined, allowedPaths);
|
|
157
|
+
expect(result).toContain('/etc/passwd');
|
|
158
|
+
expect(result).toContain('/var/secret');
|
|
159
|
+
});
|
|
160
|
+
it('returns null for commands with no path arguments', () => {
|
|
161
|
+
expect(validateBashPaths('echo hello', undefined, allowedPaths)).toBeNull();
|
|
162
|
+
expect(validateBashPaths('git status', undefined, allowedPaths)).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, } = vi.hoisted(() => ({
|
|
2
|
+
const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable, mockCheckTypeScriptCompile, mockCheckConfigExistsAndValidJson, mockCheckRequiredEnvVars, mockCheckEnvVarPatterns, mockCheckAllowedPathsWritable, mockCheckProviderAuth, mockCheckTelegramToken, mockCheckDiscordToken, mockCheckBrowserBinaryIfEnabled, mockCheckVoiceDependencies, mockCheckMcpConfig, mockCheckGatewayHostBindable, mockCheckSkimpyclawDirWritable, mockCheckPortAvailability, mockCheckSandboxAvailable, } = vi.hoisted(() => ({
|
|
3
3
|
mockLoadConfig: vi.fn(),
|
|
4
4
|
mockCheckNodeVersion: vi.fn(),
|
|
5
5
|
mockCheckPackageManagerAvailable: vi.fn(),
|
|
@@ -17,6 +17,7 @@ const { mockLoadConfig, mockCheckNodeVersion, mockCheckPackageManagerAvailable,
|
|
|
17
17
|
mockCheckGatewayHostBindable: vi.fn(),
|
|
18
18
|
mockCheckSkimpyclawDirWritable: vi.fn(),
|
|
19
19
|
mockCheckPortAvailability: vi.fn(),
|
|
20
|
+
mockCheckSandboxAvailable: vi.fn(),
|
|
20
21
|
}));
|
|
21
22
|
vi.mock('../config.js', () => ({
|
|
22
23
|
loadConfig: mockLoadConfig,
|
|
@@ -38,6 +39,7 @@ vi.mock('../doctor/checks.js', () => ({
|
|
|
38
39
|
checkGatewayHostBindable: mockCheckGatewayHostBindable,
|
|
39
40
|
checkSkimpyclawDirWritable: mockCheckSkimpyclawDirWritable,
|
|
40
41
|
checkPortAvailability: mockCheckPortAvailability,
|
|
42
|
+
checkSandboxAvailable: mockCheckSandboxAvailable,
|
|
41
43
|
}));
|
|
42
44
|
import { computeExitCode, runDoctor } from '../doctor/runner.js';
|
|
43
45
|
function okCheck(name, category, detail = 'ok') {
|
|
@@ -78,6 +80,7 @@ describe('doctor runner', () => {
|
|
|
78
80
|
mockCheckGatewayHostBindable.mockReset();
|
|
79
81
|
mockCheckSkimpyclawDirWritable.mockReset();
|
|
80
82
|
mockCheckPortAvailability.mockReset();
|
|
83
|
+
mockCheckSandboxAvailable.mockReset();
|
|
81
84
|
mockCheckNodeVersion.mockResolvedValue(okCheck('node_version', 'environment', 'v20.11.0'));
|
|
82
85
|
mockCheckPackageManagerAvailable.mockResolvedValue(okCheck('package_manager_available', 'environment', 'pnpm'));
|
|
83
86
|
mockCheckTypeScriptCompile.mockResolvedValue(okCheck('typescript_compile', 'environment'));
|
|
@@ -94,6 +97,7 @@ describe('doctor runner', () => {
|
|
|
94
97
|
mockCheckGatewayHostBindable.mockResolvedValue(okCheck('gateway_host_bindable', 'runtime', '127.0.0.1 (always available)'));
|
|
95
98
|
mockCheckSkimpyclawDirWritable.mockResolvedValue(okCheck('skimpyclaw_dirs_writable', 'runtime'));
|
|
96
99
|
mockCheckPortAvailability.mockResolvedValue(okCheck('gateway_port_available', 'runtime'));
|
|
100
|
+
mockCheckSandboxAvailable.mockResolvedValue(okCheck('sandbox_available', 'runtime', 'Sandbox disabled'));
|
|
97
101
|
});
|
|
98
102
|
it('computes exit code 0 when all checks pass', () => {
|
|
99
103
|
const code = computeExitCode({ checks: [okCheck('node_version', 'environment')] });
|
|
@@ -21,11 +21,11 @@ describe('heartbeat prompt path normalization', () => {
|
|
|
21
21
|
agents: { default: 'main' },
|
|
22
22
|
heartbeat: {
|
|
23
23
|
intervalMs: 60000,
|
|
24
|
-
prompt: 'Read /Users/
|
|
24
|
+
prompt: 'Read /Users/example/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
|
|
25
25
|
model: 'claude-fast',
|
|
26
26
|
tools: {
|
|
27
27
|
enabled: true,
|
|
28
|
-
allowedPaths: ['/Users/
|
|
28
|
+
allowedPaths: ['/Users/example/.skimpyclaw'],
|
|
29
29
|
maxIterations: 10,
|
|
30
30
|
bashTimeout: 15000,
|
|
31
31
|
},
|
|
@@ -33,7 +33,7 @@ describe('heartbeat prompt path normalization', () => {
|
|
|
33
33
|
channels: {
|
|
34
34
|
active: 'telegram',
|
|
35
35
|
telegram: {
|
|
36
|
-
defaultAllowedPaths: ['/Users/
|
|
36
|
+
defaultAllowedPaths: ['/Users/example/.skimpyclaw'],
|
|
37
37
|
},
|
|
38
38
|
},
|
|
39
39
|
};
|
|
@@ -50,7 +50,7 @@ describe('heartbeat prompt path normalization', () => {
|
|
|
50
50
|
model: 'claude-fast',
|
|
51
51
|
tools: {
|
|
52
52
|
enabled: true,
|
|
53
|
-
allowedPaths: ['/Users/
|
|
53
|
+
allowedPaths: ['/Users/example/.skimpyclaw'],
|
|
54
54
|
maxIterations: 10,
|
|
55
55
|
bashTimeout: 15000,
|
|
56
56
|
},
|
|
@@ -58,7 +58,7 @@ describe('heartbeat prompt path normalization', () => {
|
|
|
58
58
|
channels: {
|
|
59
59
|
active: 'telegram',
|
|
60
60
|
telegram: {
|
|
61
|
-
defaultAllowedPaths: ['/Users/
|
|
61
|
+
defaultAllowedPaths: ['/Users/example/.skimpyclaw'],
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
64
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const { mockExecInContainer } = vi.hoisted(() => ({
|
|
3
|
+
mockExecInContainer: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('../sandbox/runtime.js', () => ({
|
|
6
|
+
execInContainer: mockExecInContainer,
|
|
7
|
+
}));
|
|
8
|
+
import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob, } from '../sandbox/bridge.js';
|
|
9
|
+
describe('sandbox/bridge', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
describe('sandboxBash', () => {
|
|
14
|
+
it('passes command through', async () => {
|
|
15
|
+
mockExecInContainer.mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 });
|
|
16
|
+
const result = await sandboxBash('ctr', 'echo hi');
|
|
17
|
+
expect(result).toBe('ok');
|
|
18
|
+
expect(mockExecInContainer).toHaveBeenCalledWith('ctr', ['echo hi'], expect.any(Object));
|
|
19
|
+
});
|
|
20
|
+
it('handles cwd', async () => {
|
|
21
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
22
|
+
await sandboxBash('ctr', 'ls', '/workspace');
|
|
23
|
+
const args = mockExecInContainer.mock.calls[0][1][0];
|
|
24
|
+
expect(args).toContain('cd');
|
|
25
|
+
expect(args).toContain('/workspace');
|
|
26
|
+
});
|
|
27
|
+
it('handles timeout', async () => {
|
|
28
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
29
|
+
await sandboxBash('ctr', 'sleep 1', undefined, 5000);
|
|
30
|
+
const opts = mockExecInContainer.mock.calls[0][2];
|
|
31
|
+
expect(opts.timeout).toBe(5000);
|
|
32
|
+
});
|
|
33
|
+
it('truncates long output', async () => {
|
|
34
|
+
const longOutput = 'x'.repeat(60 * 1024);
|
|
35
|
+
mockExecInContainer.mockResolvedValue({ stdout: longOutput, stderr: '', exitCode: 0 });
|
|
36
|
+
const result = await sandboxBash('ctr', 'cmd');
|
|
37
|
+
expect(result.length).toBeLessThan(longOutput.length);
|
|
38
|
+
expect(result).toContain('truncated');
|
|
39
|
+
});
|
|
40
|
+
it('includes exit code on failure', async () => {
|
|
41
|
+
mockExecInContainer.mockResolvedValue({ stdout: 'out', stderr: 'err', exitCode: 42 });
|
|
42
|
+
const result = await sandboxBash('ctr', 'bad');
|
|
43
|
+
expect(result).toContain('[exit code: 42]');
|
|
44
|
+
});
|
|
45
|
+
it('returns (no output) when empty', async () => {
|
|
46
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
47
|
+
const result = await sandboxBash('ctr', 'true');
|
|
48
|
+
expect(result).toBe('(no output)');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('sandboxReadFile', () => {
|
|
52
|
+
it('calls cat with path', async () => {
|
|
53
|
+
mockExecInContainer.mockResolvedValue({ stdout: 'content', stderr: '', exitCode: 0 });
|
|
54
|
+
const result = await sandboxReadFile('ctr', '/workspace/file.txt');
|
|
55
|
+
expect(result).toBe('content');
|
|
56
|
+
expect(mockExecInContainer.mock.calls[0][1][0]).toContain('cat');
|
|
57
|
+
});
|
|
58
|
+
it('truncates large files', async () => {
|
|
59
|
+
const big = 'y'.repeat(120 * 1024);
|
|
60
|
+
mockExecInContainer.mockResolvedValue({ stdout: big, stderr: '', exitCode: 0 });
|
|
61
|
+
const result = await sandboxReadFile('ctr', '/f');
|
|
62
|
+
expect(result.length).toBeLessThan(big.length);
|
|
63
|
+
expect(result).toContain('truncated');
|
|
64
|
+
});
|
|
65
|
+
it('throws on failure', async () => {
|
|
66
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'not found', exitCode: 1 });
|
|
67
|
+
await expect(sandboxReadFile('ctr', '/missing')).rejects.toThrow('Failed to read');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('sandboxWriteFile', () => {
|
|
71
|
+
it('sends content via stdin', async () => {
|
|
72
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
73
|
+
const result = await sandboxWriteFile('ctr', '/workspace/f.txt', 'hello');
|
|
74
|
+
expect(result).toContain('Written');
|
|
75
|
+
expect(result).toContain('5 bytes');
|
|
76
|
+
const opts = mockExecInContainer.mock.calls[0][2];
|
|
77
|
+
expect(opts.stdin).toBe('hello');
|
|
78
|
+
});
|
|
79
|
+
it('creates parent dirs', async () => {
|
|
80
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
81
|
+
await sandboxWriteFile('ctr', '/workspace/a/b/c.txt', 'data');
|
|
82
|
+
expect(mockExecInContainer.mock.calls[0][1][0]).toContain('mkdir -p');
|
|
83
|
+
});
|
|
84
|
+
it('throws on failure', async () => {
|
|
85
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'perm denied', exitCode: 1 });
|
|
86
|
+
await expect(sandboxWriteFile('ctr', '/f', 'x')).rejects.toThrow('Failed to write');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('sandboxListDir', () => {
|
|
90
|
+
it('calls ls -la', async () => {
|
|
91
|
+
mockExecInContainer.mockResolvedValue({ stdout: 'drwxr-xr-x ...', stderr: '', exitCode: 0 });
|
|
92
|
+
const result = await sandboxListDir('ctr', '/workspace');
|
|
93
|
+
expect(result).toBe('drwxr-xr-x ...');
|
|
94
|
+
expect(mockExecInContainer.mock.calls[0][1][0]).toContain('ls -la');
|
|
95
|
+
});
|
|
96
|
+
it('throws on failure', async () => {
|
|
97
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'no such dir', exitCode: 1 });
|
|
98
|
+
await expect(sandboxListDir('ctr', '/bad')).rejects.toThrow('Failed to list');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('sandboxGlob', () => {
|
|
102
|
+
it('calls find with correct args', async () => {
|
|
103
|
+
mockExecInContainer.mockResolvedValue({ stdout: '/workspace/a.ts\n', stderr: '', exitCode: 0 });
|
|
104
|
+
const result = await sandboxGlob('ctr', '/workspace', '*.ts');
|
|
105
|
+
expect(result).toContain('/workspace/a.ts');
|
|
106
|
+
const cmd = mockExecInContainer.mock.calls[0][1][0];
|
|
107
|
+
expect(cmd).toContain('find');
|
|
108
|
+
expect(cmd).toContain('-name');
|
|
109
|
+
expect(cmd).toContain('-maxdepth');
|
|
110
|
+
});
|
|
111
|
+
it('throws on failure', async () => {
|
|
112
|
+
mockExecInContainer.mockResolvedValue({ stdout: '', stderr: 'err', exitCode: 1 });
|
|
113
|
+
await expect(sandboxGlob('ctr', '/x', '*.js')).rejects.toThrow('Failed to glob');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const { mockCreateContainer, mockRemoveContainer, mockIsContainerRunning, mockValidateMountPaths, } = vi.hoisted(() => ({
|
|
3
|
+
mockCreateContainer: vi.fn(),
|
|
4
|
+
mockRemoveContainer: vi.fn(),
|
|
5
|
+
mockIsContainerRunning: vi.fn(),
|
|
6
|
+
mockValidateMountPaths: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../sandbox/runtime.js', () => ({
|
|
9
|
+
createContainer: mockCreateContainer,
|
|
10
|
+
removeContainer: mockRemoveContainer,
|
|
11
|
+
isContainerRunning: mockIsContainerRunning,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../sandbox/mount-security.js', () => ({
|
|
14
|
+
validateMountPaths: mockValidateMountPaths,
|
|
15
|
+
}));
|
|
16
|
+
import { ensureContainer, releaseContainer, pruneIdle, releaseAll, resetForTesting, SANDBOX_DEFAULTS, } from '../sandbox/manager.js';
|
|
17
|
+
const testConfig = { ...SANDBOX_DEFAULTS, enabled: true };
|
|
18
|
+
describe('sandbox/manager', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
resetForTesting();
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
mockValidateMountPaths.mockReturnValue([
|
|
23
|
+
{ host: '/home/user/project', container: '/workspace/project', readOnly: false },
|
|
24
|
+
]);
|
|
25
|
+
mockCreateContainer.mockResolvedValue(undefined);
|
|
26
|
+
mockRemoveContainer.mockResolvedValue(undefined);
|
|
27
|
+
});
|
|
28
|
+
describe('ensureContainer', () => {
|
|
29
|
+
it('creates container on first call', async () => {
|
|
30
|
+
const name = await ensureContainer('sess1', testConfig, ['/home/user/project']);
|
|
31
|
+
expect(name).toBe('skimpyclaw-sbx-sess1');
|
|
32
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
it('reuses on second call if running', async () => {
|
|
35
|
+
mockIsContainerRunning
|
|
36
|
+
.mockResolvedValueOnce(false)
|
|
37
|
+
.mockResolvedValueOnce(true);
|
|
38
|
+
await ensureContainer('sess1', testConfig, ['/p']);
|
|
39
|
+
const name = await ensureContainer('sess1', testConfig, ['/p']);
|
|
40
|
+
expect(name).toBe('skimpyclaw-sbx-sess1');
|
|
41
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(1); // only first call
|
|
42
|
+
});
|
|
43
|
+
it('recreates if container died', async () => {
|
|
44
|
+
mockIsContainerRunning.mockResolvedValue(false);
|
|
45
|
+
await ensureContainer('sess1', testConfig, ['/p']);
|
|
46
|
+
// Second call — container exists in map but isContainerRunning returns false
|
|
47
|
+
await ensureContainer('sess1', testConfig, ['/p']);
|
|
48
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(2);
|
|
49
|
+
});
|
|
50
|
+
it('adopts existing running container after process restart', async () => {
|
|
51
|
+
mockIsContainerRunning.mockResolvedValue(true);
|
|
52
|
+
const name = await ensureContainer('default', testConfig, ['/p']);
|
|
53
|
+
expect(name).toBe('skimpyclaw-sbx-default');
|
|
54
|
+
expect(mockCreateContainer).not.toHaveBeenCalled();
|
|
55
|
+
expect(mockRemoveContainer).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
it('removes stale named container before creating', async () => {
|
|
58
|
+
mockIsContainerRunning.mockResolvedValue(false);
|
|
59
|
+
const name = await ensureContainer('default', testConfig, ['/p']);
|
|
60
|
+
expect(name).toBe('skimpyclaw-sbx-default');
|
|
61
|
+
expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-default');
|
|
62
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('releaseContainer', () => {
|
|
66
|
+
it('removes container and clears from map', async () => {
|
|
67
|
+
await ensureContainer('sess1', testConfig, ['/p']);
|
|
68
|
+
await releaseContainer('sess1');
|
|
69
|
+
expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-sess1');
|
|
70
|
+
// Next ensureContainer should create fresh
|
|
71
|
+
await ensureContainer('sess1', testConfig, ['/p']);
|
|
72
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(2);
|
|
73
|
+
});
|
|
74
|
+
it('no-ops for unknown session', async () => {
|
|
75
|
+
await expect(releaseContainer('unknown')).resolves.toBeUndefined();
|
|
76
|
+
expect(mockRemoveContainer).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('pruneIdle', () => {
|
|
80
|
+
it('removes containers older than threshold', async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
try {
|
|
83
|
+
await ensureContainer('old', testConfig, ['/p']);
|
|
84
|
+
// Advance time by 10 seconds so the container is idle
|
|
85
|
+
vi.advanceTimersByTime(10_000);
|
|
86
|
+
const pruned = await pruneIdle(5_000);
|
|
87
|
+
expect(pruned).toBe(1);
|
|
88
|
+
expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-old');
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it('keeps recent containers', async () => {
|
|
95
|
+
await ensureContainer('new', testConfig, ['/p']);
|
|
96
|
+
const pruned = await pruneIdle(60_000);
|
|
97
|
+
expect(pruned).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('releaseAll', () => {
|
|
101
|
+
it('removes all containers', async () => {
|
|
102
|
+
mockIsContainerRunning.mockResolvedValue(true);
|
|
103
|
+
await ensureContainer('a', testConfig, ['/p']);
|
|
104
|
+
await ensureContainer('b', testConfig, ['/p']);
|
|
105
|
+
await releaseAll();
|
|
106
|
+
expect(mockRemoveContainer).toHaveBeenCalledTimes(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('resetForTesting', () => {
|
|
110
|
+
it('clears state without removing containers', async () => {
|
|
111
|
+
mockIsContainerRunning.mockResolvedValue(false);
|
|
112
|
+
await ensureContainer('x', testConfig, ['/p']);
|
|
113
|
+
resetForTesting();
|
|
114
|
+
// Next call should create fresh (map is empty)
|
|
115
|
+
await ensureContainer('x', testConfig, ['/p']);
|
|
116
|
+
expect(mockCreateContainer).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|