11import type { ApplyPatchOperation } from '@openai/agents' ;
22import { beforeEach , describe , expect , it , vi } from 'vitest' ;
33import { 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
514const { 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