Skip to content

Commit 594f50f

Browse files
committed
feat: implement command splitting and sandbox command building in sandbox_shell.py
1 parent 14db7a9 commit 594f50f

1 file changed

Lines changed: 241 additions & 5 deletions

File tree

apps/application/flow/backend/sandbox_shell.py

Lines changed: 241 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import getpass
22
import os
33
import re
4+
import shlex
45

56
from deepagents.backends import LocalShellBackend
67
from deepagents.backends.protocol import ExecuteResponse
8+
9+
from common.utils.logger import maxkb_logger
710
from 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

Comments
 (0)