Skip to content

Commit 4a75e91

Browse files
committed
Add path traversal protection to Editor operations
Introduces strict path normalization and validation in the Editor class to prevent file operations outside the workspace via path traversal (e.g., '../'). Extensive tests were added to verify security against various traversal attempts. The README now includes a security warning about auto-approval and risks.
1 parent 29dfb79 commit 4a75e91

File tree

3 files changed

+259
-15
lines changed

3 files changed

+259
-15
lines changed

examples/openai-agents/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,13 @@ All conversations are saved in your browser's localStorage.
3030
```bash
3131
npm run deploy
3232
```
33+
34+
## Security Warning
35+
36+
**This example auto-approves all AI operations without human review.** The AI can:
37+
38+
- Execute ANY shell command
39+
- Create, modify, or delete ANY file in /workspace
40+
- No safety limits beyond the container itself
41+
42+
**Do not use in production without proper approval flows and rate limiting.**

packages/sandbox/src/openai/index.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,12 +406,35 @@ export class Editor implements OpenAIEeditor {
406406
// Remove leading ./ or / if present, then join with root
407407
const normalized = relativePath.replace(/^\.\//, '').replace(/^\//, '');
408408
const resolved = normalized ? `${this.root}/${normalized}` : this.root;
409+
410+
// Normalize path separators first
411+
const pathWithNormalizedSeparators = resolved.replace(/\/+/g, '/');
412+
413+
// Normalize .. segments by processing path segments
414+
const segments = pathWithNormalizedSeparators
415+
.split('/')
416+
.filter((s) => s && s !== '.');
417+
const stack: string[] = [];
418+
419+
for (const segment of segments) {
420+
if (segment === '..') {
421+
if (stack.length === 0) {
422+
throw new Error(`Operation outside workspace: ${relativePath}`);
423+
}
424+
stack.pop();
425+
} else {
426+
stack.push(segment);
427+
}
428+
}
429+
430+
const normalizedPath = `/${stack.join('/')}`;
431+
409432
// Ensure the resolved path is within the workspace
410-
if (!resolved.startsWith(this.root)) {
433+
if (!normalizedPath.startsWith(this.root)) {
411434
throw new Error(`Operation outside workspace: ${relativePath}`);
412435
}
413-
// Normalize path separators
414-
return resolved.replace(/\/+/g, '/');
436+
437+
return normalizedPath;
415438
}
416439

417440
private getDirname(filePath: string): string {

packages/sandbox/tests/openai-shell-editor.test.ts

Lines changed: 223 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import type { ApplyPatchOperation } from '@openai/agents';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33
import { Editor, Shell } from '../src/openai/index.ts';
4+
import type { Sandbox } from '../src/sandbox.ts';
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+
}
413

514
const { loggerSpies, createLoggerMock, applyDiffMock } = vi.hoisted(() => {
615
const logger = {
@@ -38,7 +47,8 @@ describe('Shell', () => {
3847
exitCode: 0
3948
});
4049

41-
const shell = new Shell({ exec: execMock } as unknown as any);
50+
const mockSandbox: MockSandbox = { exec: execMock };
51+
const shell = new Shell(mockSandbox as unknown as Sandbox);
4252

4353
const result = await shell.run({
4454
commands: ['echo hello'],
@@ -67,7 +77,8 @@ describe('Shell', () => {
6777
const timeoutError = new Error('Command timed out');
6878
const execMock = vi.fn().mockRejectedValue(timeoutError);
6979

70-
const shell = new Shell({ exec: execMock } as unknown as any);
80+
const mockSandbox: MockSandbox = { exec: execMock };
81+
const shell = new Shell(mockSandbox as unknown as Sandbox);
7182
const action = {
7283
commands: ['sleep 1', 'echo never'],
7384
timeoutMs: 25
@@ -101,12 +112,12 @@ describe('Editor', () => {
101112
it('creates files using applyDiff output', async () => {
102113
applyDiffMock.mockReturnValueOnce('file contents');
103114

104-
const sandbox = {
115+
const mockSandbox: MockSandbox = {
105116
mkdir: vi.fn().mockResolvedValue(undefined),
106117
writeFile: vi.fn().mockResolvedValue(undefined)
107118
};
108119

109-
const editor = new Editor(sandbox as unknown as any);
120+
const editor = new Editor(mockSandbox as unknown as Sandbox);
110121
const operation = {
111122
type: 'create_file',
112123
path: 'src/app.ts',
@@ -116,10 +127,10 @@ describe('Editor', () => {
116127
await editor.createFile(operation);
117128

118129
expect(applyDiffMock).toHaveBeenCalledWith('', operation.diff, 'create');
119-
expect(sandbox.mkdir).toHaveBeenCalledWith('/workspace/src', {
130+
expect(mockSandbox.mkdir).toHaveBeenCalledWith('/workspace/src', {
120131
recursive: true
121132
});
122-
expect(sandbox.writeFile).toHaveBeenCalledWith(
133+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
123134
'/workspace/src/app.ts',
124135
'file contents',
125136
{ encoding: 'utf-8' }
@@ -138,12 +149,12 @@ describe('Editor', () => {
138149
it('applies diffs when updating existing files', async () => {
139150
applyDiffMock.mockReturnValueOnce('patched content');
140151

141-
const sandbox = {
152+
const mockSandbox: MockSandbox = {
142153
readFile: vi.fn().mockResolvedValue({ content: 'original content' }),
143154
writeFile: vi.fn().mockResolvedValue(undefined)
144155
};
145156

146-
const editor = new Editor(sandbox as unknown as any);
157+
const editor = new Editor(mockSandbox as unknown as Sandbox);
147158
const operation = {
148159
type: 'update_file',
149160
path: 'README.md',
@@ -152,14 +163,14 @@ describe('Editor', () => {
152163

153164
await editor.updateFile(operation);
154165

155-
expect(sandbox.readFile).toHaveBeenCalledWith('/workspace/README.md', {
166+
expect(mockSandbox.readFile).toHaveBeenCalledWith('/workspace/README.md', {
156167
encoding: 'utf-8'
157168
});
158169
expect(applyDiffMock).toHaveBeenCalledWith(
159170
'original content',
160171
operation.diff
161172
);
162-
expect(sandbox.writeFile).toHaveBeenCalledWith(
173+
expect(mockSandbox.writeFile).toHaveBeenCalledWith(
163174
'/workspace/README.md',
164175
'patched content',
165176
{ encoding: 'utf-8' }
@@ -173,11 +184,11 @@ describe('Editor', () => {
173184

174185
it('throws descriptive error when attempting to update a missing file', async () => {
175186
const missingError = Object.assign(new Error('not found'), { status: 404 });
176-
const sandbox = {
187+
const mockSandbox: MockSandbox = {
177188
readFile: vi.fn().mockRejectedValue(missingError)
178189
};
179190

180-
const editor = new Editor(sandbox as unknown as any);
191+
const editor = new Editor(mockSandbox as unknown as Sandbox);
181192
const operation = {
182193
type: 'update_file',
183194
path: 'missing.txt',
@@ -193,4 +204,204 @@ describe('Editor', () => {
193204
{ path: 'missing.txt' }
194205
);
195206
});
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+
});
196407
});

0 commit comments

Comments
 (0)