@cloudflare/sandbox 0.5.1 → 0.5.3
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/.turbo/turbo-build.log +17 -9
- package/CHANGELOG.md +18 -0
- package/dist/dist-gVyG2H2h.js +612 -0
- package/dist/dist-gVyG2H2h.js.map +1 -0
- package/dist/index.d.ts +14 -1720
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +82 -698
- package/dist/index.js.map +1 -1
- package/dist/openai/index.d.ts +67 -0
- package/dist/openai/index.d.ts.map +1 -0
- package/dist/openai/index.js +362 -0
- package/dist/openai/index.js.map +1 -0
- package/dist/sandbox-HQazw9bn.d.ts +1741 -0
- package/dist/sandbox-HQazw9bn.d.ts.map +1 -0
- package/package.json +15 -1
- package/src/clients/command-client.ts +31 -13
- package/src/clients/process-client.ts +20 -2
- package/src/openai/index.ts +465 -0
- package/src/sandbox.ts +103 -47
- package/src/version.ts +1 -1
- package/tests/git-client.test.ts +7 -39
- package/tests/openai-shell-editor.test.ts +434 -0
- package/tests/port-client.test.ts +25 -35
- package/tests/process-client.test.ts +73 -107
- package/tests/sandbox.test.ts +65 -35
- package/tsconfig.json +2 -2
- package/tsdown.config.ts +1 -1
package/tests/git-client.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import type { GitCheckoutResult } from '@repo/shared';
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import type { GitCheckoutResponse } from '../src/clients';
|
|
3
3
|
import { GitClient } from '../src/clients/git-client';
|
|
4
4
|
import {
|
|
5
5
|
GitAuthenticationError,
|
|
@@ -35,12 +35,8 @@ describe('GitClient', () => {
|
|
|
35
35
|
|
|
36
36
|
describe('repository cloning', () => {
|
|
37
37
|
it('should clone public repositories successfully', async () => {
|
|
38
|
-
const mockResponse:
|
|
38
|
+
const mockResponse: GitCheckoutResult = {
|
|
39
39
|
success: true,
|
|
40
|
-
stdout:
|
|
41
|
-
"Cloning into 'react'...\nReceiving objects: 100% (1284/1284), done.",
|
|
42
|
-
stderr: '',
|
|
43
|
-
exitCode: 0,
|
|
44
40
|
repoUrl: 'https://github.com/facebook/react.git',
|
|
45
41
|
branch: 'main',
|
|
46
42
|
targetDir: 'react',
|
|
@@ -59,15 +55,11 @@ describe('GitClient', () => {
|
|
|
59
55
|
expect(result.success).toBe(true);
|
|
60
56
|
expect(result.repoUrl).toBe('https://github.com/facebook/react.git');
|
|
61
57
|
expect(result.branch).toBe('main');
|
|
62
|
-
expect(result.exitCode).toBe(0);
|
|
63
58
|
});
|
|
64
59
|
|
|
65
60
|
it('should clone repositories to specific branches', async () => {
|
|
66
|
-
const mockResponse:
|
|
61
|
+
const mockResponse: GitCheckoutResult = {
|
|
67
62
|
success: true,
|
|
68
|
-
stdout: "Cloning into 'project'...\nSwitching to branch 'development'",
|
|
69
|
-
stderr: '',
|
|
70
|
-
exitCode: 0,
|
|
71
63
|
repoUrl: 'https://github.com/company/project.git',
|
|
72
64
|
branch: 'development',
|
|
73
65
|
targetDir: 'project',
|
|
@@ -89,11 +81,8 @@ describe('GitClient', () => {
|
|
|
89
81
|
});
|
|
90
82
|
|
|
91
83
|
it('should clone repositories to custom directories', async () => {
|
|
92
|
-
const mockResponse:
|
|
84
|
+
const mockResponse: GitCheckoutResult = {
|
|
93
85
|
success: true,
|
|
94
|
-
stdout: "Cloning into 'workspace/my-app'...\nDone.",
|
|
95
|
-
stderr: '',
|
|
96
|
-
exitCode: 0,
|
|
97
86
|
repoUrl: 'https://github.com/user/my-app.git',
|
|
98
87
|
branch: 'main',
|
|
99
88
|
targetDir: 'workspace/my-app',
|
|
@@ -115,12 +104,8 @@ describe('GitClient', () => {
|
|
|
115
104
|
});
|
|
116
105
|
|
|
117
106
|
it('should handle large repository clones with warnings', async () => {
|
|
118
|
-
const mockResponse:
|
|
107
|
+
const mockResponse: GitCheckoutResult = {
|
|
119
108
|
success: true,
|
|
120
|
-
stdout:
|
|
121
|
-
"Cloning into 'linux'...\nReceiving objects: 100% (8125432/8125432), 2.34 GiB, done.",
|
|
122
|
-
stderr: 'warning: filtering not recognized by server',
|
|
123
|
-
exitCode: 0,
|
|
124
109
|
repoUrl: 'https://github.com/torvalds/linux.git',
|
|
125
110
|
branch: 'master',
|
|
126
111
|
targetDir: 'linux',
|
|
@@ -137,15 +122,11 @@ describe('GitClient', () => {
|
|
|
137
122
|
);
|
|
138
123
|
|
|
139
124
|
expect(result.success).toBe(true);
|
|
140
|
-
expect(result.stderr).toContain('warning:');
|
|
141
125
|
});
|
|
142
126
|
|
|
143
127
|
it('should handle SSH repository URLs', async () => {
|
|
144
|
-
const mockResponse:
|
|
128
|
+
const mockResponse: GitCheckoutResult = {
|
|
145
129
|
success: true,
|
|
146
|
-
stdout: "Cloning into 'private-project'...\nDone.",
|
|
147
|
-
stderr: '',
|
|
148
|
-
exitCode: 0,
|
|
149
130
|
repoUrl: 'git@github.com:company/private-project.git',
|
|
150
131
|
branch: 'main',
|
|
151
132
|
targetDir: 'private-project',
|
|
@@ -175,8 +156,6 @@ describe('GitClient', () => {
|
|
|
175
156
|
JSON.stringify({
|
|
176
157
|
success: true,
|
|
177
158
|
stdout: `Cloning into '${repoName}'...\nDone.`,
|
|
178
|
-
stderr: '',
|
|
179
|
-
exitCode: 0,
|
|
180
159
|
repoUrl: body.repoUrl,
|
|
181
160
|
branch: body.branch || 'main',
|
|
182
161
|
targetDir: body.targetDir || repoName,
|
|
@@ -320,11 +299,8 @@ describe('GitClient', () => {
|
|
|
320
299
|
});
|
|
321
300
|
|
|
322
301
|
it('should handle partial clone failures', async () => {
|
|
323
|
-
const mockResponse:
|
|
302
|
+
const mockResponse: GitCheckoutResult = {
|
|
324
303
|
success: false,
|
|
325
|
-
stdout: "Cloning into 'repo'...\nReceiving objects: 45% (450/1000)",
|
|
326
|
-
stderr: 'error: RPC failed\nfatal: early EOF',
|
|
327
|
-
exitCode: 128,
|
|
328
304
|
repoUrl: 'https://github.com/problematic/repo.git',
|
|
329
305
|
branch: 'main',
|
|
330
306
|
targetDir: 'repo',
|
|
@@ -341,8 +317,6 @@ describe('GitClient', () => {
|
|
|
341
317
|
);
|
|
342
318
|
|
|
343
319
|
expect(result.success).toBe(false);
|
|
344
|
-
expect(result.exitCode).toBe(128);
|
|
345
|
-
expect(result.stderr).toContain('RPC failed');
|
|
346
320
|
});
|
|
347
321
|
});
|
|
348
322
|
|
|
@@ -434,9 +408,6 @@ describe('GitClient', () => {
|
|
|
434
408
|
new Response(
|
|
435
409
|
JSON.stringify({
|
|
436
410
|
success: true,
|
|
437
|
-
stdout: "Cloning into 'private-repo'...\nDone.",
|
|
438
|
-
stderr: '',
|
|
439
|
-
exitCode: 0,
|
|
440
411
|
repoUrl:
|
|
441
412
|
'https://oauth2:ghp_token123@github.com/user/private-repo.git',
|
|
442
413
|
branch: 'main',
|
|
@@ -463,9 +434,6 @@ describe('GitClient', () => {
|
|
|
463
434
|
new Response(
|
|
464
435
|
JSON.stringify({
|
|
465
436
|
success: true,
|
|
466
|
-
stdout: "Cloning into 'react'...\nDone.",
|
|
467
|
-
stderr: '',
|
|
468
|
-
exitCode: 0,
|
|
469
437
|
repoUrl: 'https://github.com/facebook/react.git',
|
|
470
438
|
branch: 'main',
|
|
471
439
|
targetDir: '/workspace/react',
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import type { ApplyPatchOperation } from '@openai/agents';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Editor, Shell } from '../src/openai/index';
|
|
4
|
+
import type { Sandbox } from '../src/sandbox';
|
|
5
|
+
|
|
6
|
+
interface MockSandbox {
|
|
7
|
+
exec?: ReturnType<typeof vi.fn>;
|
|
8
|
+
mkdir?: ReturnType<typeof vi.fn>;
|
|
9
|
+
writeFile?: ReturnType<typeof vi.fn>;
|
|
10
|
+
readFile?: ReturnType<typeof vi.fn>;
|
|
11
|
+
deleteFile?: ReturnType<typeof vi.fn>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { loggerSpies, createLoggerMock, applyDiffMock } = vi.hoisted(() => {
|
|
15
|
+
const logger = {
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
child: vi.fn().mockReturnThis()
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
loggerSpies: logger,
|
|
25
|
+
createLoggerMock: vi.fn(() => logger),
|
|
26
|
+
applyDiffMock: vi.fn<(...args: any[]) => string>()
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock('@repo/shared', () => ({
|
|
31
|
+
createLogger: createLoggerMock
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('@openai/agents', () => ({
|
|
35
|
+
applyDiff: applyDiffMock
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('Shell', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('runs commands and collects results', async () => {
|
|
44
|
+
const execMock = vi.fn().mockResolvedValue({
|
|
45
|
+
stdout: 'hello\n',
|
|
46
|
+
stderr: '',
|
|
47
|
+
exitCode: 0
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const mockSandbox: MockSandbox = { exec: execMock };
|
|
51
|
+
const shell = new Shell(mockSandbox as unknown as Sandbox);
|
|
52
|
+
|
|
53
|
+
const result = await shell.run({
|
|
54
|
+
commands: ['echo hello'],
|
|
55
|
+
timeoutMs: 500
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(execMock).toHaveBeenCalledWith('echo hello', {
|
|
59
|
+
timeout: 500,
|
|
60
|
+
cwd: '/workspace'
|
|
61
|
+
});
|
|
62
|
+
expect(result.output).toHaveLength(1);
|
|
63
|
+
expect(result.output[0]).toMatchObject({
|
|
64
|
+
command: 'echo hello',
|
|
65
|
+
stdout: 'hello\n',
|
|
66
|
+
stderr: '',
|
|
67
|
+
outcome: { type: 'exit', exitCode: 0 }
|
|
68
|
+
});
|
|
69
|
+
expect(shell.results).toHaveLength(1);
|
|
70
|
+
expect(loggerSpies.info).toHaveBeenCalledWith(
|
|
71
|
+
'Command completed successfully',
|
|
72
|
+
{ command: 'echo hello' }
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('halts subsequent commands after a timeout error', async () => {
|
|
77
|
+
const timeoutError = new Error('Command timed out');
|
|
78
|
+
const execMock = vi.fn().mockRejectedValue(timeoutError);
|
|
79
|
+
|
|
80
|
+
const mockSandbox: MockSandbox = { exec: execMock };
|
|
81
|
+
const shell = new Shell(mockSandbox as unknown as Sandbox);
|
|
82
|
+
const action = {
|
|
83
|
+
commands: ['sleep 1', 'echo never'],
|
|
84
|
+
timeoutMs: 25
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = await shell.run(action);
|
|
88
|
+
|
|
89
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(result.output[0].outcome).toEqual({ type: 'timeout' });
|
|
91
|
+
expect(shell.results[0].exitCode).toBeNull();
|
|
92
|
+
expect(loggerSpies.warn).toHaveBeenCalledWith(
|
|
93
|
+
'Breaking command loop due to timeout'
|
|
94
|
+
);
|
|
95
|
+
expect(loggerSpies.error).toHaveBeenCalledWith(
|
|
96
|
+
'Command timed out',
|
|
97
|
+
undefined,
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
command: 'sleep 1',
|
|
100
|
+
timeout: 25
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Editor', () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
applyDiffMock.mockReset();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('creates files using applyDiff output', async () => {
|
|
113
|
+
applyDiffMock.mockReturnValueOnce('file contents');
|
|
114
|
+
|
|
115
|
+
const mockSandbox: MockSandbox = {
|
|
116
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
117
|
+
writeFile: vi.fn().mockResolvedValue(undefined)
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
121
|
+
const operation = {
|
|
122
|
+
type: 'create_file',
|
|
123
|
+
path: 'src/app.ts',
|
|
124
|
+
diff: '--- diff ---'
|
|
125
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
126
|
+
|
|
127
|
+
await editor.createFile(operation);
|
|
128
|
+
|
|
129
|
+
expect(applyDiffMock).toHaveBeenCalledWith('', operation.diff, 'create');
|
|
130
|
+
expect(mockSandbox.mkdir).toHaveBeenCalledWith('/workspace/src', {
|
|
131
|
+
recursive: true
|
|
132
|
+
});
|
|
133
|
+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
|
|
134
|
+
'/workspace/src/app.ts',
|
|
135
|
+
'file contents',
|
|
136
|
+
{ encoding: 'utf-8' }
|
|
137
|
+
);
|
|
138
|
+
expect(editor.results[0]).toMatchObject({
|
|
139
|
+
operation: 'create',
|
|
140
|
+
path: 'src/app.ts',
|
|
141
|
+
status: 'completed'
|
|
142
|
+
});
|
|
143
|
+
expect(loggerSpies.info).toHaveBeenCalledWith(
|
|
144
|
+
'File created successfully',
|
|
145
|
+
expect.objectContaining({ path: 'src/app.ts' })
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('applies diffs when updating existing files', async () => {
|
|
150
|
+
applyDiffMock.mockReturnValueOnce('patched content');
|
|
151
|
+
|
|
152
|
+
const mockSandbox: MockSandbox = {
|
|
153
|
+
readFile: vi.fn().mockResolvedValue({ content: 'original content' }),
|
|
154
|
+
writeFile: vi.fn().mockResolvedValue(undefined)
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
158
|
+
const operation = {
|
|
159
|
+
type: 'update_file',
|
|
160
|
+
path: 'README.md',
|
|
161
|
+
diff: 'patch diff'
|
|
162
|
+
} as Extract<ApplyPatchOperation, { type: 'update_file' }>;
|
|
163
|
+
|
|
164
|
+
await editor.updateFile(operation);
|
|
165
|
+
|
|
166
|
+
expect(mockSandbox.readFile).toHaveBeenCalledWith('/workspace/README.md', {
|
|
167
|
+
encoding: 'utf-8'
|
|
168
|
+
});
|
|
169
|
+
expect(applyDiffMock).toHaveBeenCalledWith(
|
|
170
|
+
'original content',
|
|
171
|
+
operation.diff
|
|
172
|
+
);
|
|
173
|
+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
|
|
174
|
+
'/workspace/README.md',
|
|
175
|
+
'patched content',
|
|
176
|
+
{ encoding: 'utf-8' }
|
|
177
|
+
);
|
|
178
|
+
expect(editor.results[0]).toMatchObject({
|
|
179
|
+
operation: 'update',
|
|
180
|
+
path: 'README.md',
|
|
181
|
+
status: 'completed'
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('throws descriptive error when attempting to update a missing file', async () => {
|
|
186
|
+
const missingError = Object.assign(new Error('not found'), { status: 404 });
|
|
187
|
+
const mockSandbox: MockSandbox = {
|
|
188
|
+
readFile: vi.fn().mockRejectedValue(missingError)
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
192
|
+
const operation = {
|
|
193
|
+
type: 'update_file',
|
|
194
|
+
path: 'missing.txt',
|
|
195
|
+
diff: 'patch diff'
|
|
196
|
+
} as Extract<ApplyPatchOperation, { type: 'update_file' }>;
|
|
197
|
+
|
|
198
|
+
await expect(editor.updateFile(operation)).rejects.toThrow(
|
|
199
|
+
'Cannot update missing file: missing.txt'
|
|
200
|
+
);
|
|
201
|
+
expect(loggerSpies.error).toHaveBeenCalledWith(
|
|
202
|
+
'Cannot update missing file',
|
|
203
|
+
undefined,
|
|
204
|
+
{ path: 'missing.txt' }
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('Path traversal security', () => {
|
|
209
|
+
it('should reject path traversal attempts with ../', async () => {
|
|
210
|
+
const mockSandbox: MockSandbox = {
|
|
211
|
+
mkdir: vi.fn(),
|
|
212
|
+
writeFile: vi.fn()
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
216
|
+
const operation = {
|
|
217
|
+
type: 'create_file',
|
|
218
|
+
path: '../etc/passwd',
|
|
219
|
+
diff: 'malicious content'
|
|
220
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
221
|
+
|
|
222
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
223
|
+
'Operation outside workspace: ../etc/passwd'
|
|
224
|
+
);
|
|
225
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should reject path traversal attempts with ../../', async () => {
|
|
229
|
+
const mockSandbox: MockSandbox = {
|
|
230
|
+
mkdir: vi.fn(),
|
|
231
|
+
writeFile: vi.fn()
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
235
|
+
const operation = {
|
|
236
|
+
type: 'create_file',
|
|
237
|
+
path: '../../etc/passwd',
|
|
238
|
+
diff: 'malicious content'
|
|
239
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
240
|
+
|
|
241
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
242
|
+
'Operation outside workspace: ../../etc/passwd'
|
|
243
|
+
);
|
|
244
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should reject path traversal attempts with mixed paths like src/../../etc/passwd', async () => {
|
|
248
|
+
const mockSandbox: MockSandbox = {
|
|
249
|
+
mkdir: vi.fn(),
|
|
250
|
+
writeFile: vi.fn()
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
254
|
+
const operation = {
|
|
255
|
+
type: 'create_file',
|
|
256
|
+
path: 'src/../../etc/passwd',
|
|
257
|
+
diff: 'malicious content'
|
|
258
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
259
|
+
|
|
260
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
261
|
+
'Operation outside workspace: src/../../etc/passwd'
|
|
262
|
+
);
|
|
263
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should reject path traversal attempts with leading slash /../../etc/passwd', async () => {
|
|
267
|
+
const mockSandbox: MockSandbox = {
|
|
268
|
+
mkdir: vi.fn(),
|
|
269
|
+
writeFile: vi.fn()
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
273
|
+
const operation = {
|
|
274
|
+
type: 'create_file',
|
|
275
|
+
path: '/../../etc/passwd',
|
|
276
|
+
diff: 'malicious content'
|
|
277
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
278
|
+
|
|
279
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
280
|
+
'Operation outside workspace: /../../etc/passwd'
|
|
281
|
+
);
|
|
282
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should reject path traversal attempts with leading dot-slash ./../../etc/passwd', async () => {
|
|
286
|
+
const mockSandbox: MockSandbox = {
|
|
287
|
+
mkdir: vi.fn(),
|
|
288
|
+
writeFile: vi.fn()
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
292
|
+
const operation = {
|
|
293
|
+
type: 'create_file',
|
|
294
|
+
path: './../../etc/passwd',
|
|
295
|
+
diff: 'malicious content'
|
|
296
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
297
|
+
|
|
298
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
299
|
+
'Operation outside workspace: ./../../etc/passwd'
|
|
300
|
+
);
|
|
301
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should reject path traversal in updateFile operations', async () => {
|
|
305
|
+
const mockSandbox: MockSandbox = {
|
|
306
|
+
readFile: vi.fn()
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
310
|
+
const operation = {
|
|
311
|
+
type: 'update_file',
|
|
312
|
+
path: '../../etc/passwd',
|
|
313
|
+
diff: 'patch diff'
|
|
314
|
+
} as Extract<ApplyPatchOperation, { type: 'update_file' }>;
|
|
315
|
+
|
|
316
|
+
await expect(editor.updateFile(operation)).rejects.toThrow(
|
|
317
|
+
'Operation outside workspace: ../../etc/passwd'
|
|
318
|
+
);
|
|
319
|
+
expect(mockSandbox.readFile).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should reject path traversal in deleteFile operations', async () => {
|
|
323
|
+
const mockSandbox: MockSandbox = {
|
|
324
|
+
deleteFile: vi.fn()
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
328
|
+
const operation = {
|
|
329
|
+
type: 'delete_file',
|
|
330
|
+
path: '../../etc/passwd'
|
|
331
|
+
} as Extract<ApplyPatchOperation, { type: 'delete_file' }>;
|
|
332
|
+
|
|
333
|
+
await expect(editor.deleteFile(operation)).rejects.toThrow(
|
|
334
|
+
'Operation outside workspace: ../../etc/passwd'
|
|
335
|
+
);
|
|
336
|
+
expect(mockSandbox.deleteFile).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should allow valid paths that use .. but stay within workspace', async () => {
|
|
340
|
+
applyDiffMock.mockReturnValueOnce('file contents');
|
|
341
|
+
|
|
342
|
+
const mockSandbox: MockSandbox = {
|
|
343
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
344
|
+
writeFile: vi.fn().mockResolvedValue(undefined)
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
348
|
+
const operation = {
|
|
349
|
+
type: 'create_file',
|
|
350
|
+
path: 'src/subdir/../../file.txt',
|
|
351
|
+
diff: '--- diff ---'
|
|
352
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
353
|
+
|
|
354
|
+
await editor.createFile(operation);
|
|
355
|
+
|
|
356
|
+
// Should resolve to /workspace/file.txt
|
|
357
|
+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
|
|
358
|
+
'/workspace/file.txt',
|
|
359
|
+
'file contents',
|
|
360
|
+
{ encoding: 'utf-8' }
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should handle paths with multiple consecutive slashes correctly', async () => {
|
|
365
|
+
applyDiffMock.mockReturnValueOnce('file contents');
|
|
366
|
+
|
|
367
|
+
const mockSandbox: MockSandbox = {
|
|
368
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
369
|
+
writeFile: vi.fn().mockResolvedValue(undefined)
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
373
|
+
const operation = {
|
|
374
|
+
type: 'create_file',
|
|
375
|
+
path: 'src//subdir///file.txt',
|
|
376
|
+
diff: '--- diff ---'
|
|
377
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
378
|
+
|
|
379
|
+
await editor.createFile(operation);
|
|
380
|
+
|
|
381
|
+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
|
|
382
|
+
'/workspace/src/subdir/file.txt',
|
|
383
|
+
'file contents',
|
|
384
|
+
{ encoding: 'utf-8' }
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should reject deep path traversal attempts', async () => {
|
|
389
|
+
const mockSandbox: MockSandbox = {
|
|
390
|
+
mkdir: vi.fn(),
|
|
391
|
+
writeFile: vi.fn()
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const editor = new Editor(mockSandbox as unknown as Sandbox);
|
|
395
|
+
const operation = {
|
|
396
|
+
type: 'create_file',
|
|
397
|
+
path: 'a/b/c/../../../../etc/passwd',
|
|
398
|
+
diff: 'malicious content'
|
|
399
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
400
|
+
|
|
401
|
+
await expect(editor.createFile(operation)).rejects.toThrow(
|
|
402
|
+
'Operation outside workspace: a/b/c/../../../../etc/passwd'
|
|
403
|
+
);
|
|
404
|
+
expect(mockSandbox.writeFile).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should handle absolute paths within workspace', async () => {
|
|
408
|
+
applyDiffMock.mockReturnValueOnce('content');
|
|
409
|
+
|
|
410
|
+
const mockSandbox: MockSandbox = {
|
|
411
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
412
|
+
writeFile: vi.fn().mockResolvedValue(undefined)
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const editor = new Editor(
|
|
416
|
+
mockSandbox as unknown as Sandbox,
|
|
417
|
+
'/workspace'
|
|
418
|
+
);
|
|
419
|
+
const operation = {
|
|
420
|
+
type: 'create_file',
|
|
421
|
+
path: '/workspace/file.txt',
|
|
422
|
+
diff: 'content'
|
|
423
|
+
} as Extract<ApplyPatchOperation, { type: 'create_file' }>;
|
|
424
|
+
|
|
425
|
+
await editor.createFile(operation);
|
|
426
|
+
|
|
427
|
+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
|
|
428
|
+
'/workspace/file.txt', // Not /workspace/workspace/file.txt
|
|
429
|
+
'content',
|
|
430
|
+
{ encoding: 'utf-8' }
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|