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.
@@ -61,7 +61,13 @@ _PATH_TOOLS = {
61
61
 
62
62
  # 宿主沙箱根前缀(用于 execute 命令串扫描;shell 里无法安全改写,只能拦)。
63
63
  # 命中即视为「引用了宿主沙箱真实路径」,一律拦。
64
- _HOST_SANDBOX_RE = re.compile(re.escape(_WORKSPACE_ROOT) + r'(?:/|\\)')
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
- self._pushed: bool = False # 注入脚本是否已推送(每 backend 实例一次)
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
- """把 sitecustomize.py + skill_wl_check.py 推到沙箱 workspace 内的 .wl 目录。
242
+ """把 loader + 2 个 base64 payload 推到沙箱 workspace 内的 .wl 目录。
159
243
 
160
- 沙箱强制路径隔离:任何绝对路径都会被 resolve_sandbox_path 映射进用户 workspace,
161
- 真实 site-packages 无法写入。故改用「推到 workspace/.wl/ + export PYTHONPATH」
162
- 的方案:Python 启动时若 .wl PYTHONPATH 上,会自动 import sitecustomize。
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
- files = []
173
- for src, dst in _PUSH_FILES:
174
- content = _load_resource_bytes(src)
175
- files.append((dst, content))
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
- self._pushed = True
178
- SYLogger.info("[Whitelist] 注入脚本已推送至沙箱 %s", _WL_DIR)
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
- if tc.get("name") != "execute":
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
- # 服务端 /ls 失败时返回 [](无 error 字段),HTTP 层失败已进 except
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["error"])
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=str(e))
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["error"])
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=str(e))
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
- delete_result = DeleteResult(
759
- path=result.get("path", path),
760
- error=result.get("error"),
761
- )
762
- if delete_result.error:
763
- SYLogger.error(f"[Sandbox] 删除失败: {delete_result.error}")
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=str(e))
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
- delete_result = DeleteResult(
795
- path=result.get("path", path),
796
- error=result.get("error"),
797
- )
798
- if delete_result.error:
799
- SYLogger.error(f"[Sandbox] 异步删除失败: {delete_result.error}")
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=str(e))
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
- if ls_result and not ls_result.get("error"):
1024
- for item in ls_result.get("files", ls_result.get("children", [])):
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
- if ls_result and not ls_result.get("error"):
1402
- for item in ls_result.get("files", ls_result.get("children", [])):
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}")
@@ -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, req.command)
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", response_model=list[FileInfo])
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
- return results
654
+ return None, f"list_failed: {e}"
655
+ return results, None
588
656
 
589
- result = await asyncio.to_thread(_ls)
590
- if result is None:
591
- SYLogger.error(f"[Sandbox Server] 列目录失败: 目录不存在 {target_path}")
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
- try:
637
- raw_prefix.decode('utf-8')
638
- is_text = True
639
- except UnicodeDecodeError:
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
- lines.append(line.rstrip('\n').rstrip('\r'))
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
- with open(file_path, 'wb') as f:
714
- f.write(content)
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
- with open(file_path, 'rb') as f:
769
- raw = f.read()
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
- with open(file_path, 'wb') as f:
810
- f.write(new_content.encode('utf-8'))
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
- with open(file_path, 'wb') as f:
837
- f.write(content)
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
- with open(file_path, 'wb') as f:
898
- f.write(content)
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
- grep_opts = "-rHnF"
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
- stdout, stderr = await proc.communicate()
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
- for line in result_text.strip().split("\n"):
1111
- if not line:
1112
- continue
1113
- first_colon = line.find(":")
1114
- if first_colon == -1:
1115
- continue
1116
- second_colon = line.find(":", first_colon + 1)
1117
- if second_colon == -1:
1118
- continue
1119
-
1120
- path_part = line[:first_colon]
1121
-
1122
- # 路径安全检查(对齐 deepagents 0.6.4):
1123
- # resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
1124
- try:
1125
- resolved = os.path.realpath(path_part)
1126
- if not _is_path_within(resolved, workspace_resolved):
1127
- SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
1128
- continue
1129
- except OSError:
1130
- continue
1131
-
1132
- # 将绝对路径转换为虚拟路径
1133
- try:
1134
- virtual_path_part = _to_virtual_path(path_part, workspace)
1135
- except ValueError:
1136
- virtual_path_part = path_part
1137
- try:
1138
- line_num = int(line[first_colon + 1:second_colon])
1139
- except ValueError:
1140
- continue
1141
- text_part = line[second_colon + 1:]
1142
-
1143
- matches.append(GrepMatch(
1144
- path=virtual_path_part,
1145
- line=line_num,
1146
- text=text_part
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.7a21
3
+ Version: 0.2.7a23
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -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=5hgtf-9aqJ16BQuDwda3gXL12RbwPcexhYQPEV6H33M,7684
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=h3CntW-FIdAfrKMo4QoC6lA35f8Y1iH7hcHldYMi4lw,10946
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=e9OwBZZfOtFvVhJ1Lixggi4eD2Djn56naZLiF_YIpa0,35128
159
- sycommon/agent/sandbox/http_sandbox_backend.py,sha256=2rF33c1x1n2c1H6P_H-AzS7_VoLbDD1xk3l7ZSwJSj8,69863
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=e_vGPHtnLJKYGwRJ8k0-ErgS_cEODoY_k3mAAphKwf8,50965
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.7a21.dist-info/METADATA,sha256=rB5bXbr-EBWLaQ0-cscQQ2gNOlOuZhlu2Lu0M4zoHUg,7962
312
- sycommon_python_lib-0.2.7a21.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
313
- sycommon_python_lib-0.2.7a21.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
314
- sycommon_python_lib-0.2.7a21.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
315
- sycommon_python_lib-0.2.7a21.dist-info/RECORD,,
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,,