@@ -12,11 +12,26 @@ class ShellExecutor:
12
12
13
13
def __init__ (self ):
14
14
"""
15
- Initialize the executor. The allowed commands are read from ALLOW_COMMANDS
16
- environment variable during command validation, not at initialization.
15
+ Initialize the executor.
17
16
"""
18
17
pass
19
18
19
+ def _get_allowed_commands (self ) -> set [str ]:
20
+ """Get the set of allowed commands from environment variables"""
21
+ allow_commands = os .environ .get ("ALLOW_COMMANDS" , "" )
22
+ allowed_commands = os .environ .get ("ALLOWED_COMMANDS" , "" )
23
+ commands = allow_commands + "," + allowed_commands
24
+ return {cmd .strip () for cmd in commands .split ("," ) if cmd .strip ()}
25
+
26
+ def get_allowed_commands (self ) -> list [str ]:
27
+ """Get the list of allowed commands from environment variables"""
28
+ return list (self ._get_allowed_commands ())
29
+
30
+ def is_command_allowed (self , command : str ) -> bool :
31
+ """Check if a command is in the allowed list"""
32
+ cmd = command .strip ()
33
+ return cmd in self ._get_allowed_commands ()
34
+
20
35
def _validate_redirection_syntax (self , command : List [str ]) -> None :
21
36
"""
22
37
Validate the syntax of redirection operators in the command.
@@ -155,24 +170,12 @@ async def _cleanup_handles(
155
170
"""
156
171
for key in ["stdout" , "stderr" ]:
157
172
handle = handles .get (key )
158
- if isinstance (handle , IO ) and handle != asyncio . subprocess . PIPE :
173
+ if handle and hasattr (handle , "close" ) and not isinstance ( handle , int ) :
159
174
try :
160
175
handle .close ()
161
- except IOError :
176
+ except ( IOError , ValueError ) :
162
177
pass
163
178
164
- def _get_allowed_commands (self ) -> set :
165
- """
166
- Get the set of allowed commands from environment variables.
167
- Checks both ALLOW_COMMANDS and ALLOWED_COMMANDS.
168
- """
169
- allow_commands = os .environ .get ("ALLOW_COMMANDS" , "" )
170
- allowed_commands = os .environ .get ("ALLOWED_COMMANDS" , "" )
171
-
172
- # Combine and deduplicate commands from both environment variables
173
- commands = allow_commands + "," + allowed_commands
174
- return {cmd .strip () for cmd in commands .split ("," ) if cmd .strip ()}
175
-
176
179
def _clean_command (self , command : List [str ]) -> List [str ]:
177
180
"""
178
181
Clean command by trimming whitespace from each part.
@@ -253,32 +256,36 @@ def _validate_directory(self, directory: Optional[str]) -> None:
253
256
if not os .access (directory , os .R_OK | os .X_OK ):
254
257
raise ValueError (f"Directory is not accessible: { directory } " )
255
258
256
- def get_allowed_commands (self ) -> list [str ]:
257
- """Get the allowed commands"""
258
- return list (self ._get_allowed_commands ())
259
-
260
259
def _validate_no_shell_operators (self , cmd : str ) -> None :
261
260
"""Validate that the command does not contain shell operators"""
262
261
if cmd in [";" "&&" , "||" , "|" ]:
263
262
raise ValueError (f"Unexpected shell operator: { cmd } " )
264
263
265
- def _validate_pipeline (self , commands : List [str ]) -> None :
266
- """Validate pipeline command and ensure all parts are allowed"""
264
+ def _validate_pipeline (self , commands : List [str ]) -> Dict [str , str ]:
265
+ """Validate pipeline command and ensure all parts are allowed
266
+
267
+ Returns:
268
+ Dict[str, str]: Error message if validation fails, empty dict if success
269
+ """
267
270
current_cmd : List [str ] = []
268
271
269
272
for token in commands :
270
273
if token == "|" :
271
274
if not current_cmd :
272
275
raise ValueError ("Empty command before pipe operator" )
273
- self ._validate_command (current_cmd )
276
+ if not self .is_command_allowed (current_cmd [0 ]):
277
+ raise ValueError (f"Command not allowed: { current_cmd [0 ]} " )
274
278
current_cmd = []
275
279
elif token in [";" , "&&" , "||" ]:
276
280
raise ValueError (f"Unexpected shell operator in pipeline: { token } " )
277
281
else :
278
282
current_cmd .append (token )
279
283
280
284
if current_cmd :
281
- self ._validate_command (current_cmd )
285
+ if not self .is_command_allowed (current_cmd [0 ]):
286
+ raise ValueError (f"Command not allowed: { current_cmd [0 ]} " )
287
+
288
+ return {}
282
289
283
290
def _split_pipe_commands (self , command : List [str ]) -> List [List [str ]]:
284
291
"""
@@ -379,6 +386,7 @@ async def execute(
379
386
timeout : Optional [int ] = None ,
380
387
) -> Dict [str , Any ]:
381
388
start_time = time .time ()
389
+ process = None # Initialize process variable
382
390
383
391
try :
384
392
# Validate directory if specified
@@ -393,33 +401,55 @@ async def execute(
393
401
"execution_time" : time .time () - start_time ,
394
402
}
395
403
396
- # Preprocess command to handle pipe operators
404
+ # Process command
397
405
preprocessed_command = self ._preprocess_command (command )
398
406
cleaned_command = self ._clean_command (preprocessed_command )
399
407
if not cleaned_command :
400
- raise ValueError ("Empty command" )
408
+ return {
409
+ "error" : "Empty command" ,
410
+ "status" : 1 ,
411
+ "stdout" : "" ,
412
+ "stderr" : "Empty command" ,
413
+ "execution_time" : time .time () - start_time ,
414
+ }
401
415
402
416
# First check for pipe operators and handle pipeline
403
417
if "|" in cleaned_command :
404
- commands : List [List [str ]] = []
405
- current_cmd : List [str ] = []
406
- for token in cleaned_command :
407
- if token == "|" :
408
- if current_cmd :
409
- commands .append (current_cmd )
410
- current_cmd = []
418
+ try :
419
+ # Validate pipeline first
420
+ error = self ._validate_pipeline (cleaned_command )
421
+ if error :
422
+ return {
423
+ ** error ,
424
+ "status" : 1 ,
425
+ "stdout" : "" ,
426
+ "execution_time" : time .time () - start_time ,
427
+ }
428
+
429
+ # Split commands
430
+ commands : List [List [str ]] = []
431
+ current_cmd : List [str ] = []
432
+ for token in cleaned_command :
433
+ if token == "|" :
434
+ if current_cmd :
435
+ commands .append (current_cmd )
436
+ current_cmd = []
437
+ else :
438
+ raise ValueError ("Empty command before pipe operator" )
411
439
else :
412
- raise ValueError ("Empty command before pipe operator" )
413
- else :
414
- current_cmd .append (token )
415
- if current_cmd :
416
- commands .append (current_cmd )
417
-
418
- # Validate each command in pipeline
419
- for cmd in commands :
420
- self ._validate_command (cmd )
440
+ current_cmd .append (token )
441
+ if current_cmd :
442
+ commands .append (current_cmd )
421
443
422
- return await self ._execute_pipeline (commands , directory , timeout )
444
+ return await self ._execute_pipeline (commands , directory , timeout )
445
+ except ValueError as e :
446
+ return {
447
+ "error" : str (e ),
448
+ "status" : 1 ,
449
+ "stdout" : "" ,
450
+ "stderr" : str (e ),
451
+ "execution_time" : time .time () - start_time ,
452
+ }
423
453
424
454
# Then check for other shell operators
425
455
for token in cleaned_command :
@@ -561,6 +591,11 @@ async def communicate_with_timeout():
561
591
"stderr" : str (e ),
562
592
"execution_time" : time .time () - start_time ,
563
593
}
594
+ finally :
595
+ # Ensure process is terminated
596
+ if process and process .returncode is None :
597
+ process .kill ()
598
+ await process .wait ()
564
599
565
600
async def _execute_pipeline (
566
601
self ,
@@ -663,5 +698,10 @@ async def _execute_pipeline(
663
698
}
664
699
665
700
finally :
701
+ # Ensure all processes are terminated
702
+ for process in processes :
703
+ if process .returncode is None :
704
+ process .kill ()
705
+ await process .wait ()
666
706
if isinstance (last_stdout , IO ):
667
707
last_stdout .close ()
0 commit comments