sycommon-python-lib 0.2.7a21__py3-none-any.whl → 0.2.7a23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sycommon/agent/middleware/sandbox_path_guard.py +7 -1
- sycommon/agent/middleware/skill_api_whitelist.py +149 -23
- sycommon/agent/sandbox/file_ops.py +61 -27
- sycommon/agent/sandbox/http_sandbox_backend.py +11 -6
- sycommon/middleware/sandbox.py +239 -70
- {sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/RECORD +10 -10
- {sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/top_level.txt +0 -0
|
@@ -61,7 +61,13 @@ _PATH_TOOLS = {
|
|
|
61
61
|
|
|
62
62
|
# 宿主沙箱根前缀(用于 execute 命令串扫描;shell 里无法安全改写,只能拦)。
|
|
63
63
|
# 命中即视为「引用了宿主沙箱真实路径」,一律拦。
|
|
64
|
-
|
|
64
|
+
# 注意:要拦到「根目录本身」和「根下子路径」两种形式。原来要求尾斜杠
|
|
65
|
+
# (_WORKSPACE_ROOT/),会漏过 `find /data/sycommon_sandbox -name x`(后跟空格,
|
|
66
|
+
# 没有尾斜杠)这种「拿沙箱根当 find 起点」的越权扫描。改为允许「尾斜杠」或
|
|
67
|
+
# 「词边界(后跟空白/行尾/引号)」,根目录本身与任意子路径都能命中。
|
|
68
|
+
_HOST_SANDBOX_RE = re.compile(
|
|
69
|
+
re.escape(_WORKSPACE_ROOT) + r'(?:[/\\]|(?=\s|$|["\']))'
|
|
70
|
+
)
|
|
65
71
|
|
|
66
72
|
_DENIED_MSG = (
|
|
67
73
|
"Error: 不允许使用宿主机绝对路径访问沙箱文件。"
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
allowlist:[] 空数组 → 注入空白名单(全拦截)。
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
22
24
|
import json
|
|
23
25
|
import os
|
|
24
26
|
import re
|
|
@@ -26,6 +28,7 @@ import shlex
|
|
|
26
28
|
from typing import Optional
|
|
27
29
|
|
|
28
30
|
from langchain.agents.middleware.types import AgentMiddleware
|
|
31
|
+
from langchain_core.messages import ToolMessage
|
|
29
32
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
30
33
|
|
|
31
34
|
from sycommon.logging.kafka_log import SYLogger
|
|
@@ -41,14 +44,94 @@ _SKILL_TOP_RE = re.compile(
|
|
|
41
44
|
# BASE_URL 占位符 ${VAR}
|
|
42
45
|
_PLACEHOLDER_RE = re.compile(r'\$\{([A-Z_][A-Z0-9_]*)\}')
|
|
43
46
|
|
|
44
|
-
# 推送到沙箱 workspace 内的 .wl 目录(沙箱强制路径隔离,绝对路径会被映射进 workspace,
|
|
45
|
-
# 故用相对 POSIX 路径,落地为 {workspace}/.wl/,再 export PYTHONPATH 让 Python 自动 import)
|
|
46
|
-
_PUSH_FILES = [
|
|
47
|
-
("sitecustomize.py", ".wl/sitecustomize.py"),
|
|
48
|
-
("skill_wl_check.py", ".wl/skill_wl_check.py"),
|
|
49
|
-
]
|
|
50
47
|
# workspace 内白名单目录的 shell 形式(init_script 已 export $SANDBOX_ROOT,运行期展开)
|
|
51
48
|
_WL_DIR = "$SANDBOX_ROOT/.wl"
|
|
49
|
+
# .wl 目录在 workspace 内的相对路径段(用于 hash 校验命令里的文件名)
|
|
50
|
+
_WL_FILES = [
|
|
51
|
+
".wl/sitecustomize.py", # 极小 loader(明文,无业务逻辑)
|
|
52
|
+
".wl/sitecustomize.payload", # sitecustomize.py 源码的 base64
|
|
53
|
+
".wl/skill_wl_check.payload", # skill_wl_check.py 源码的 base64
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# ---- 极小 loader(落盘为 .wl/sitecustomize.py)----
|
|
57
|
+
# 不含任何白名单/拦截业务逻辑,只做"解码同目录 payload + exec"。
|
|
58
|
+
# 关键:把 skill_wl_check 注册成真模块对象进 sys.modules,使 sitecustomize.payload
|
|
59
|
+
# 里的 `import skill_wl_check; skill_wl_check.match(...)` 按原逻辑工作(逻辑不变)。
|
|
60
|
+
_LOADER_SRC = b'''# sycommon injected loader: decode payloads, exec them. No business logic.
|
|
61
|
+
import os, sys, base64, types
|
|
62
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
63
|
+
if _HERE not in sys.path:
|
|
64
|
+
sys.path.insert(0, _HERE)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(name, ns):
|
|
68
|
+
p = os.path.join(_HERE, name)
|
|
69
|
+
try:
|
|
70
|
+
with open(p, "rb") as f:
|
|
71
|
+
exec(compile(base64.b64decode(f.read()), name, "exec"), ns)
|
|
72
|
+
except Exception as _e:
|
|
73
|
+
sys.stderr.write("[.wl loader] load %s failed: %s\\n" % (name, _e))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_wl = types.ModuleType("skill_wl_check")
|
|
77
|
+
sys.modules["skill_wl_check"] = _wl
|
|
78
|
+
_run("skill_wl_check.payload", _wl.__dict__)
|
|
79
|
+
_run("sitecustomize.payload", globals())
|
|
80
|
+
'''
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_payload(src_name: str) -> bytes:
|
|
84
|
+
"""读包内源码并 base64 编码,作为 .payload 落盘内容(防明文窥探)。"""
|
|
85
|
+
raw = _load_resource_bytes(src_name)
|
|
86
|
+
return base64.b64encode(raw)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expected_hashes() -> dict:
|
|
90
|
+
"""运行时从主进程源码算各 .wl 文件的预期 sha256(hex)。
|
|
91
|
+
|
|
92
|
+
payload 的预期 hash 算的是【编码后的 base64 字节】——即沙箱里实际落盘内容,
|
|
93
|
+
这样 sha256 比对的就是真实文件内容。loader 的预期 hash 算 _LOADER_SRC(固定常量)。
|
|
94
|
+
"""
|
|
95
|
+
loader_bytes = _LOADER_SRC
|
|
96
|
+
sc_bytes = _build_payload("sitecustomize.py")
|
|
97
|
+
wl_bytes = _build_payload("skill_wl_check.py")
|
|
98
|
+
return {
|
|
99
|
+
".wl/sitecustomize.py": hashlib.sha256(loader_bytes).hexdigest(),
|
|
100
|
+
".wl/sitecustomize.payload": hashlib.sha256(sc_bytes).hexdigest(),
|
|
101
|
+
".wl/skill_wl_check.payload": hashlib.sha256(wl_bytes).hexdigest(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---- .wl 写拦截(复刻 skill_write_guard 的正则集合)----
|
|
106
|
+
# .wl 目录保护:精确匹配 .wl 或 .wl/...(带边界,防误伤 myapp.wl 之类)
|
|
107
|
+
_WL_PROTECTED_RE = re.compile(r'\.wl(?:/|\s|"|\'|$)')
|
|
108
|
+
# 写动作动词(命令首或分号/管道/空格后)
|
|
109
|
+
_WRITE_VERB_RE = re.compile(
|
|
110
|
+
r'(^|[\s;&|])(rm|rmdir|mv|cp|chmod|chown|tee|truncate|install|rsync|scp|dd|>\.wl)\b')
|
|
111
|
+
# 输出重定向 > 或 >>
|
|
112
|
+
_REDIRECT_RE = re.compile(r'>>?')
|
|
113
|
+
_WL_DENIED_MSG = "Error: .wl 为系统注入目录,禁止写入/修改/删除。"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _command_writes_wl(cmd: str) -> bool:
|
|
117
|
+
"""execute 命令是否在写 .wl:命中 .wl 路径 且 含写动词或重定向。"""
|
|
118
|
+
if not _WL_PROTECTED_RE.search(cmd or ""):
|
|
119
|
+
return False
|
|
120
|
+
return bool(_WRITE_VERB_RE.search(cmd) or _REDIRECT_RE.search(cmd))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_wl_path(p: str) -> bool:
|
|
124
|
+
"""file_path 是否落在 .wl 目录内(write_file/edit_file 拦截用)。
|
|
125
|
+
|
|
126
|
+
注意不能用 lstrip("./")——它是按字符集 strip,会把 ".wl" 的前导 "." 也吃成 "wl"。
|
|
127
|
+
改为逐前缀剥离再判。
|
|
128
|
+
"""
|
|
129
|
+
s = (p or "").strip().replace("\\", "/")
|
|
130
|
+
# 剥前导 ./ 和 /
|
|
131
|
+
while s.startswith("./") or s.startswith("/"):
|
|
132
|
+
s = s[1:] if s.startswith("/") else s[2:]
|
|
133
|
+
return s == ".wl" or s.startswith(".wl/")
|
|
134
|
+
|
|
52
135
|
|
|
53
136
|
|
|
54
137
|
def _resolve_placeholders(pattern: str, env_lookup) -> Optional[str]:
|
|
@@ -120,7 +203,8 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
120
203
|
|
|
121
204
|
def __init__(self, sandbox_backend):
|
|
122
205
|
self._backend = sandbox_backend
|
|
123
|
-
|
|
206
|
+
# 注:不再缓存"已推送"标志。用户可在会话中删除 .wl/ 绕过白名单,
|
|
207
|
+
# 故改为每次 execute 前校验文件完整性、缺则重推(见 _ensure_sitecustomize)。
|
|
124
208
|
|
|
125
209
|
def _env_lookup(self):
|
|
126
210
|
"""构造占位符查找器:先查主进程 os.environ,再查 sandbox env 字典。
|
|
@@ -155,27 +239,54 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
155
239
|
|
|
156
240
|
# ---- 懒加载:把注入脚本推进沙箱 workspace/.wl ----
|
|
157
241
|
async def _ensure_sitecustomize(self) -> bool:
|
|
158
|
-
"""把
|
|
242
|
+
"""把 loader + 2 个 base64 payload 推到沙箱 workspace 内的 .wl 目录。
|
|
159
243
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
幂等:每个 backend 实例只推一次(_pushed 标记)。失败 fail-open(不阻塞业务)。
|
|
244
|
+
防篡改 + 自愈:每次 execute 前对 3 个文件算 sha256,与主进程源码算出的
|
|
245
|
+
预期 hash 比对。任一文件缺失/被改 → 全量重推 + chmod 555。
|
|
246
|
+
旧的 test -f 存在性校验挡不住「用户把 sitecustomize 改成空文件」,
|
|
247
|
+
sha256 内容校验才能挡。失败 fail-open(不阻塞业务)。
|
|
165
248
|
"""
|
|
166
|
-
if self._pushed:
|
|
167
|
-
return True
|
|
168
249
|
backend = self._backend
|
|
169
250
|
if backend is None:
|
|
170
251
|
return False
|
|
171
252
|
try:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
expected = _expected_hashes()
|
|
254
|
+
# 一条命令取三个文件的 sha256(缺失则该行输出 MISSING),换行拼接返回。
|
|
255
|
+
files_quoted = " ".join(
|
|
256
|
+
shlex.quote(f) for f in _WL_FILES)
|
|
257
|
+
check_cmd = (
|
|
258
|
+
f'for f in {files_quoted}; do '
|
|
259
|
+
f'if [ -f "$SANDBOX_ROOT/$f" ]; then '
|
|
260
|
+
f'sha256sum "$SANDBOX_ROOT/$f" 2>/dev/null | cut -d" " -f1; '
|
|
261
|
+
f'else echo MISSING; fi; done'
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
resp = await backend.aexecute(check_cmd)
|
|
265
|
+
actual_lines = (getattr(resp, "output", "") or "").strip().splitlines()
|
|
266
|
+
except Exception:
|
|
267
|
+
actual_lines = []
|
|
268
|
+
|
|
269
|
+
# 逐文件比对:长度/顺序与 _WL_FILES 对应
|
|
270
|
+
intact = (len(actual_lines) == len(_WL_FILES)
|
|
271
|
+
and all(actual_lines[i] == expected[_WL_FILES[i]]
|
|
272
|
+
for i in range(len(_WL_FILES))))
|
|
273
|
+
|
|
274
|
+
if intact:
|
|
275
|
+
return True # 内容完整,无需重推
|
|
276
|
+
|
|
277
|
+
# 重推:loader 明文 + 2 个 payload
|
|
278
|
+
files = [
|
|
279
|
+
(".wl/sitecustomize.py", _LOADER_SRC),
|
|
280
|
+
(".wl/sitecustomize.payload", _build_payload("sitecustomize.py")),
|
|
281
|
+
(".wl/skill_wl_check.payload", _build_payload("skill_wl_check.py")),
|
|
282
|
+
]
|
|
176
283
|
await backend.aupload_files(files)
|
|
177
|
-
|
|
178
|
-
|
|
284
|
+
# chmod -R 555:挡普通用户改/删(root 仍可绕,靠 sha256 自愈兜底)
|
|
285
|
+
try:
|
|
286
|
+
await backend.aexecute(f'chmod -R 555 "{_WL_DIR}"')
|
|
287
|
+
except Exception as _ce:
|
|
288
|
+
SYLogger.warning("[Whitelist] chmod .wl 555 失败(非致命): %s", _ce)
|
|
289
|
+
SYLogger.info("[Whitelist] 注入脚本已(重新)推送至沙箱 %s (sha256 校验触发)", _WL_DIR)
|
|
179
290
|
return True
|
|
180
291
|
except Exception as e:
|
|
181
292
|
SYLogger.error("[Whitelist] 推送 sitecustomize 失败(fail-open): %s", e)
|
|
@@ -195,10 +306,25 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
195
306
|
handler,
|
|
196
307
|
):
|
|
197
308
|
tc = request.tool_call
|
|
198
|
-
|
|
309
|
+
name = tc.get("name")
|
|
310
|
+
args = tc.get("args", {}) or {}
|
|
311
|
+
|
|
312
|
+
# ---- .wl 写拦截(短路 fail-closed,在任何业务逻辑之前)----
|
|
313
|
+
# 防 agent 经 write_file/edit_file/execute 篡改注入目录。参考 SkillWriteGuard。
|
|
314
|
+
if name in ("write_file", "edit_file"):
|
|
315
|
+
fp = args.get("file_path") if isinstance(args, dict) else ""
|
|
316
|
+
if _is_wl_path(fp):
|
|
317
|
+
return ToolMessage(content=_WL_DENIED_MSG, name=name,
|
|
318
|
+
tool_call_id=tc.get("id", ""), status="error")
|
|
319
|
+
elif name == "execute":
|
|
320
|
+
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
321
|
+
if _command_writes_wl(command):
|
|
322
|
+
return ToolMessage(content=_WL_DENIED_MSG, name="execute",
|
|
323
|
+
tool_call_id=tc.get("id", ""), status="error")
|
|
324
|
+
|
|
325
|
+
if name != "execute":
|
|
199
326
|
return await handler(request)
|
|
200
327
|
|
|
201
|
-
args = tc.get("args", {}) or {}
|
|
202
328
|
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
203
329
|
if not command:
|
|
204
330
|
return await handler(request)
|
|
@@ -54,6 +54,25 @@ _EDIT_ERROR_CODE_MAP = {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
# 服务端 /ls 错误码 -> 人类可读文案(对齐 BaseSandbox._parse_ls_output 的
|
|
58
|
+
# path_not_found / not_a_directory 语义)。未在表内的码原样透传。
|
|
59
|
+
_LS_ERROR_MAP = {
|
|
60
|
+
"path_not_found": "path not found",
|
|
61
|
+
"not_a_directory": "not a directory",
|
|
62
|
+
"permission_denied": "permission denied",
|
|
63
|
+
"list_failed": "listing failed",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 服务端 /delete 错误码 -> 人类可读文案(统一前缀 "Failed to delete '<path>': ...",
|
|
68
|
+
# 与 write 的 "Failed to write file '<path>': ..." 风格一致)。
|
|
69
|
+
_DELETE_ERROR_MAP = {
|
|
70
|
+
"file_not_found": "file not found",
|
|
71
|
+
"is_directory": "is a directory, set recursive=true",
|
|
72
|
+
"permission_denied": "permission denied",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
57
76
|
def _normalize_edit_error(error: str, file_path: str, old_string: str) -> str:
|
|
58
77
|
"""把服务端的自由格式错误串映射成与 BaseSandbox 一致的可读消息。
|
|
59
78
|
|
|
@@ -282,7 +301,14 @@ class FileOperationsMixin:
|
|
|
282
301
|
"path": path,
|
|
283
302
|
"user_id": self.user_id
|
|
284
303
|
})
|
|
285
|
-
#
|
|
304
|
+
# 服务端路径不存在/非目录时返回 {"error": "path_not_found"/"not_a_directory"/...}
|
|
305
|
+
# 对齐 BaseSandbox._parse_ls_output,映射成 "Path '<path>': <reason>",
|
|
306
|
+
# 而非当成空目录成功(否则 agent 会误判路径存在但无文件)。
|
|
307
|
+
if isinstance(result, dict) and result.get("error"):
|
|
308
|
+
err = result["error"]
|
|
309
|
+
# 归一化错误码为人类可读文案(对齐 BaseSandbox 的 path_not_found/not_a_directory 语义)
|
|
310
|
+
err_msg = _LS_ERROR_MAP.get(err, err)
|
|
311
|
+
return LsResult(entries=None, error=f"Path '{path}': {err_msg}")
|
|
286
312
|
entries = [FileInfo(**item) for item in (result or [])]
|
|
287
313
|
SYLogger.info(f"[Sandbox] 目录内容: {len(entries)} 项")
|
|
288
314
|
return LsResult(entries=entries)
|
|
@@ -300,6 +326,10 @@ class FileOperationsMixin:
|
|
|
300
326
|
"path": path,
|
|
301
327
|
"user_id": self.user_id
|
|
302
328
|
}, timeout=timeout)
|
|
329
|
+
if isinstance(result, dict) and result.get("error"):
|
|
330
|
+
err = result["error"]
|
|
331
|
+
err_msg = _LS_ERROR_MAP.get(err, err)
|
|
332
|
+
return LsResult(entries=None, error=f"Path '{path}': {err_msg}")
|
|
303
333
|
entries = [FileInfo(**item) for item in (result or [])]
|
|
304
334
|
SYLogger.info(f"[Sandbox] 目录内容: {len(entries)} 项")
|
|
305
335
|
return LsResult(entries=entries)
|
|
@@ -606,7 +636,10 @@ class FileOperationsMixin:
|
|
|
606
636
|
# ============== 树形目录 ==============
|
|
607
637
|
|
|
608
638
|
def tree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0) -> TreeResult:
|
|
609
|
-
"""以树形结构展示目录
|
|
639
|
+
"""以树形结构展示目录
|
|
640
|
+
|
|
641
|
+
错误前缀对齐 read/ls/grep/glob:统一包装为 "Path '<path>': <reason>"。
|
|
642
|
+
"""
|
|
610
643
|
try:
|
|
611
644
|
self._lazy_sync_skill_from_path_sync(path)
|
|
612
645
|
self._ensure_synced_sync()
|
|
@@ -618,13 +651,13 @@ class FileOperationsMixin:
|
|
|
618
651
|
"max_files": max_files,
|
|
619
652
|
})
|
|
620
653
|
if result.get("error"):
|
|
621
|
-
return TreeResult(error=result[
|
|
654
|
+
return TreeResult(error=f"Path '{path}': {result['error']}")
|
|
622
655
|
tree_node = TreeNode(
|
|
623
656
|
**result["tree"]) if result.get("tree") else None
|
|
624
657
|
return TreeResult(tree=tree_node)
|
|
625
658
|
except Exception as e:
|
|
626
659
|
SYLogger.error(f"[Sandbox] tree 失败: {e}")
|
|
627
|
-
return TreeResult(error=
|
|
660
|
+
return TreeResult(error=f"Path '{path}': {e}")
|
|
628
661
|
|
|
629
662
|
async def atree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0, *, timeout: int = None) -> TreeResult:
|
|
630
663
|
"""异步以树形结构展示目录"""
|
|
@@ -639,13 +672,13 @@ class FileOperationsMixin:
|
|
|
639
672
|
"max_files": max_files,
|
|
640
673
|
}, timeout=timeout)
|
|
641
674
|
if result.get("error"):
|
|
642
|
-
return TreeResult(error=result[
|
|
675
|
+
return TreeResult(error=f"Path '{path}': {result['error']}")
|
|
643
676
|
tree_node = TreeNode(
|
|
644
677
|
**result["tree"]) if result.get("tree") else None
|
|
645
678
|
return TreeResult(tree=tree_node)
|
|
646
679
|
except Exception as e:
|
|
647
680
|
SYLogger.error(f"[Sandbox] 异步 tree 失败: {e}")
|
|
648
|
-
return TreeResult(error=
|
|
681
|
+
return TreeResult(error=f"Path '{path}': {e}")
|
|
649
682
|
|
|
650
683
|
def grep(
|
|
651
684
|
self: "HTTPSandboxBackend",
|
|
@@ -745,7 +778,12 @@ class FileOperationsMixin:
|
|
|
745
778
|
path: str,
|
|
746
779
|
recursive: bool = False,
|
|
747
780
|
) -> DeleteResult:
|
|
748
|
-
"""删除文件或文件夹
|
|
781
|
+
"""删除文件或文件夹
|
|
782
|
+
|
|
783
|
+
错误文案对齐 write 的 "Failed to write file '<path>': ..." 风格,
|
|
784
|
+
统一为 "Failed to delete '<path>': <reason>",并把服务端错误码
|
|
785
|
+
归一化(file_not_found / is_directory / permission_denied)。
|
|
786
|
+
"""
|
|
749
787
|
try:
|
|
750
788
|
self._lazy_sync_skill_from_path_sync(path)
|
|
751
789
|
self._ensure_synced_sync()
|
|
@@ -755,18 +793,15 @@ class FileOperationsMixin:
|
|
|
755
793
|
"user_id": self.user_id,
|
|
756
794
|
"recursive": recursive,
|
|
757
795
|
})
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
else:
|
|
765
|
-
SYLogger.info(f"[Sandbox] 删除成功: {delete_result.path}")
|
|
766
|
-
return delete_result
|
|
796
|
+
err = result.get("error")
|
|
797
|
+
if err:
|
|
798
|
+
err_msg = _DELETE_ERROR_MAP.get(err, err)
|
|
799
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {err_msg}")
|
|
800
|
+
SYLogger.info(f"[Sandbox] 删除成功: {result.get('path', path)}")
|
|
801
|
+
return DeleteResult(path=result.get("path", path), error=None)
|
|
767
802
|
except Exception as e:
|
|
768
803
|
SYLogger.error(f"[Sandbox] 删除异常: {e}")
|
|
769
|
-
return DeleteResult(path=path, error=
|
|
804
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {e}")
|
|
770
805
|
|
|
771
806
|
async def adelete(
|
|
772
807
|
self: "HTTPSandboxBackend",
|
|
@@ -780,6 +815,8 @@ class FileOperationsMixin:
|
|
|
780
815
|
|
|
781
816
|
_skip_lazy_sync=True 时跳过懒同步(供内部技能覆盖删除用,
|
|
782
817
|
避免「删之前先把要删的技能上传一遍」的浪费)。
|
|
818
|
+
|
|
819
|
+
错误文案对齐 write/delete:统一 "Failed to delete '<path>': <reason>"。
|
|
783
820
|
"""
|
|
784
821
|
try:
|
|
785
822
|
if not _skip_lazy_sync:
|
|
@@ -791,18 +828,15 @@ class FileOperationsMixin:
|
|
|
791
828
|
"user_id": self.user_id,
|
|
792
829
|
"recursive": recursive,
|
|
793
830
|
}, timeout=timeout)
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
else:
|
|
801
|
-
SYLogger.info(f"[Sandbox] 异步删除成功: {delete_result.path}")
|
|
802
|
-
return delete_result
|
|
831
|
+
err = result.get("error")
|
|
832
|
+
if err:
|
|
833
|
+
err_msg = _DELETE_ERROR_MAP.get(err, err)
|
|
834
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {err_msg}")
|
|
835
|
+
SYLogger.info(f"[Sandbox] 异步删除成功: {result.get('path', path)}")
|
|
836
|
+
return DeleteResult(path=result.get("path", path), error=None)
|
|
803
837
|
except Exception as e:
|
|
804
838
|
SYLogger.error(f"[Sandbox] 异步删除异常: {e}")
|
|
805
|
-
return DeleteResult(path=path, error=
|
|
839
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {e}")
|
|
806
840
|
|
|
807
841
|
# ============== 文件存在检查 ==============
|
|
808
842
|
|
|
@@ -1020,10 +1020,13 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
|
|
|
1020
1020
|
"path": remote_path,
|
|
1021
1021
|
"user_id": self._user_id
|
|
1022
1022
|
}, timeout=timeout)
|
|
1023
|
-
|
|
1024
|
-
|
|
1023
|
+
# /ls 返回可能是 list[FileInfo](成功)或 {"error": ...}(路径不存在)。
|
|
1024
|
+
# 路径不存在时不视为错误——相当于无已有子目录可跳过,走全量同步。
|
|
1025
|
+
items = ls_result if isinstance(ls_result, list) else (ls_result or {}).get("files", [])
|
|
1026
|
+
if ls_result and not (isinstance(ls_result, dict) and ls_result.get("error")):
|
|
1027
|
+
for item in items:
|
|
1025
1028
|
if item.get("is_dir", False):
|
|
1026
|
-
existing_subdirs.add(item.get("name", ""))
|
|
1029
|
+
existing_subdirs.add(item.get("name", item.get("path", "").rstrip("/").split("/")[-1]))
|
|
1027
1030
|
if existing_subdirs:
|
|
1028
1031
|
SYLogger.info(
|
|
1029
1032
|
f"[Sandbox] partial 模式,跳过已有子目录: {existing_subdirs}")
|
|
@@ -1398,10 +1401,12 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
|
|
|
1398
1401
|
"path": remote_path,
|
|
1399
1402
|
"user_id": self._user_id
|
|
1400
1403
|
})
|
|
1401
|
-
|
|
1402
|
-
|
|
1404
|
+
# /ls 成功返回 list[FileInfo],路径不存在返回 {"error":...}(不视为错误,走全量)。
|
|
1405
|
+
items = ls_result if isinstance(ls_result, list) else (ls_result or {}).get("files", [])
|
|
1406
|
+
if ls_result and not (isinstance(ls_result, dict) and ls_result.get("error")):
|
|
1407
|
+
for item in items:
|
|
1403
1408
|
if item.get("is_dir", False):
|
|
1404
|
-
existing_subdirs.add(item.get("name", ""))
|
|
1409
|
+
existing_subdirs.add(item.get("name", item.get("path", "").rstrip("/").split("/")[-1]))
|
|
1405
1410
|
if existing_subdirs:
|
|
1406
1411
|
SYLogger.info(
|
|
1407
1412
|
f"[Sandbox] partial 模式,跳过已有子目录: {existing_subdirs}")
|
sycommon/middleware/sandbox.py
CHANGED
|
@@ -103,6 +103,14 @@ async def _cleanup_finished_processes():
|
|
|
103
103
|
# ============== 常量配置 ==============
|
|
104
104
|
|
|
105
105
|
MAX_OUTPUT_BYTES = 100_000
|
|
106
|
+
|
|
107
|
+
# grep 墙钟超时(秒)。对齐 deepagents DEFAULT_GREP_TIMEOUT 的思路:
|
|
108
|
+
# 大目录/死循环 symlink 会让 grep 无限挂住,占满 FastAPI worker。
|
|
109
|
+
_GREP_TIMEOUT = 60
|
|
110
|
+
|
|
111
|
+
# read 文本路径服务端字节上限(对齐 deepagents _READ_COMMAND_TEMPLATE 的
|
|
112
|
+
# MAX_OUTPUT_BYTES=500KiB):超大文本文件不会把整包塞进 HTTP 响应/内存。
|
|
113
|
+
_READ_MAX_OUTPUT_BYTES = 500 * 1024
|
|
106
114
|
# 默认工作目录配置
|
|
107
115
|
# - Linux: /data/sycommon_sandbox (固定路径,自动创建)
|
|
108
116
|
# - macOS/Windows: 使用系统临时目录
|
|
@@ -136,7 +144,6 @@ def _build_init_script(workspace: str) -> str:
|
|
|
136
144
|
safe_workspace = workspace.replace("'", "''")
|
|
137
145
|
return f'''
|
|
138
146
|
$env:SANDBOX_ROOT = '{safe_workspace}'
|
|
139
|
-
$env:SANDBOX_OUTPUT = Join-Path '{safe_workspace}' 'output'
|
|
140
147
|
Set-Location '{safe_workspace}'
|
|
141
148
|
'''
|
|
142
149
|
|
|
@@ -144,9 +151,6 @@ Set-Location '{safe_workspace}'
|
|
|
144
151
|
return f'''
|
|
145
152
|
# 沙箱环境初始化
|
|
146
153
|
export SANDBOX_ROOT={safe_workspace}
|
|
147
|
-
# 产物文件统一输出目录:技能脚本写产物时优先用 $SANDBOX_OUTPUT(等价 $SANDBOX_ROOT/output),
|
|
148
|
-
# 避免落技能目录等不同步 MinIO 的位置。脚本回退默认值用相对 "output"(本地开发无此变量时)。
|
|
149
|
-
export SANDBOX_OUTPUT="$SANDBOX_ROOT/output"
|
|
150
154
|
|
|
151
155
|
# 资源限制(防止失控进程)
|
|
152
156
|
ulimit -f 1048576 2>/dev/null # 单文件最大 1GB
|
|
@@ -355,6 +359,46 @@ def _kill_process_tree(process):
|
|
|
355
359
|
pass
|
|
356
360
|
|
|
357
361
|
|
|
362
|
+
def _translate_virtual_prefixes(command: str) -> str:
|
|
363
|
+
"""把 execute 命令里的沙箱虚拟路径前缀翻译成 $SANDBOX_ROOT 前缀。
|
|
364
|
+
|
|
365
|
+
根因:read_file/ls 等文件工具认虚拟路径(/skills/...、/output/...,由服务端
|
|
366
|
+
resolve_sandbox_path 映射);但 execute 里的 shell(cat/ls/cp/python 等)把
|
|
367
|
+
/skills/ 当真·文件系统绝对路径,沙箱根 / 下没有 /skills → "No such file"。
|
|
368
|
+
agent 因此误判「execute 受限」改走 read_file。
|
|
369
|
+
|
|
370
|
+
这里在命令交给 shell 前,把"作为路径参数出现"的虚拟前缀替换成 $SANDBOX_ROOT。
|
|
371
|
+
关键是**只替换 token 边界**(前导是空格/引号/行首/重定向符/`=`),不替换
|
|
372
|
+
字符串中间出现的,避免误伤(如命令里恰好有形如片段的普通字符串)。
|
|
373
|
+
|
|
374
|
+
仅替换这些已知虚拟前缀;其余路径(用户工作目录下的相对路径、真绝对路径)不动。
|
|
375
|
+
对已是 $SANDBOX_ROOT/... 形式的不重复处理(无 /skills/ token)。
|
|
376
|
+
"""
|
|
377
|
+
if not command or "$SANDBOX_ROOT" in command:
|
|
378
|
+
# 用户已显式用 $SANDBOX_ROOT 时,尊重其写法,不再二次翻译
|
|
379
|
+
# (避免把命令里独立出现的 /skills/ 等误改,多数情况一次替换就够)
|
|
380
|
+
return command
|
|
381
|
+
|
|
382
|
+
import re
|
|
383
|
+
# 虚拟前缀 → 沙箱子目录(与 resolve_sandbox_path 的映射保持一致)
|
|
384
|
+
# 顺序无关紧要,前缀互不互为子串(skills/ vs memory/ vs output/ ...)。
|
|
385
|
+
prefixes = [
|
|
386
|
+
"skills", "memory", "output", "projects", "data",
|
|
387
|
+
"conversation_history", "large_tool_results", "tmp",
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
# token 边界:前导是 空白/引号/重定向(|<>)/行首/=,后接虚拟前缀名再接 /
|
|
391
|
+
# 用负向零宽断言匹配边界,避免命中 "my-skills/"、"some/output/" 这种子串。
|
|
392
|
+
out = command
|
|
393
|
+
for pfx in prefixes:
|
|
394
|
+
pattern = re.compile(
|
|
395
|
+
r'(?:(?<=\s)|(?<=\")|(?<=\')|(?<=^)|(?<==)|(?<=\|)|(?<=<)|(?<=>))'
|
|
396
|
+
+ re.escape("/" + pfx + "/")
|
|
397
|
+
)
|
|
398
|
+
out = pattern.sub("$SANDBOX_ROOT/" + pfx + "/", out)
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
|
|
358
402
|
def _build_execute_command(init_script: str, user_command: str) -> tuple[str, dict]:
|
|
359
403
|
"""构建跨平台的执行命令。
|
|
360
404
|
|
|
@@ -363,15 +407,26 @@ def _build_execute_command(init_script: str, user_command: str) -> tuple[str, di
|
|
|
363
407
|
"""
|
|
364
408
|
if platform.system() == "Windows":
|
|
365
409
|
# Windows: PowerShell 执行
|
|
410
|
+
# 注: Windows 仅本地调试用,未做 stdin 重定向(沙箱生产跑 Linux)。
|
|
366
411
|
full_command = f'''{init_script}
|
|
367
412
|
{user_command}
|
|
368
413
|
'''
|
|
369
414
|
return full_command, {"shell": True}
|
|
370
415
|
else:
|
|
371
416
|
# Linux/macOS: bash heredoc
|
|
417
|
+
# 用户命令包进 `( ... ) </dev/null`: 对整个子 shell 的 stdin 重定向到
|
|
418
|
+
# /dev/null,等价于 subprocess 的 stdin=DEVNULL(对齐 deepagents
|
|
419
|
+
# LocalShellBackend)。作用: 防止读 stdin 的命令(裸 cat / python REPL /
|
|
420
|
+
# ssh / 脚本里的 input())在沙箱非交互环境下挂死等待输入——否则会一直
|
|
421
|
+
# 占用 worker 直到 req.timeout(可达 180s)才被超时杀掉。
|
|
422
|
+
# 之所以要 `( ... )` 包一层: 用户命令可能是复合命令/管道/heredoc,直接
|
|
423
|
+
# `cmd </dev/null` 只对最后一条生效;包进子 shell 后重定向对该子 shell 内
|
|
424
|
+
# 所有命令统一生效。经核查全部技能脚本无正常依赖 stdin 的(手动验证码/
|
|
425
|
+
# 本地报表工具在沙箱里要么有 isatty 守卫走文件方案、要么本就不会触发),
|
|
426
|
+
# DEVNULL 不影响现有功能。
|
|
372
427
|
full_command = f'''bash <<'COMMAND_EOF'
|
|
373
428
|
{init_script}
|
|
374
|
-
{user_command}
|
|
429
|
+
( {user_command} ) </dev/null
|
|
375
430
|
COMMAND_EOF
|
|
376
431
|
'''
|
|
377
432
|
return full_command, {}
|
|
@@ -462,8 +517,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
462
517
|
# 使用统一初始化脚本
|
|
463
518
|
init_script = _build_init_script(workspace)
|
|
464
519
|
|
|
520
|
+
# 把命令里的虚拟路径前缀(/skills/、/output/...)翻译成 $SANDBOX_ROOT,
|
|
521
|
+
# 让 cat/ls/cp/python 等 shell 命令能访问到技能/产物文件(否则 No such file)。
|
|
522
|
+
translated_command = _translate_virtual_prefixes(req.command)
|
|
523
|
+
if translated_command != req.command:
|
|
524
|
+
SYLogger.info(
|
|
525
|
+
f"[Sandbox Server] 虚拟前缀翻译: {req.command!r} -> {translated_command!r}")
|
|
526
|
+
|
|
465
527
|
# 构建跨平台执行命令
|
|
466
|
-
full_command, _ = _build_execute_command(init_script,
|
|
528
|
+
full_command, _ = _build_execute_command(init_script, translated_command)
|
|
467
529
|
|
|
468
530
|
# 设置环境变量
|
|
469
531
|
env = os.environ.copy()
|
|
@@ -471,12 +533,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
471
533
|
env["TEMP"] = os.path.join(workspace, "tmp")
|
|
472
534
|
env["TMP"] = os.path.join(workspace, "tmp")
|
|
473
535
|
env["SANDBOX_ROOT"] = workspace
|
|
474
|
-
env["SANDBOX_OUTPUT"] = os.path.join(workspace, "output")
|
|
475
536
|
|
|
476
537
|
try:
|
|
477
538
|
# 使用 asyncio.create_subprocess_shell 异步执行命令,避免阻塞事件循环
|
|
539
|
+
# stdin=DEVNULL: 双重保险。命令内部已用 `( ... ) </dev/null` 把子 shell
|
|
540
|
+
# stdin 重定向(见 _build_execute_command),这里再在 subprocess 层 DEVNULL,
|
|
541
|
+
# 防止任何遗漏的读 stdin 命令挂住 worker。对齐 deepagents LocalShellBackend。
|
|
478
542
|
process = await asyncio.create_subprocess_shell(
|
|
479
543
|
full_command,
|
|
544
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
480
545
|
stdout=asyncio.subprocess.PIPE,
|
|
481
546
|
stderr=asyncio.subprocess.PIPE,
|
|
482
547
|
cwd=workspace,
|
|
@@ -542,7 +607,7 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
542
607
|
truncated=False
|
|
543
608
|
)
|
|
544
609
|
|
|
545
|
-
@sandbox_router.post("/ls"
|
|
610
|
+
@sandbox_router.post("/ls") # response_model 不固定:dict(error)/list[FileInfo]
|
|
546
611
|
async def ls_info(req: LsRequest):
|
|
547
612
|
"""列出目录内容
|
|
548
613
|
|
|
@@ -558,8 +623,10 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
558
623
|
|
|
559
624
|
def _ls():
|
|
560
625
|
workspace_resolved = os.path.realpath(workspace)
|
|
626
|
+
if not os.path.exists(target_path):
|
|
627
|
+
return None, "path_not_found"
|
|
561
628
|
if not os.path.isdir(target_path):
|
|
562
|
-
return None
|
|
629
|
+
return None, "not_a_directory"
|
|
563
630
|
results = []
|
|
564
631
|
try:
|
|
565
632
|
with os.scandir(target_path) as it:
|
|
@@ -584,15 +651,37 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
584
651
|
results.append(info)
|
|
585
652
|
except Exception as e:
|
|
586
653
|
SYLogger.error(f"[Sandbox Server] 列目录异常: {e}")
|
|
587
|
-
|
|
654
|
+
return None, f"list_failed: {e}"
|
|
655
|
+
return results, None
|
|
588
656
|
|
|
589
|
-
result = await asyncio.to_thread(_ls)
|
|
590
|
-
if
|
|
591
|
-
SYLogger.
|
|
592
|
-
return
|
|
657
|
+
result, err = await asyncio.to_thread(_ls)
|
|
658
|
+
if err:
|
|
659
|
+
SYLogger.info(f"[Sandbox Server] 列目录未命中: {target_path} ({err})")
|
|
660
|
+
return {"error": err}
|
|
593
661
|
SYLogger.info(f"[Sandbox Server] 目录内容: {len(result)} 项")
|
|
594
662
|
return result
|
|
595
663
|
|
|
664
|
+
def _looks_like_text(raw_prefix: bytes) -> bool:
|
|
665
|
+
"""粗判一段字节是否为文本(用于 /read 决定按文本还是二进制返回)。
|
|
666
|
+
|
|
667
|
+
对齐 deepagents BaseSandbox.read 的检测协议(backends/sandbox.py:315-325):
|
|
668
|
+
用增量 UTF-8 解码器,8192 字节前缀若正好截断在多字节字符(中文 3 字节、
|
|
669
|
+
emoji 4 字节)中间,增量解码器会缓存尾部半截序列、不报错;只有真遇到
|
|
670
|
+
非法字节序列才抛 UnicodeDecodeError。
|
|
671
|
+
|
|
672
|
+
旧实现 raw_prefix.decode('utf-8')(strict)会在「中文被 8192 腰斩」时
|
|
673
|
+
必抛 UnicodeDecodeError,把正常的中文文本文件误判成「二进制」,导致
|
|
674
|
+
/read 返回 base64 乱码、agent 只能退而用 grep 兜底。
|
|
675
|
+
"""
|
|
676
|
+
if not raw_prefix:
|
|
677
|
+
return True
|
|
678
|
+
import codecs
|
|
679
|
+
try:
|
|
680
|
+
codecs.getincrementaldecoder('utf-8')().decode(raw_prefix, final=False)
|
|
681
|
+
return True
|
|
682
|
+
except UnicodeDecodeError:
|
|
683
|
+
return False
|
|
684
|
+
|
|
596
685
|
@sandbox_router.post("/read", response_model=ReadResponse)
|
|
597
686
|
async def read_file(req: ReadRequest):
|
|
598
687
|
"""读取文件内容"""
|
|
@@ -633,23 +722,33 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
633
722
|
with open(file_path, 'rb') as f:
|
|
634
723
|
raw_prefix = f.read(8192)
|
|
635
724
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
is_text = False
|
|
725
|
+
# 文本/二进制判定:旧版整段 strict decode 会在「中文被 8192 截断在
|
|
726
|
+
# 多字节边界」时误判为二进制。改用 _looks_like_text(errors='ignore' +
|
|
727
|
+
# 控制字符占比),既修截断 bug 又能挡住真二进制。
|
|
728
|
+
is_text = _looks_like_text(raw_prefix)
|
|
641
729
|
|
|
642
730
|
if is_text:
|
|
643
731
|
lines = []
|
|
644
732
|
lines_read = 0
|
|
733
|
+
# 服务端字节上限(对齐 deepagents _READ_COMMAND_TEMPLATE 的
|
|
734
|
+
# MAX_OUTPUT_BYTES):避免单响应塞入 GB 级文本(超长行/巨大文件),
|
|
735
|
+
# 触顶即截断并附提示,让客户端走 offset/limit 分页续读。
|
|
736
|
+
current_bytes = 0
|
|
737
|
+
truncated = False
|
|
645
738
|
with open(file_path, 'r', encoding='utf-8', newline=None) as f:
|
|
646
739
|
for i, line in enumerate(f):
|
|
647
740
|
if i < req.offset:
|
|
648
741
|
continue
|
|
649
742
|
if lines_read >= req.limit:
|
|
650
743
|
break
|
|
651
|
-
|
|
744
|
+
line_clean = line.rstrip('\n').rstrip('\r')
|
|
745
|
+
line_bytes = len(line_clean.encode('utf-8')) + 1 # +1 for \n
|
|
746
|
+
if current_bytes + line_bytes > _READ_MAX_OUTPUT_BYTES:
|
|
747
|
+
truncated = True
|
|
748
|
+
break
|
|
749
|
+
lines.append(line_clean)
|
|
652
750
|
lines_read += 1
|
|
751
|
+
current_bytes += line_bytes
|
|
653
752
|
|
|
654
753
|
if not lines:
|
|
655
754
|
if req.offset > 0:
|
|
@@ -659,6 +758,11 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
659
758
|
return ReadResponse(content="System reminder: File exists but has empty contents", error=None)
|
|
660
759
|
|
|
661
760
|
content = "\n".join(lines)
|
|
761
|
+
if truncated:
|
|
762
|
+
content += (
|
|
763
|
+
"\n\n[Output was truncated due to size limits. "
|
|
764
|
+
"Continue reading with a larger offset or smaller limit to inspect the rest of the file.]"
|
|
765
|
+
)
|
|
662
766
|
return ReadResponse(content=content, error=None, encoding="utf-8")
|
|
663
767
|
else:
|
|
664
768
|
MAX_BINARY_BYTES = 500 * 1024
|
|
@@ -710,8 +814,21 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
710
814
|
if parent:
|
|
711
815
|
os.makedirs(parent, exist_ok=True)
|
|
712
816
|
|
|
713
|
-
|
|
714
|
-
|
|
817
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:473-475):
|
|
818
|
+
# target 本身若是 symlink(即使 resolve 后仍在 workspace 内,跨用户
|
|
819
|
+
# 也可被植入),拒绝穿过它写。无 O_NOFOLLOW 平台退化为 open()。
|
|
820
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
821
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
822
|
+
flags |= os.O_NOFOLLOW
|
|
823
|
+
if overwrite:
|
|
824
|
+
flags |= getattr(os, "O_TRUNC", 0)
|
|
825
|
+
fd = os.open(file_path, flags, 0o644)
|
|
826
|
+
try:
|
|
827
|
+
with os.fdopen(fd, 'wb') as f:
|
|
828
|
+
f.write(content)
|
|
829
|
+
except Exception:
|
|
830
|
+
fd = -1 # fdopen 已接管 fd;这里仅防误关
|
|
831
|
+
raise
|
|
715
832
|
|
|
716
833
|
action = "覆盖" if (os.path.exists(file_path) and overwrite) else "新建"
|
|
717
834
|
SYLogger.info(
|
|
@@ -765,8 +882,16 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
765
882
|
SYLogger.error(f"[Sandbox Server] 编辑失败: 权限不足 {file_path}")
|
|
766
883
|
return EditResponse(error="permission_denied")
|
|
767
884
|
|
|
768
|
-
|
|
769
|
-
|
|
885
|
+
# 读取用 O_NOFOLLOW 防 symlink(对齐 deepagents filesystem.py:412):
|
|
886
|
+
# 若 file_path 是 symlink,O_NOFOLLOW 会直接报错,不穿过链接读目标。
|
|
887
|
+
read_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
888
|
+
fd = os.open(file_path, read_flags)
|
|
889
|
+
try:
|
|
890
|
+
with os.fdopen(fd, 'rb') as f:
|
|
891
|
+
raw = f.read()
|
|
892
|
+
except Exception:
|
|
893
|
+
fd = -1
|
|
894
|
+
raise
|
|
770
895
|
|
|
771
896
|
try:
|
|
772
897
|
content = raw.decode('utf-8')
|
|
@@ -806,8 +931,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
806
931
|
else:
|
|
807
932
|
new_content = content.replace(matched_old, matched_new, 1)
|
|
808
933
|
|
|
809
|
-
|
|
810
|
-
|
|
934
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:537-539)。
|
|
935
|
+
write_flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
936
|
+
fd = os.open(file_path, write_flags)
|
|
937
|
+
try:
|
|
938
|
+
with os.fdopen(fd, 'wb') as f:
|
|
939
|
+
f.write(new_content.encode('utf-8'))
|
|
940
|
+
except Exception:
|
|
941
|
+
fd = -1
|
|
942
|
+
raise
|
|
811
943
|
|
|
812
944
|
SYLogger.info(f"[Sandbox Server] 编辑成功: {file_path} (替换 {count} 处)")
|
|
813
945
|
virtual_path = _to_virtual_path(file_path, workspace)
|
|
@@ -833,8 +965,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
833
965
|
parent = os.path.dirname(file_path)
|
|
834
966
|
if parent:
|
|
835
967
|
os.makedirs(parent, exist_ok=True)
|
|
836
|
-
|
|
837
|
-
|
|
968
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:913-916)。
|
|
969
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
970
|
+
fd = os.open(file_path, flags, 0o644)
|
|
971
|
+
try:
|
|
972
|
+
with os.fdopen(fd, 'wb') as f:
|
|
973
|
+
f.write(content)
|
|
974
|
+
except Exception:
|
|
975
|
+
fd = -1
|
|
976
|
+
raise
|
|
838
977
|
return UploadResponse(path=file_path, error=None)
|
|
839
978
|
|
|
840
979
|
try:
|
|
@@ -894,8 +1033,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
894
1033
|
parent = os.path.dirname(file_path)
|
|
895
1034
|
if parent:
|
|
896
1035
|
os.makedirs(parent, exist_ok=True)
|
|
897
|
-
|
|
898
|
-
|
|
1036
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py)。
|
|
1037
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
1038
|
+
fd = os.open(file_path, flags, 0o644)
|
|
1039
|
+
try:
|
|
1040
|
+
with os.fdopen(fd, 'wb') as f:
|
|
1041
|
+
f.write(content)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
fd = -1
|
|
1044
|
+
raise
|
|
899
1045
|
results.append(BatchUploadResult(path=file_entry.path))
|
|
900
1046
|
success_count += 1
|
|
901
1047
|
except Exception as e:
|
|
@@ -1075,7 +1221,9 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1075
1221
|
return GrepResponse(error=str(e))
|
|
1076
1222
|
|
|
1077
1223
|
quoted_path = shlex.quote(search_path)
|
|
1078
|
-
|
|
1224
|
+
# -Z:文件名与行数据之间用 NUL 分隔(对齐 deepagents sandbox.py:783 的 grep -rHnFZ),
|
|
1225
|
+
# 文件名含 ":" 时也能无歧义解析;旧实现按冒号切,遇到含冒号文件名会静默错路径/错行号。
|
|
1226
|
+
grep_opts = "-rHnFZ"
|
|
1079
1227
|
|
|
1080
1228
|
# 安全处理 glob_pattern,使用 shlex.quote 防止命令注入
|
|
1081
1229
|
glob_pattern = f"--include={shlex.quote(req.glob)}" if req.glob else ""
|
|
@@ -1087,11 +1235,23 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1087
1235
|
# 使用 asyncio.create_subprocess_shell 异步执行
|
|
1088
1236
|
proc = await asyncio.create_subprocess_shell(
|
|
1089
1237
|
cmd,
|
|
1238
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1090
1239
|
stdout=asyncio.subprocess.PIPE,
|
|
1091
1240
|
stderr=asyncio.subprocess.PIPE,
|
|
1092
1241
|
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
1093
1242
|
)
|
|
1094
|
-
|
|
1243
|
+
# 墙钟超时(对齐 deepagents DEFAULT_GREP_TIMEOUT 思路):大目录/死循环 symlink
|
|
1244
|
+
# 会让 grep 无限挂住,进而占满 FastAPI worker。超时即杀进程组,返回 partial_error。
|
|
1245
|
+
try:
|
|
1246
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_GREP_TIMEOUT)
|
|
1247
|
+
except asyncio.TimeoutError:
|
|
1248
|
+
await _kill_process_tree(proc)
|
|
1249
|
+
SYLogger.warning(
|
|
1250
|
+
f"[Sandbox Server] grep 超时({ _GREP_TIMEOUT}s),已终止: pattern={req.pattern!r}, path={req.path!r}")
|
|
1251
|
+
return GrepResponse(
|
|
1252
|
+
matches=[],
|
|
1253
|
+
partial_error=f"grep timed out after {_GREP_TIMEOUT}s. Try a more specific pattern or a narrower path.",
|
|
1254
|
+
)
|
|
1095
1255
|
result_text = stdout.decode('utf-8') if stdout else ''
|
|
1096
1256
|
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ''
|
|
1097
1257
|
|
|
@@ -1107,44 +1267,53 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1107
1267
|
def _parse_grep_results():
|
|
1108
1268
|
workspace_resolved = os.path.realpath(workspace)
|
|
1109
1269
|
matches = []
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1270
|
+
# grep -Z 输出每条匹配:<path>\0<lineno>:<text>\n,连续拼接。
|
|
1271
|
+
# 按 NUL split 后:
|
|
1272
|
+
# seg[0] = 第一个 path
|
|
1273
|
+
# seg[i>0] = "<lineno>:<text>\n<next_path>"(数据 + 紧跟的下一个 path)
|
|
1274
|
+
# 最后一段 = "<lineno>:<text>\n"(无后续 path)
|
|
1275
|
+
# 用「每段按第一个 \n 切:前半是 lineno:text,后半是 next_path」解析,
|
|
1276
|
+
# 这样文件名含 ":"、文本含 ":" 都不会错位(path 整段不按冒号切)。
|
|
1277
|
+
segments = result_text.split("\0")
|
|
1278
|
+
if not segments:
|
|
1279
|
+
return matches
|
|
1280
|
+
current_path = segments[0].rstrip("\n")
|
|
1281
|
+
for seg in segments[1:]:
|
|
1282
|
+
nl = seg.find("\n")
|
|
1283
|
+
if nl == -1:
|
|
1284
|
+
data_part, next_path = seg, None
|
|
1285
|
+
else:
|
|
1286
|
+
data_part = seg[:nl]
|
|
1287
|
+
rest = seg[nl + 1:].rstrip("\n")
|
|
1288
|
+
next_path = rest or None
|
|
1289
|
+
# data_part = "lineno:text",按第一个冒号切(文本可含冒号)
|
|
1290
|
+
colon = data_part.find(":")
|
|
1291
|
+
if colon > 0 and data_part[:colon].isdigit():
|
|
1292
|
+
line_num = int(data_part[:colon])
|
|
1293
|
+
text_part = data_part[colon + 1:]
|
|
1294
|
+
if current_path:
|
|
1295
|
+
path_part = current_path
|
|
1296
|
+
# 路径安全检查(对齐 deepagents 0.6.4):
|
|
1297
|
+
# resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
|
|
1298
|
+
try:
|
|
1299
|
+
resolved = os.path.realpath(path_part)
|
|
1300
|
+
if not _is_path_within(resolved, workspace_resolved):
|
|
1301
|
+
SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
|
|
1302
|
+
continue
|
|
1303
|
+
except OSError:
|
|
1304
|
+
continue
|
|
1305
|
+
# 将绝对路径转换为虚拟路径
|
|
1306
|
+
try:
|
|
1307
|
+
virtual_path_part = _to_virtual_path(path_part, workspace)
|
|
1308
|
+
except ValueError:
|
|
1309
|
+
virtual_path_part = path_part
|
|
1310
|
+
matches.append(GrepMatch(
|
|
1311
|
+
path=virtual_path_part,
|
|
1312
|
+
line=line_num,
|
|
1313
|
+
text=text_part
|
|
1314
|
+
))
|
|
1315
|
+
if next_path:
|
|
1316
|
+
current_path = next_path
|
|
1148
1317
|
return matches
|
|
1149
1318
|
|
|
1150
1319
|
matches = await asyncio.to_thread(_parse_grep_results)
|
|
@@ -147,16 +147,16 @@ sycommon/agent/acp/tool.py,sha256=yt6ohKL2y7wIZMNiLv-m5oCg5u1p5GAbqgjzQVt285Y,10
|
|
|
147
147
|
sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
|
|
148
148
|
sycommon/agent/mcp/models.py,sha256=zda4ho-UTYOJ5oPAZPZ-vxhfDRLghUCsMroHnA2A2QQ,1490
|
|
149
149
|
sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBqZBZs,10960
|
|
150
|
-
sycommon/agent/middleware/sandbox_path_guard.py,sha256=
|
|
150
|
+
sycommon/agent/middleware/sandbox_path_guard.py,sha256=K1SnxA8pDxkCSucOkranwUvC9_8rd3l_-TIR8gMMTK8,8102
|
|
151
151
|
sycommon/agent/middleware/sensitive_guard.py,sha256=ApmC094ogG4jIwEWRaUawTpQSDs722k0OTjdeAEQ7Qc,22159
|
|
152
152
|
sycommon/agent/middleware/sitecustomize.py,sha256=Bt7XFnWV_LJo89l858AGp5VUkXwefpLGmjkCtLe-CMw,4800
|
|
153
|
-
sycommon/agent/middleware/skill_api_whitelist.py,sha256=
|
|
153
|
+
sycommon/agent/middleware/skill_api_whitelist.py,sha256=2qVsrwS6VkkPKHrvjJMNUfBDokdMJkP-mn-V-ZCn7X8,16643
|
|
154
154
|
sycommon/agent/middleware/skill_wl_check.py,sha256=rCJ9F6aWPh8tVQBIPmcq2lKDsfwiJQduwSUku_8kXfs,3952
|
|
155
155
|
sycommon/agent/middleware/skill_write_guard.py,sha256=ZbeRysGBqn0knjmDWRgPu7c8Pr5lGnjFgeEpAukSSWQ,2702
|
|
156
156
|
sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
|
|
157
157
|
sycommon/agent/sandbox/consistent_hash.py,sha256=8Jgk-W4NAD2-u5_vKRVlPmql_e0Vy4aNORiXOuzvIrs,7277
|
|
158
|
-
sycommon/agent/sandbox/file_ops.py,sha256=
|
|
159
|
-
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=
|
|
158
|
+
sycommon/agent/sandbox/file_ops.py,sha256=VTK7xduMMWLVTr9JViMnmPeh7s8Ojr8YwYA4ovMZZg0,37138
|
|
159
|
+
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=1Cj_JYdvwP3PFPa_4xFPnrGdt67Wp5iQdoziSzjeqoE,70481
|
|
160
160
|
sycommon/agent/sandbox/minio_sync.py,sha256=NuvgI2SQb6pdegei_a4pCqrwTjC8DTxkCvM3ABadljs,29326
|
|
161
161
|
sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
|
|
162
162
|
sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
|
|
@@ -234,7 +234,7 @@ sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26N
|
|
|
234
234
|
sycommon/middleware/middleware.py,sha256=1qA_0rT7pv1xTfuvA0kdYkxVOHYT6roSSNO7Cmf2wYg,1778
|
|
235
235
|
sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
|
|
236
236
|
sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
|
|
237
|
-
sycommon/middleware/sandbox.py,sha256=
|
|
237
|
+
sycommon/middleware/sandbox.py,sha256=CFisRjXs_hqktuxElDmZB-xKX2vb-Rk2xHE7pc0wf_w,61417
|
|
238
238
|
sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
|
|
239
239
|
sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
|
|
240
240
|
sycommon/middleware/tool_result_truncation.py,sha256=SREo6tb_RDHxqiQdO0N82pio-Cpo0YSrHNa1sASUafM,12429
|
|
@@ -308,8 +308,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
|
|
|
308
308
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
309
309
|
sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
|
|
310
310
|
sycommon/xxljob/xxljob_service.py,sha256=1yifwIBNGsCIxLnQjHKiBlbsigc_zvPH-dMTZcNxe-Q,7649
|
|
311
|
-
sycommon_python_lib-0.2.
|
|
312
|
-
sycommon_python_lib-0.2.
|
|
313
|
-
sycommon_python_lib-0.2.
|
|
314
|
-
sycommon_python_lib-0.2.
|
|
315
|
-
sycommon_python_lib-0.2.
|
|
311
|
+
sycommon_python_lib-0.2.7a23.dist-info/METADATA,sha256=-nxp2cHfOFVzCFhRco15gVP5z2m1FX-offmy-DKZDs4,7962
|
|
312
|
+
sycommon_python_lib-0.2.7a23.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
313
|
+
sycommon_python_lib-0.2.7a23.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
|
|
314
|
+
sycommon_python_lib-0.2.7a23.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
|
|
315
|
+
sycommon_python_lib-0.2.7a23.dist-info/RECORD,,
|
|
File without changes
|
{sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.7a21.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/top_level.txt
RENAMED
|
File without changes
|