11import getpass
22import os
33import re
4+ import shlex
45
56from deepagents .backends import LocalShellBackend
67from deepagents .backends .protocol import ExecuteResponse
8+
9+ from common .utils .logger import maxkb_logger
710from maxkb .const import CONFIG
811
912_enable_sandbox = bool (int (CONFIG .get ("SANDBOX" , 0 )))
@@ -53,6 +56,237 @@ def translate(m: re.Match) -> str:
5356 # Only translate when virtual_mode is active.
5457 return re .sub (r'(?<![.\w\-])/[A-Za-z_][^\s\'"\\;|&><:,]*' , translate , command )
5558
59+ def _consume_group (self , command : str , start_index : int ) -> tuple [str , int ]:
60+ current = []
61+ in_single_quote = False
62+ in_double_quote = False
63+ in_backticks = False
64+ escaped = False
65+ substitution_depth = 0
66+ group_depth = 1
67+ index = start_index + 1
68+
69+ while index < len (command ):
70+ char = command [index ]
71+
72+ if escaped :
73+ current .append (char )
74+ escaped = False
75+ index += 1
76+ continue
77+
78+ if char == "\\ " and not in_single_quote :
79+ current .append (char )
80+ escaped = True
81+ index += 1
82+ continue
83+
84+ if char == "`" and not in_single_quote :
85+ in_backticks = not in_backticks
86+ current .append (char )
87+ index += 1
88+ continue
89+
90+ if in_backticks :
91+ current .append (char )
92+ index += 1
93+ continue
94+
95+ if char == "'" and not in_double_quote :
96+ in_single_quote = not in_single_quote
97+ current .append (char )
98+ index += 1
99+ continue
100+
101+ if char == '"' and not in_single_quote :
102+ in_double_quote = not in_double_quote
103+ current .append (char )
104+ index += 1
105+ continue
106+
107+ if in_single_quote or in_double_quote :
108+ current .append (char )
109+ index += 1
110+ continue
111+
112+ if command .startswith ("$(" , index ):
113+ substitution_depth += 1
114+ current .append ("$(" )
115+ index += 2
116+ continue
117+
118+ if substitution_depth :
119+ if char == ")" :
120+ substitution_depth -= 1
121+ current .append (char )
122+ index += 1
123+ continue
124+
125+ if char == "(" :
126+ group_depth += 1
127+ current .append (char )
128+ index += 1
129+ continue
130+
131+ if char == ")" :
132+ group_depth -= 1
133+ if group_depth == 0 :
134+ return "" .join (current ).strip (), index + 1
135+ current .append (char )
136+ index += 1
137+ continue
138+
139+ current .append (char )
140+ index += 1
141+
142+ raise ValueError ("unclosed command group" )
143+
144+ def _append_pending_command_part (self , parts : list [str | tuple [str , str ]], current : list [str ]) -> None :
145+ part = "" .join (current ).strip ()
146+ if part :
147+ parts .append (part )
148+ return
149+
150+ if not parts :
151+ parts .append ("" )
152+ return
153+
154+ last_part = parts [- 1 ]
155+ if isinstance (last_part , str ) and last_part in {";" , "&&" , "||" , "|" , "&" }:
156+ parts .append ("" )
157+
158+ def _split_shell_command_list (self , command : str ) -> list [str | tuple [str , str ]]:
159+ parts = []
160+ current = []
161+ in_single_quote = False
162+ in_double_quote = False
163+ in_backticks = False
164+ escaped = False
165+ substitution_depth = 0
166+ index = 0
167+
168+ while index < len (command ):
169+ char = command [index ]
170+
171+ if escaped :
172+ current .append (char )
173+ escaped = False
174+ index += 1
175+ continue
176+
177+ if char == "\\ " and not in_single_quote :
178+ current .append (char )
179+ escaped = True
180+ index += 1
181+ continue
182+
183+ if char == "`" and not in_single_quote :
184+ in_backticks = not in_backticks
185+ current .append (char )
186+ index += 1
187+ continue
188+
189+ if in_backticks :
190+ current .append (char )
191+ index += 1
192+ continue
193+
194+ if char == "'" and not in_double_quote :
195+ in_single_quote = not in_single_quote
196+ current .append (char )
197+ index += 1
198+ continue
199+
200+ if char == '"' and not in_single_quote :
201+ in_double_quote = not in_double_quote
202+ current .append (char )
203+ index += 1
204+ continue
205+
206+ if not in_single_quote and not in_double_quote :
207+ if command .startswith ("$(" , index ):
208+ substitution_depth += 1
209+ current .append ("$(" )
210+ index += 2
211+ continue
212+
213+ if substitution_depth :
214+ if char == ")" :
215+ substitution_depth -= 1
216+ current .append (char )
217+ index += 1
218+ continue
219+
220+ if char == "(" and not "" .join (current ).strip ():
221+ group_content , index = self ._consume_group (command , index )
222+ parts .append (("group" , group_content ))
223+ current = []
224+ continue
225+
226+ if command .startswith ("&&" , index ) or command .startswith ("||" , index ):
227+ self ._append_pending_command_part (parts , current )
228+ parts .append (command [index : index + 2 ])
229+ current = []
230+ index += 2
231+ continue
232+
233+ if char in {";" , "|" , "&" }:
234+ self ._append_pending_command_part (parts , current )
235+ parts .append (char )
236+ current = []
237+ index += 1
238+ continue
239+
240+ if char == "\n " :
241+ self ._append_pending_command_part (parts , current )
242+ parts .append (";" )
243+ current = []
244+ index += 1
245+ continue
246+
247+ current .append (char )
248+ index += 1
249+
250+ self ._append_pending_command_part (parts , current )
251+ return parts
252+
253+ def _build_sandbox_command (self , command : str ) -> str :
254+ prefix = (
255+ "env -i LD_PRELOAD=/opt/maxkb-app/sandbox/lib/sandbox.so "
256+ f'PATH="${{PATH}}" PYTHONPATH="${{PYTHONPATH}}" gosu { _run_user } '
257+ )
258+ parts = self ._split_shell_command_list (command )
259+ sandboxed_parts = []
260+ expect_command = True
261+
262+ for part in parts :
263+ if expect_command :
264+ if isinstance (part , tuple ):
265+ group_kind , group_content = part
266+ if group_kind != "group" :
267+ raise ValueError (f"unsupported command part: { group_kind } " )
268+ if not group_content :
269+ raise ValueError ("empty command group" )
270+ sandboxed_parts .append (f"( { self ._build_sandbox_command (group_content )} )" )
271+ elif not part :
272+ raise ValueError ("empty command" )
273+ else :
274+ tokens = shlex .split (part )
275+ if not tokens :
276+ raise ValueError ("empty command" )
277+ sandboxed_parts .append (prefix + " " .join (shlex .quote (token ) for token in tokens ))
278+ else :
279+ if part not in {";" , "&&" , "||" , "|" , "&" }:
280+ raise ValueError (f"unsupported shell operator: { part } " )
281+ sandboxed_parts .append (part )
282+
283+ expect_command = not expect_command
284+
285+ if expect_command :
286+ raise ValueError ("command cannot end with a shell operator" )
287+
288+ return " " .join (sandboxed_parts )
289+
56290 def execute (
57291 self ,
58292 command : str ,
@@ -65,11 +299,13 @@ def execute(
65299 if _enable_sandbox :
66300 # 用 runuser 在子进程里切换用户,父进程凭据保持不变,
67301 # 避免父进程 ruid/euid 不一致导致 execve 报 Permission denied
68- command = (
69- "env -i LD_PRELOAD=/opt/maxkb-app/sandbox/lib/sandbox.so "
70- f'PATH="${{PATH}}" PYTHONPATH="${{PYTHONPATH}}" gosu { _run_user } { command } '
71- )
302+ try :
303+ # 将命令列表拆成多个简单命令,并分别在 sandbox 用户下执行。
304+ # 每个简单命令仍按 argv 重新 quote,避免 $()、反引号等在父 shell 中展开。
305+ command = self ._build_sandbox_command (command )
306+ except ValueError as e :
307+ return ExecuteResponse (output = f"Invalid command: { e } " , exit_code = 1 )
72308 # command = f"runuser -u {_run_user} -- env -i PATH=${{PATH}} {command}"
73309
74- # print (f"Executing command in sandbox: {command}")
310+ maxkb_logger . info (f"Executing command in sandbox: { command } " )
75311 return super ().execute (command = command , timeout = timeout )
0 commit comments