sycommon-python-lib 0.2.8a4__py3-none-any.whl → 0.2.8a6__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.
@@ -1274,21 +1274,6 @@ async def create_deep_agent(
1274
1274
  on_evaluation=rubric_eval_collector.on_evaluation,
1275
1275
  ))
1276
1276
 
1277
- # SkillApiWhitelistMiddleware(旧管控层):已被出网收口取代——
1278
- # 沙箱网关(egress gateway)做 path 白名单+凭据注入,iptables 硬墙(egress_wall)
1279
- # 在内核层挡 node/裸socket。旧的 .wl 推送/sitecustomize monkeypatch/shell-guard
1280
- # 不再注册(子进程 env 零密钥后 sitecustomize 的 SYT_API_AUTHZ 注入也已由网关承接)。
1281
- # 回滚开关: 环境变量 SANDBOX_LEGACY_WHITELIST=1 时恢复注册(应急用)。
1282
- if sandbox_backend and os.environ.get("SANDBOX_LEGACY_WHITELIST", "") == "1":
1283
- try:
1284
- from sycommon.agent.middleware.skill_api_whitelist import (
1285
- SkillApiWhitelistMiddleware)
1286
- middleware_list.append(
1287
- SkillApiWhitelistMiddleware(sandbox_backend))
1288
- SYLogger.warning("[DeepAgent] 旧白名单中间件已启用(SANDBOX_LEGACY_WHITELIST=1 应急模式)")
1289
- except Exception as _wl_err:
1290
- SYLogger.warning(f"[DeepAgent] 技能接口白名单中间件加载失败(忽略): {_wl_err}")
1291
-
1292
1277
  agent_kwargs = {
1293
1278
  "model": raw_model,
1294
1279
  "tools": config.tools or [get_current_date],
@@ -13,8 +13,8 @@ EgressPolicy 来自沙箱自己的 Nacos dataId(shengye-platform-sandbox)的
13
13
  EgressPolicy 段(见 sycommon/config/EgressPolicyConfig.py),Nacos 热更新后
14
14
  调 refresh_egress_wall() 重配。
15
15
 
16
- 这是 L3/L4 内核级硬墙,语言无关、绕不过——比应用层 hook(sitecustomize/
17
- LD_PRELOAD/HTTPS_PROXY)强且无盲区。
16
+ 这是 L3/L4 内核级硬墙,语言无关、绕不过——比旧应用层 hook(已删的
17
+ monkeypatch/LD_PRELOAD 等)强且无盲区。
18
18
 
19
19
  设计要点:
20
20
  - owner-match(-m owner --uid-owner):只锁 child uid,沙箱服务进程(root/其它 uid)
@@ -971,6 +971,20 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
971
971
  env["TEMP"] = os.path.join(workspace, "tmp")
972
972
  env["TMP"] = os.path.join(workspace, "tmp")
973
973
  env["SANDBOX_ROOT"] = workspace
974
+ # 签名型凭据豁免(企微文档机器人):WECOM_DOC_BOT_* 用于【本地 HMAC 签名】
975
+ # (sign=sha256(secret+bot_id+t+nonce)),签名必须在发起方算,网关无法代注
976
+ # ——这是收口「零密钥」的已知例外。流量仍走网关(URL 指网关+X-Upstream-Host,
977
+ # 受白名单/审计管制),只是 secret 必须到脚本手里。仅当凭据包里有才注入。
978
+ try:
979
+ from sycommon.middleware.sandbox_gateway import credential_store
980
+ _c = credential_store().any_user()
981
+ _creds = credential_store().get(_c) if _c else None
982
+ for _k in ("WECOM_DOC_BOT_ID", "WECOM_DOC_BOT_SECRET"):
983
+ _v = (_creds or {}).get(_k)
984
+ if _v:
985
+ env[_k] = str(_v)
986
+ except Exception:
987
+ pass
974
988
  # 出网网关:子进程 SYT_BASE_URL 指向网关(http://127.0.0.1:port),
975
989
  # HTTPS_PROXY 让 requests/httpx/curl/wget 自动走网关。node/裸 socket 不读
976
990
  # proxy 的运行时,由后续 seccomp 硬墙兜底(只放行网关 host:port)。
@@ -6,7 +6,7 @@
6
6
  1. 按「目标 host → 凭据形态」注入正确凭据(SYT 用 Authorization Bearer +
7
7
  X-Sy-Api-Authz;OA 系用 SYU-Token + SYU-Url 改写;企微 gettoken 用
8
8
  URL query 的 corpid/corpsecret;内网服务无鉴权透传)。
9
- 2. 复用 skill_wl_check.match path 白名单校验,不在白名单 → 403。
9
+ 2. host/path 白名单校验(EgressPolicy 驱动),不在名单 → 403。
10
10
  3. 删子进程自带的凭据头(Authorization/SYU-Token/X-Sy-Api-Authz),由网关
11
11
  重新注入正确值(防子进程塞脏值/旧值)。
12
12
  4. 审计:每个请求记录 user/host/path/状态码/字节量。
@@ -116,15 +116,22 @@ def _strip_cred_headers(headers) -> dict:
116
116
 
117
117
 
118
118
  def _apply_credentials(method: str, target_url: str, headers: dict,
119
- creds: dict) -> tuple[str, dict]:
119
+ creds: dict, upstream_host: str = "") -> tuple[str, dict]:
120
120
  """按目标 host/path 改写 URL(query 注入)+ 注入凭据头。返回 (url, headers)。
121
121
 
122
122
  网关→真上游的请求构造在此完成。host 路由表见模块 docstring。
123
+ upstream_host: 请求头 X-Upstream-Host 的值(网关 origin 模式下 URL host 是
124
+ 网关自身,真实目标在该头里)——凭据路由优先按它判定,其次按 URL host。
123
125
  """
124
126
  host = (urlparse(target_url).hostname or "").lower()
127
+ # 网关 origin 模式:URL host 是 127.0.0.1(网关),真实目标在 X-Upstream-Host;
128
+ # 凭据路由按真实目标判定(取两者中非网关地址的那个)。
129
+ route_host = host
130
+ if upstream_host:
131
+ route_host = (urlparse(f"https://{upstream_host}").hostname or "").lower()
125
132
 
126
133
  # --- SYT 网关:Bearer + X-Sy-Api-Authz + X-Host-Name ---
127
- if _match_syt_upstream(host, creds.get("SYT_BASE_URL", "")):
134
+ if _match_syt_upstream(route_host, creds.get("SYT_BASE_URL", "")):
128
135
  token = creds.get("SYT_TOKEN", "")
129
136
  if token:
130
137
  headers["Authorization"] = f"Bearer {token}"
@@ -137,7 +144,7 @@ def _apply_credentials(method: str, target_url: str, headers: dict,
137
144
  return target_url, headers
138
145
 
139
146
  # --- 企微 gettoken:corpid/corpsecret 在 URL query ---
140
- if _is_wecom(host):
147
+ if _is_wecom(route_host):
141
148
  corp_id = creds.get("WECOM_CORP_ID", "")
142
149
  corp_secret = creds.get("WECOM_CORP_SECRET", "")
143
150
  if corp_id and corp_secret and "/cgi-bin/gettoken" in target_url:
@@ -146,10 +153,12 @@ def _apply_credentials(method: str, target_url: str, headers: dict,
146
153
  q.setdefault("corpid", corp_id)
147
154
  q.setdefault("corpsecret", corp_secret)
148
155
  target_url = urlunparse(parsed._replace(query=urlencode(q)))
156
+ # 企微文档机器人(wecom.py 的 MCP 网关)用 DOC_BOT 凭据签名——不在此注入
157
+ # (wecom.py 本地算签名),只保证 gettoken 的 corp 凭据注入。
149
158
  return target_url, headers
150
159
 
151
160
  # --- OA MCP 后端:SYU-Token + SYU-Url(改写为真上游 base)---
152
- if _is_oa_backend(host):
161
+ if _is_oa_backend(route_host):
153
162
  token = creds.get("SYT_TOKEN", "")
154
163
  syt_base = (creds.get("SYT_BASE_URL") or "").rstrip("/")
155
164
  if token:
@@ -231,7 +240,7 @@ def _check_allowlist(target_url: str, creds: dict,
231
240
 
232
241
 
233
242
  def _url_allowed(url: str, allowlist: list[str]) -> bool:
234
- """与 skill_wl_check._is_allowed 同构的 path 级前缀匹配(不依赖 env)。"""
243
+ """path 级前缀匹配(斜杠边界)。"""
235
244
  try:
236
245
  p = urlparse(url if "://" in url else "http://" + str(url))
237
246
  except Exception:
@@ -412,9 +421,23 @@ class EgressGateway:
412
421
  syt_base = (creds.get("SYT_BASE_URL") or "").rstrip("/")
413
422
  if upstream_host:
414
423
  # SSRF 防护:上游 host 禁止指向回环/本机/私有网段(本机服务含沙箱
415
- # 自身 API,免鉴权,打穿即跨用户任意执行)。非回环的 10.x/172.16-31.x/
416
- # 192.168.x 内网目标按 EgressPolicy 判定(directAllow 命中才放)。
417
- if _is_loopback_or_private(upstream_host):
424
+ # 自身 API,免鉴权,打穿即跨用户任意执行)。例外:EgressPolicy
425
+ # viaGateway/directAllow 显式登记的【信任内网上游】(如 OA MCP
426
+ # ai.syitservice.com→10.10.1.171)——它们是内网服务但有意经营,
427
+ # 不属于 SSRF 攻击面(策略由 _check_allowlist 继续管)。
428
+ policy = None
429
+ try:
430
+ from sycommon.config.EgressPolicyConfig import EgressPolicyConfig
431
+ policy = EgressPolicyConfig.from_config()
432
+ except Exception:
433
+ policy = None
434
+ trusted = []
435
+ if policy is not None:
436
+ trusted = list(policy.viaGateway) + list(policy.directAllow)
437
+ uh_hostname = upstream_host.split(":")[0] if upstream_host.count(":") <= 1 \
438
+ else upstream_host
439
+ if (_is_loopback_or_private(upstream_host)
440
+ and not EgressPolicyConfig._host_match(uh_hostname.lower(), trusted)):
418
441
  SYLogger.warning(
419
442
  f"[EgressGateway] 拒绝内网/回环上游(SSRF 防护): {upstream_host}")
420
443
  return web.Response(status=403, text="gateway: upstream host not allowed")
@@ -446,9 +469,11 @@ class EgressGateway:
446
469
  continue
447
470
  fwd_headers[k] = v
448
471
 
449
- # 注入凭据头 / 改写 URL(query 注入)
472
+ # 注入凭据头 / 改写 URL(query 注入)。
473
+ # upstream_host 透传给凭据路由:网关 origin 模式下 URL host 是网关自身,
474
+ # 真实目标在 X-Upstream-Host——OA(ai.sy)/企微按它判路由。
450
475
  target_url, fwd_headers = _apply_credentials(
451
- request.method, target_url, fwd_headers, creds)
476
+ request.method, target_url, fwd_headers, creds, upstream_host)
452
477
 
453
478
  # 读请求体(上传场景)
454
479
  body = await request.read() if request.can_read_body else None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.8a4
3
+ Version: 0.2.8a6
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -136,7 +136,7 @@ sycommon/services.py,sha256=BalCSLqx0aictqEsyUOUXimjqdMGbZgZyd0MqViaBI0,27315
136
136
  sycommon/agent/__init__.py,sha256=mxceAeUifQ-DKvWp7ZEJIFlmOCb5wpYHPGQw3rwEN8I,4378
137
137
  sycommon/agent/agent_manager.py,sha256=UhhaekEumT7g4v_Z1UB4jTp13X0n8M8erYaQdkGGWkA,13620
138
138
  sycommon/agent/chat_events.py,sha256=t7qWa6OrIWLfqtd1AnqaVP67QWkn1JxpCBTZYQpoLuM,14226
139
- sycommon/agent/deep_agent.py,sha256=aH2SM-TXacbW8gziFMlBkPOB4CbDzC3CCly5v6HWrXY,88388
139
+ sycommon/agent/deep_agent.py,sha256=kDEdlXKha2moNMNUA0FIP99gCkosyi0WohcTqB9uYo8,87346
140
140
  sycommon/agent/multi_agent_team.py,sha256=227UgO0XrrPxvQE4xXTNCCYq-5WiUi13r_AUolTpLS4,34719
141
141
  sycommon/agent/reasoning_content_patch.py,sha256=HZ_If0qVDHlz_b1mDT3tuTqbExhzRVhP4VgsoDt0JLc,3945
142
142
  sycommon/agent/resume_compactor.py,sha256=CJp3AUqeStr8U2JeWP31DyulomXZdrkLL4kVtLxnwn8,20849
@@ -152,9 +152,6 @@ sycommon/agent/mcp/tool_loader.py,sha256=pwhZoSoZuQVYo6BgfdMoRHlGnRPDRxvOvxEgvUM
152
152
  sycommon/agent/middleware/model_request_prep.py,sha256=W0dTgdFo6RI0Tg9jdN7jSB9QOroQR5XHIrm2hw5lslk,7618
153
153
  sycommon/agent/middleware/sandbox_path_guard.py,sha256=K1SnxA8pDxkCSucOkranwUvC9_8rd3l_-TIR8gMMTK8,8102
154
154
  sycommon/agent/middleware/sensitive_guard.py,sha256=Ql4BUwF0rNOcpqB7OOHTaO0-65MTEJ9Qk-sC6p7SIQ4,31834
155
- sycommon/agent/middleware/sitecustomize.py,sha256=2EUuYvmbmNXfCuW3uF_shh4Egt4kqmkPgTUaQGDWt5g,9781
156
- sycommon/agent/middleware/skill_api_whitelist.py,sha256=YjkqUzylOcRfNH8cd43lKpHIXCp9ql-3yxMAM9fSqh4,22154
157
- sycommon/agent/middleware/skill_wl_check.py,sha256=rCJ9F6aWPh8tVQBIPmcq2lKDsfwiJQduwSUku_8kXfs,3952
158
155
  sycommon/agent/middleware/skill_write_guard.py,sha256=ZLW-OIG56z5LvLRMaMnryhPz_O5PbrdTLuHTfC3PveA,3101
159
156
  sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
160
157
  sycommon/agent/sandbox/consistent_hash.py,sha256=8Jgk-W4NAD2-u5_vKRVlPmql_e0Vy4aNORiXOuzvIrs,7277
@@ -183,7 +180,6 @@ sycommon/config/PgConfig.py,sha256=Hs9LwgIxSBxcFP16oq18N6Gq9hU2qVl4-7bPfd-ON_s,2
183
180
  sycommon/config/RedisConfig.py,sha256=gIa4BS8L_HdmBg9Dkv3cuIK6CU9zt9RodZOJUuUlh5Y,5235
184
181
  sycommon/config/RerankerConfig.py,sha256=35sVwzus2IscvTHnCG63Orl2pC-pMsrVi6wAGDmOH3U,341
185
182
  sycommon/config/SentryConfig.py,sha256=OsLb3G9lTsCSZ7tWkcXWJHmvfILQopBxje5pjnkFJfo,320
186
- sycommon/config/SkillApiWhitelistConfig.py,sha256=-fimbAofnYSZRhksmvg_rrZOJPc76wSWClH57L1xg-s,4059
187
183
  sycommon/config/XxlJobConfig.py,sha256=VSG6dn9ysfUVunOs7PqugyZUGJWmX_cEePz2ZCfqHtU,392
188
184
  sycommon/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
189
185
  sycommon/config/config_change_notifier.py,sha256=7qQe7vdBS1zohEfVq0d5IaDqYsLdgd2ckF4mKKgh0gE,6848
@@ -234,13 +230,13 @@ sycommon/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
234
230
  sycommon/middleware/context.py,sha256=Skwq0IAXJmyVTZCYoNbjWotExmm2LgxS-dlENQ4bnUs,1188
235
231
  sycommon/middleware/cors.py,sha256=0B5d_ovD56wcH9TfktRs88Q09R9f8Xx5h5ALWYvE8Iw,600
236
232
  sycommon/middleware/docs.py,sha256=bVdDBIHXGVBv562MQLSroa1DgHoObxR9gFzv71PIejg,1187
237
- sycommon/middleware/egress_wall.py,sha256=BWp9M0SG4YH1b_bLeO1NB2wXo66SRoZxmyKeNvHjetw,11756
233
+ sycommon/middleware/egress_wall.py,sha256=z66EInw-tjy7UnPzP6lAjBqSoMKi2FbPKGVqRV-3qpY,11759
238
234
  sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26NOao,2883
239
235
  sycommon/middleware/middleware.py,sha256=1qA_0rT7pv1xTfuvA0kdYkxVOHYT6roSSNO7Cmf2wYg,1778
240
236
  sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
241
237
  sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
242
- sycommon/middleware/sandbox.py,sha256=q4meNKKonqviamB4iDcX92pfSNZ7PLwjikfeCjdxr2Q,101543
243
- sycommon/middleware/sandbox_gateway.py,sha256=MdvzYz-LLicwX9YkwreZA7OUTR9WTwVi6VW4e20wQWw,23298
238
+ sycommon/middleware/sandbox.py,sha256=UMSFteEKhzbL-G_o0BypIc6MftmQd5ICzK8eWgJhhq8,102376
239
+ sycommon/middleware/sandbox_gateway.py,sha256=pwW0n-U7JVO3oE1l1L_7P55hcnI0lQMGyRPfFNpMbBE,24920
244
240
  sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
245
241
  sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
246
242
  sycommon/middleware/tool_result_truncation.py,sha256=xtwBf8q11Z8EPzmo3Ju4OfUbULOklSsWo_oystxCW0o,13640
@@ -333,8 +329,8 @@ sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
333
329
  sycommon/tools/user_id.py,sha256=o-zsubGSR5rSSZf8Pk8B7AhE21ahDTTIW5JZmJO6Flw,4761
334
330
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
335
331
  sycommon/xxljob/xxljob_service.py,sha256=1yifwIBNGsCIxLnQjHKiBlbsigc_zvPH-dMTZcNxe-Q,7649
336
- sycommon_python_lib-0.2.8a4.dist-info/METADATA,sha256=NZxc6nNAdgPwefWjpCfFT3wVBl8QhqlfcVGh7M_0Fb8,7996
337
- sycommon_python_lib-0.2.8a4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
- sycommon_python_lib-0.2.8a4.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
339
- sycommon_python_lib-0.2.8a4.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
340
- sycommon_python_lib-0.2.8a4.dist-info/RECORD,,
332
+ sycommon_python_lib-0.2.8a6.dist-info/METADATA,sha256=3brl8cOuDVlHkn1Zz5YiDtGIV8Jcg4RzKWlmMYNdvLU,7996
333
+ sycommon_python_lib-0.2.8a6.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
334
+ sycommon_python_lib-0.2.8a6.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
335
+ sycommon_python_lib-0.2.8a6.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
336
+ sycommon_python_lib-0.2.8a6.dist-info/RECORD,,
@@ -1,300 +0,0 @@
1
- """技能接口白名单客户端注入脚本(部署到沙箱 site-packages)。
2
-
3
- Python 解释器启动时(未传 -S)自动 import 本模块。它负责 Python HTTP 库的
4
- 请求拦截:
5
-
6
- 1. 读环境变量 SKILL_API_WHITELIST(JSON)。无此 env → 不管控(全放行,fail-open)。
7
- 2. monkeypatch urllib / httpx / requests / aiohttp,发请求前做 path 级前缀匹配,
8
- 未命中抛 PermissionError(LLM 能看到清晰错误)。
9
-
10
- ⚠️ shell 命令(curl/wget 等)的拦截【不】在本文件处理——sitecustomize 只在
11
- `python` 启动时执行,纯 shell 命令不会触发它。shell 拦截由主进程中间件把
12
- curl/wget 包装函数【内联】到每条 execute 命令前缀实现(见 skill_api_whitelist.py)。
13
-
14
- URL 匹配核心逻辑复用同目录 skill_wl_check.py。
15
-
16
- 另外提供【独立于白名单】的 header 注入:当环境变量 SYT_API_AUTHZ 非空时,
17
- 对 host 命中 SYT_BASE_URL 的请求自动补 X-Sy-Api-Authz 头。这是云平台网关的
18
- 鉴权要求,对所有沙箱技能请求生效(不门控白名单),由中间件无条件 export
19
- SYT_API_AUTHZ + 推送本 sitecustomize 触发。
20
-
21
- 注意:本模块在沙箱子进程里运行,不依赖主进程内存。配置全部来自环境变量。
22
- """
23
-
24
- import json
25
- import os
26
- import sys
27
- from urllib.parse import urlparse
28
-
29
- # SKILL_API_WHITELIST 存在即「启用管控」;allowlist 为空时「全拦截」。
30
- _ENABLED = "SKILL_API_WHITELIST" in os.environ
31
-
32
- # 把沙箱 site-packages 里存放本模块的目录加入 sys.path,以便 import skill_wl_check。
33
- _HERE = os.path.dirname(os.path.abspath(__file__))
34
- if _HERE not in sys.path:
35
- sys.path.insert(0, _HERE)
36
-
37
-
38
- def _load_check():
39
- """加载同目录 skill_wl_check,返回其 match 函数;失败返回 None。"""
40
- try:
41
- import skill_wl_check # type: ignore
42
- return skill_wl_check
43
- except Exception as e: # pragma: no cover
44
- sys.stderr.write("[sitecustomize] 加载 skill_wl_check 失败,白名单禁用: %s\n" % e)
45
- return None
46
-
47
-
48
- def _patch_urllib(match):
49
- import urllib.request
50
-
51
- _orig = urllib.request.urlopen
52
-
53
- def _urlopen(url, *args, **kwargs):
54
- actual = url.full_url if isinstance(url, urllib.request.Request) else url
55
- if actual and not match(str(actual)):
56
- raise PermissionError(
57
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (urlopen, %s)" % actual)
58
- return _orig(url, *args, **kwargs)
59
-
60
- urllib.request.urlopen = _urlopen
61
-
62
-
63
- def _patch_httpx(match):
64
- try:
65
- import httpx
66
- except ImportError:
67
- return
68
-
69
- _orig_sync = httpx.Client.send
70
-
71
- def _send(self, request, **kwargs):
72
- if not match(str(request.url)):
73
- raise PermissionError(
74
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (httpx, %s)" % request.url)
75
- return _orig_sync(self, request, **kwargs)
76
-
77
- httpx.Client.send = _send
78
-
79
- if hasattr(httpx, "AsyncClient"):
80
- _orig_async = httpx.AsyncClient.send
81
-
82
- async def _asend(self, request, **kwargs):
83
- if not match(str(request.url)):
84
- raise PermissionError(
85
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (httpx.async, %s)" % request.url)
86
- return await _orig_async(self, request, **kwargs)
87
-
88
- httpx.AsyncClient.send = _asend
89
-
90
-
91
- def _patch_requests(match):
92
- try:
93
- import requests
94
- except ImportError:
95
- return
96
-
97
- _orig = requests.Session.request
98
-
99
- def _request(self, method, url, *args, **kwargs):
100
- if not match(str(url)):
101
- raise PermissionError(
102
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (requests %s, %s)"
103
- % (method.upper(), url))
104
- return _orig(self, method, url, *args, **kwargs)
105
-
106
- requests.Session.request = _request
107
-
108
-
109
- def _patch_aiohttp(match):
110
- try:
111
- import aiohttp
112
- except ImportError:
113
- return
114
- if not hasattr(aiohttp.ClientSession, "_request"):
115
- return
116
-
117
- _orig = aiohttp.ClientSession._request
118
-
119
- async def _request(self, method, str_or_url, **kwargs):
120
- if not match(str(str_or_url)):
121
- raise PermissionError(
122
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (aiohttp %s, %s)"
123
- % (method, str_or_url))
124
- return await _orig(self, method, str_or_url, **kwargs)
125
-
126
- aiohttp.ClientSession._request = _request
127
-
128
-
129
- # =============== header 注入(独立于白名单)===============
130
- # 云平台网关要求请求带 X-Sy-Api-Authz 头。仅当 host 命中 SYT_BASE_URL 的 host
131
- # 才补该头,其它域名(内网 OCR / 企微 / 合同比对内网 / MCP 后端等)不注入。
132
- # 门控:环境变量 SYT_API_AUTHZ 非空。调用方已显式设过同名头则不覆盖。
133
-
134
- _AUTHZ_HEADER = "X-Sy-Api-Authz"
135
-
136
-
137
- def _syt_host():
138
- """解析 SYT_BASE_URL 的 host(小写)。失败返回 None。"""
139
- base = os.environ.get("SYT_BASE_URL", "").strip()
140
- if not base:
141
- return None
142
- try:
143
- return (urlparse(base).hostname or "").lower() or None
144
- except Exception:
145
- return None
146
-
147
-
148
- def _host_matches_syt(url) -> bool:
149
- """请求 URL 的 host 是否等于 SYT_BASE_URL 的 host。"""
150
- host = _syt_host()
151
- if not host:
152
- return False
153
- try:
154
- return (urlparse(str(url)).hostname or "").lower() == host
155
- except Exception:
156
- return False
157
-
158
-
159
- def _patch_urllib_hdr():
160
- import urllib.request
161
-
162
- _orig = urllib.request.urlopen
163
-
164
- def _urlopen(url, *args, **kwargs):
165
- # 只能给 Request 对象补头;裸 str URL 在 urlopen 层无 headers 概念,跳过
166
- if isinstance(url, urllib.request.Request) and _host_matches_syt(url.full_url):
167
- url.add_header(_AUTHZ_HEADER, os.environ["SYT_API_AUTHZ"])
168
- return _orig(url, *args, **kwargs)
169
-
170
- urllib.request.urlopen = _urlopen
171
-
172
-
173
- def _patch_requests_hdr():
174
- try:
175
- import requests
176
- except ImportError:
177
- return
178
-
179
- _orig = requests.Session.request
180
-
181
- def _request(self, method, url, *args, **kwargs):
182
- if _host_matches_syt(url):
183
- headers = kwargs.get("headers")
184
- if headers is None:
185
- headers = {}
186
- if isinstance(headers, dict):
187
- if _AUTHZ_HEADER not in headers:
188
- headers[_AUTHZ_HEADER] = os.environ["SYT_API_AUTHZ"]
189
- kwargs["headers"] = headers
190
- else:
191
- # list / 其它形态:保守起见交给底层,避免破坏既有结构
192
- pass
193
- return _orig(self, method, url, *args, **kwargs)
194
-
195
- requests.Session.request = _request
196
-
197
-
198
- def _patch_httpx_hdr():
199
- try:
200
- import httpx
201
- except ImportError:
202
- return
203
-
204
- def _apply(request):
205
- if request is not None and _host_matches_syt(request.url):
206
- request.headers.setdefault(_AUTHZ_HEADER, os.environ["SYT_API_AUTHZ"])
207
-
208
- _orig_sync = httpx.Client.send
209
-
210
- def _send(self, request, **kwargs):
211
- _apply(request)
212
- return _orig_sync(self, request, **kwargs)
213
-
214
- httpx.Client.send = _send
215
-
216
- if hasattr(httpx, "AsyncClient"):
217
- _orig_async = httpx.AsyncClient.send
218
-
219
- async def _asend(self, request, **kwargs):
220
- _apply(request)
221
- return await _orig_async(self, request, **kwargs)
222
-
223
- httpx.AsyncClient.send = _asend
224
-
225
-
226
- def _patch_aiohttp_hdr():
227
- try:
228
- import aiohttp
229
- except ImportError:
230
- return
231
- if not hasattr(aiohttp.ClientSession, "_request"):
232
- return
233
-
234
- _orig = aiohttp.ClientSession._request
235
-
236
- async def _request(self, method, str_or_url, **kwargs):
237
- if _host_matches_syt(str_or_url):
238
- headers = kwargs.get("headers")
239
- if isinstance(headers, dict):
240
- if _AUTHZ_HEADER not in headers:
241
- headers[_AUTHZ_HEADER] = os.environ["SYT_API_AUTHZ"]
242
- elif headers is None:
243
- kwargs["headers"] = {_AUTHZ_HEADER: os.environ["SYT_API_AUTHZ"]}
244
- else:
245
- # list / CIMultiDict 等其它形态:跳过,避免破坏结构
246
- pass
247
- return await _orig(self, method, str_or_url, **kwargs)
248
-
249
- aiohttp.ClientSession._request = _request
250
-
251
-
252
- def _install_header_injection():
253
- """装 header 注入 patch(独立于白名单)。
254
-
255
- 门控:环境变量 SYT_API_AUTHZ 非空。任一库 import 失败都不阻断。
256
- """
257
- val = os.environ.get("SYT_API_AUTHZ", "").strip()
258
- if not val:
259
- return
260
- if not _syt_host():
261
- sys.stderr.write(
262
- "[sitecustomize] SYT_API_AUTHZ 已设置但 SYT_BASE_URL 无法解析 host,"
263
- "跳过 header 注入\n")
264
- return
265
- _patch_urllib_hdr()
266
- _patch_requests_hdr()
267
- _patch_httpx_hdr()
268
- _patch_aiohttp_hdr()
269
-
270
-
271
- def _install():
272
- if not _ENABLED:
273
- return
274
- check = _load_check()
275
- if check is None:
276
- return
277
- # allowlist 为空(明确禁止)时也走校验——match 必然 False,从而全拦截。
278
- match = check.match
279
- _patch_urllib(match)
280
- _patch_httpx(match)
281
- _patch_requests(match)
282
- _patch_aiohttp(match)
283
-
284
-
285
- # 保留解析结果(供排查)
286
- try:
287
- _WHITELIST_RAW = json.loads(os.environ.get("SKILL_API_WHITELIST", "{}"))
288
- except Exception:
289
- _WHITELIST_RAW = {}
290
-
291
- try:
292
- _install()
293
- except Exception as _e: # 任何 patch 失败都不能阻断进程启动
294
- sys.stderr.write("[sitecustomize] 白名单安装失败(fail-open): %s\n" % _e)
295
-
296
- # header 注入独立于白名单,单独 try/except,互不影响
297
- try:
298
- _install_header_injection()
299
- except Exception as _e:
300
- sys.stderr.write("[sitecustomize] header 注入安装失败(fail-open): %s\n" % _e)
@@ -1,479 +0,0 @@
1
- """技能接口白名单中间件。
2
-
3
- 拦截 `execute` 工具(FilesystemMiddleware 注册的 shell 执行工具),做两件事:
4
-
5
- 1. 从命令路径推断顶级技能名(skills/<顶级技能>/ → <顶级技能>,如 sy-oa-skill),
6
- 查 SkillApiWhitelist 配置得到该顶级技能的白名单,把占位符 ${VAR} 在主进程
7
- 解析成完整 URL,序列化成 JSON 后以 `export SKILL_API_WHITELIST='<json>'`
8
- 前缀注入命令——供沙箱内 sitecustomize.py 读取并 monkeypatch urllib/
9
- httpx/requests/aiohttp。每个顶级技能的 allowlist 是其下所有子技能出网地址的并集。
10
-
11
- 2. 命令前缀内联 curl/wget 的 shell 函数包装:纯 shell 命令(不走 Python)
12
- 不会触发 sitecustomize,故用 shell 函数在执行 curl/wget 前调
13
- `python -m skill_wl_check <url>` 做校验,未命中则 return 1 拦截。
14
-
15
- 3. 首次惰性把 sitecustomize.py + skill_wl_check.py 推进沙箱 site-packages,
16
- 使 Python 启动时自动 import sitecustomize。
17
-
18
- 语义:子技能未配置 / enabled:false → 不注入(全放行,向后兼容);
19
- allowlist:[] 空数组 → 注入空白名单(全拦截)。
20
- """
21
-
22
- import base64
23
- import hashlib
24
- import json
25
- import os
26
- import re
27
- import shlex
28
- from typing import Optional
29
- from urllib.parse import urlparse
30
-
31
- from langchain.agents.middleware.types import AgentMiddleware
32
- from langchain_core.messages import ToolMessage
33
- from langgraph.prebuilt.tool_node import ToolCallRequest
34
-
35
- from sycommon.logging.kafka_log import SYLogger
36
-
37
- # 命令路径形如 skills/system/sy-oa-skill/workflow-todo/scripts/x.py
38
- # skills/user/sy-oa-skill/SKILL.md
39
- # _SKILL_TOP_RE:白名单配置提取用——限定 system|user|shengye-platform 三层后取第二段
40
- # 作域级技能名(如 sy-oa-skill / oa),配合 SkillApiWhitelist 按顶级域配置。
41
- # (?<![\w]) 否定左查找:要求 skills 前不是单词字符,避免误匹配 myskills/。
42
- _SKILL_TOP_RE = re.compile(
43
- r'(?<![\w])skills/(?:system|user|shengye-platform)/([A-Za-z0-9_\-]+)(?:/|$)')
44
-
45
- # _SKILL_ANY_RE:技能命令判定用(宽松)——匹配任意 skills/<...>/ 前缀。
46
- # 用于 header 注入等对所有技能命令(含用户自行拷贝到非 system 目录的技能)生效的能力,
47
- # 与白名单配置是否启用解耦。同样 (?<![\w]) 避免误匹配 myskills/。
48
- _SKILL_ANY_RE = re.compile(r'(?<![\w])skills/[A-Za-z0-9_\-]+(?:/|$)')
49
-
50
- # BASE_URL 占位符 ${VAR}
51
- _PLACEHOLDER_RE = re.compile(r'\$\{([A-Z_][A-Z0-9_]*)\}')
52
-
53
- # workspace 内白名单目录的 shell 形式(init_script 已 export $SANDBOX_ROOT,运行期展开)
54
- _WL_DIR = "$SANDBOX_ROOT/.wl"
55
- # .wl 目录在 workspace 内的相对路径段(用于 hash 校验命令里的文件名)
56
- _WL_FILES = [
57
- ".wl/sitecustomize.py", # 极小 loader(明文,无业务逻辑)
58
- ".wl/sitecustomize.payload", # sitecustomize.py 源码的 base64
59
- ".wl/skill_wl_check.payload", # skill_wl_check.py 源码的 base64
60
- ]
61
-
62
- # ---- 极小 loader(落盘为 .wl/sitecustomize.py)----
63
- # 不含任何白名单/拦截业务逻辑,只做"解码同目录 payload + exec"。
64
- # 关键:把 skill_wl_check 注册成真模块对象进 sys.modules,使 sitecustomize.payload
65
- # 里的 `import skill_wl_check; skill_wl_check.match(...)` 按原逻辑工作(逻辑不变)。
66
- _LOADER_SRC = b'''# sycommon injected loader: decode payloads, exec them. No business logic.
67
- import os, sys, base64, types
68
- _HERE = os.path.dirname(os.path.abspath(__file__))
69
- if _HERE not in sys.path:
70
- sys.path.insert(0, _HERE)
71
-
72
-
73
- def _run(name, ns):
74
- p = os.path.join(_HERE, name)
75
- try:
76
- with open(p, "rb") as f:
77
- exec(compile(base64.b64decode(f.read()), name, "exec"), ns)
78
- except Exception as _e:
79
- sys.stderr.write("[.wl loader] load %s failed: %s\\n" % (name, _e))
80
-
81
-
82
- _wl = types.ModuleType("skill_wl_check")
83
- sys.modules["skill_wl_check"] = _wl
84
- _run("skill_wl_check.payload", _wl.__dict__)
85
- _run("sitecustomize.payload", globals())
86
- '''
87
-
88
-
89
- def _build_payload(src_name: str) -> bytes:
90
- """读包内源码并 base64 编码,作为 .payload 落盘内容(防明文窥探)。"""
91
- raw = _load_resource_bytes(src_name)
92
- return base64.b64encode(raw)
93
-
94
-
95
- def _expected_hashes() -> dict:
96
- """运行时从主进程源码算各 .wl 文件的预期 sha256(hex)。
97
-
98
- payload 的预期 hash 算的是【编码后的 base64 字节】——即沙箱里实际落盘内容,
99
- 这样 sha256 比对的就是真实文件内容。loader 的预期 hash 算 _LOADER_SRC(固定常量)。
100
- """
101
- loader_bytes = _LOADER_SRC
102
- sc_bytes = _build_payload("sitecustomize.py")
103
- wl_bytes = _build_payload("skill_wl_check.py")
104
- return {
105
- ".wl/sitecustomize.py": hashlib.sha256(loader_bytes).hexdigest(),
106
- ".wl/sitecustomize.payload": hashlib.sha256(sc_bytes).hexdigest(),
107
- ".wl/skill_wl_check.payload": hashlib.sha256(wl_bytes).hexdigest(),
108
- }
109
-
110
-
111
- # ---- .wl 写拦截(复刻 skill_write_guard 的正则集合)----
112
- # .wl 目录保护:精确匹配 .wl 或 .wl/...(带边界,防误伤 myapp.wl 之类)
113
- _WL_PROTECTED_RE = re.compile(r'\.wl(?:/|\s|"|\'|$)')
114
- # 写动作动词(命令首或分号/管道/空格后)
115
- _WRITE_VERB_RE = re.compile(
116
- r'(^|[\s;&|])(rm|rmdir|mv|cp|chmod|chown|tee|truncate|install|rsync|scp|dd|>\.wl)\b')
117
- # 输出重定向 > 或 >>
118
- _REDIRECT_RE = re.compile(r'>>?')
119
- _WL_DENIED_MSG = "Error: .wl 为系统注入目录,禁止写入/修改/删除。"
120
-
121
-
122
- def _command_writes_wl(cmd: str) -> bool:
123
- """execute 命令是否在写 .wl:命中 .wl 路径 且 含写动词或重定向。"""
124
- if not _WL_PROTECTED_RE.search(cmd or ""):
125
- return False
126
- return bool(_WRITE_VERB_RE.search(cmd) or _REDIRECT_RE.search(cmd))
127
-
128
-
129
- def _is_wl_path(p: str) -> bool:
130
- """file_path 是否落在 .wl 目录内(write_file/edit_file 拦截用)。
131
-
132
- 注意不能用 lstrip("./")——它是按字符集 strip,会把 ".wl" 的前导 "." 也吃成 "wl"。
133
- 改为逐前缀剥离再判。
134
- """
135
- s = (p or "").strip().replace("\\", "/")
136
- # 剥前导 ./ 和 /
137
- while s.startswith("./") or s.startswith("/"):
138
- s = s[1:] if s.startswith("/") else s[2:]
139
- return s == ".wl" or s.startswith(".wl/")
140
-
141
-
142
-
143
- def _resolve_placeholders(pattern: str, env_lookup) -> Optional[str]:
144
- """把 ${VAR} 替换为解析后的值。任一 VAR 不存在返回 None(配置错误)。
145
-
146
- env_lookup: callable(var_name) -> str|None。中间件传入「先查主进程 os.environ,
147
- 再查 sandbox env 字典(含 SYT_BASE_URL 等运行时算出/从 Nacos 读出的值)」的查找器。
148
- """
149
- missing = []
150
-
151
- def _sub(m):
152
- val = env_lookup(m.group(1))
153
- if val is None:
154
- missing.append(m.group(1))
155
- return ""
156
- return str(val).rstrip("/")
157
-
158
- expanded = _PLACEHOLDER_RE.sub(_sub, pattern)
159
- if missing:
160
- SYLogger.warning(
161
- "[Whitelist] 占位符未定义 %s,丢弃规则 %s", missing, pattern)
162
- return None
163
- return expanded
164
-
165
-
166
- def _load_resource_bytes(filename: str) -> bytes:
167
- """从本包读取注入脚本源码(package_data)。"""
168
- try:
169
- from importlib.resources import files
170
- return (files("sycommon.agent.middleware").joinpath(filename).read_bytes())
171
- except Exception as e:
172
- raise RuntimeError(
173
- f"读取注入脚本 {filename} 失败,请确认已随包安装(package_data): {e}") from e
174
-
175
-
176
- def _build_shell_guard(with_whitelist: bool = False,
177
- with_header: bool = False,
178
- with_rewrite_to_gateway: bool = False) -> str:
179
- """生成内联到命令前缀的 curl/wget 包装函数(shell)。
180
-
181
- 抽取参数里【最后一个】 http(s) URL 到 _u,按需承担三类职责(可同时启用):
182
- - with_rewrite_to_gateway:把 _u 的真上游 URL 改写成网关 URL
183
- (http://127.0.0.1:port,真 host 进 X-Upstream-Host 头),凭据头剥除。
184
- 收口模式下子进程 SYT_BASE_URL 已是网关,多数脚本自动走网关;此分支兜底
185
- 硬编码 https://<真上游> 的 curl/wget 命令(绕过 SYT_BASE_URL)。
186
- - with_whitelist:网关已做白名单,此处保留为纵深(沙箱 python3 校验)。
187
- - with_header:收口后凭据由网关注入,此分支仅在未启用收口(兼容旧部署)时生效。
188
-
189
- skill_wl_check 与 sitecustomize 同处 site-packages,python3 启动时 sitecustomize
190
- 会 import 它并把该目录加入 sys.path。覆盖 curl / wget;其它 shell 出网方式
191
- (nc/裸 /dev/tcp 等)不覆盖(由 seccomp 硬墙兜底)。
192
- """
193
- rewrite_block = ""
194
- if with_rewrite_to_gateway:
195
- # _u 形如 https://syapi.x.com/path?qs。改写为 http://127.0.0.1:$__GW_PORT__/path?qs,
196
- # 真上游 host 进 X-Upstream-Header,并剥除 Authorization/SYU-Token/X-Sy-Api-Authz
197
- # (网关重新注入正确凭据)。port 取自子进程 SYT_BASE_URL(收口已注入网关地址)。
198
- # 修审核 P1-6:重写后的 URL 必须替换 "$@" 里的原 URL 参数(旧实现只改了
199
- # 校验变量 _u,curl 实际仍直连真上游)。URL 参数判定:与 _u 原值相等的参数。
200
- # 注:该分支当前无调用方启用(旧白名单中间件已注销,收口走 SYT_BASE_URL
201
- # 指向网关),保留为 legacy 应急路径并修正确性。
202
- rewrite_block = (
203
- 'if [ -n "$_u" ]; then '
204
- # 取真上游 host(去 scheme/path/port)
205
- '_rh="${_u#*://}"; _rh="${_rh%%/*}"; _rh="${_rh%%\\?*}"; _rh="${_rh%%:*}"; '
206
- # 取 path?query
207
- '_rp="${_u#*://}"; _rp="${_rp#*/}"; '
208
- '[ "$_rp" = "$_u#*://" ] && _rp=""; '
209
- '[ -n "$_rp" ] && _rp="/$_rp"; '
210
- # 网关地址来自 SYT_BASE_URL(收口已注入 http://127.0.0.1:port)
211
- '_gw="${SYT_BASE_URL%%/}"; '
212
- 'if [ -n "$__GW_PORT__" ]; then _gw="http://127.0.0.1:$__GW_PORT__"; fi; '
213
- 'if [ -n "$_gw" ] && [ -n "$_rh" ]; then '
214
- '_rw="$_gw$_rp"; '
215
- # 重建参数:剥凭据头 + 把原 URL 参数替换为网关 URL(P1-6 修)
216
- 'set -- "$@"; _new=(); _skip=0; '
217
- 'for _a in "$@"; do '
218
- 'if [ "$_skip" = "1" ]; then _skip=0; continue; fi; '
219
- 'case "$_a" in '
220
- '"-H"|"--header") _skip=1; continue;; '
221
- '"Authorization:"*|"SYU-Token:"*|"X-Sy-Api-Authz:"*|"SYU-Url:"*) continue;; '
222
- '"$_u") _new+=("$_rw"); continue;; '
223
- 'esac; _new+=("$_a"); done; '
224
- 'set -- "${_new[@]}" -H "X-Upstream-Host: $_rh"; '
225
- '_u="$_rw"; '
226
- 'fi; '
227
- 'fi; '
228
- )
229
- wl_block = ""
230
- if with_whitelist:
231
- check_cmd = (
232
- 'python3 -c \'import skill_wl_check,sys; '
233
- 'sys.exit(0 if skill_wl_check.match(sys.argv[1]) else 1)\' "$_u"'
234
- )
235
- wl_block = (
236
- f'if [ -n "$_u" ] && ! {check_cmd}; then '
237
- 'echo "[SkillApiWhitelist] 拦截 shell 请求: $_u" 1>&2; return 1; fi; '
238
- )
239
- hdr_block = ""
240
- if with_header:
241
- # 仅兼容旧部署(未收口)的 X-Sy-Api-Authz 注入。收口模式下网关注入,跳过。
242
- hdr_block = (
243
- 'if [ -n "$_u" ] && [ -n "$__SYT_HOST__" ] && [ -n "$SYT_API_AUTHZ" ]; then '
244
- '_h="${_u#*://}"; _h="${_h%%/*}"; _h="${_h%%\\?*}"; _h="${_h%%#*}"; '
245
- '_h="${_h##*@}"; _h="${_h%%:*}"; '
246
- 'case "$(printf "%s" "$_h" | tr "A-Z" "a-z")" in '
247
- '"$__SYT_HOST__") '
248
- 'case " $* " in *"X-Sy-Api-Authz"*) :;; *) '
249
- 'set -- "$@" -H "X-Sy-Api-Authz: $SYT_API_AUTHZ";; '
250
- 'esac;; '
251
- 'esac; '
252
- 'fi; '
253
- )
254
- if not wl_block and not hdr_block and not rewrite_block:
255
- return ""
256
- body = (
257
- '() { local _u _a _h; for _a in "$@"; do '
258
- 'case "$_a" in http://*|https://*) _u="$_a";; esac; done; '
259
- + rewrite_block + wl_block + hdr_block +
260
- 'command {CMD} "$@"; };'
261
- )
262
- guard = "curl" + body.replace("{CMD}", "curl") + " wget" + body.replace("{CMD}", "wget")
263
- return guard.rstrip(";") # 末尾不带分号:由调用方按需拼接分隔符
264
-
265
-
266
- class SkillApiWhitelistMiddleware(AgentMiddleware):
267
- """按子技能动态注入接口白名单环境变量 + shell 包装。
268
-
269
- AgentMiddleware 协议钩子:实现 `awrap_tool_call`(异步版本),在 execute
270
- 工具真正执行前注入白名单。框架对工具调用走 astream/ainvoke 异步链路,故
271
- 只需实现异步钩子(与现有 TokenTrackingMiddleware/ToolResultTruncationMiddleware 一致)。
272
- """
273
-
274
- def __init__(self, sandbox_backend):
275
- self._backend = sandbox_backend
276
- # 注:不再缓存"已推送"标志。用户可在会话中删除 .wl/ 绕过白名单,
277
- # 故改为每次 execute 前校验文件完整性、缺则重推(见 _ensure_sitecustomize)。
278
-
279
- def _env_lookup(self):
280
- """构造占位符查找器:先查主进程 os.environ,再查 sandbox env 字典。
281
-
282
- SYT_BASE_URL / XINGHAN_BASE_URL / YEALINK_BASE_URL 等不在主进程 os.environ 里,
283
- 而是放在传给 sandbox backend 的 env 字典中(tools/config.py.build_sandbox_env 构造)。
284
- MultiSandboxRouter/HTTPSandboxBackend 都暴露 `_env`,这里统一兜底取值。
285
- """
286
- sandbox_env = {}
287
- b = self._backend
288
- for attr in ("_env",):
289
- v = getattr(b, attr, None)
290
- if isinstance(v, dict):
291
- sandbox_env.update(v)
292
- else:
293
- # RoutingBackend 等代理对象:尝试 default/local/remote 子 backend
294
- pass
295
- # 再尝试 default backend(MultiSandboxRouter._default._env)
296
- default = getattr(b, "_default", None)
297
- if default is not None:
298
- v = getattr(default, "_env", None)
299
- if isinstance(v, dict):
300
- sandbox_env.update(v)
301
-
302
- def lookup(var):
303
- val = os.environ.get(var)
304
- if val is None:
305
- val = sandbox_env.get(var)
306
- return val
307
-
308
- return lookup
309
-
310
- # ---- 懒加载:把注入脚本推进沙箱 workspace/.wl ----
311
- async def _ensure_sitecustomize(self) -> bool:
312
- """把 loader + 2 个 base64 payload 推到沙箱 workspace 内的 .wl 目录。
313
-
314
- 防篡改 + 自愈:每次 execute 前对 3 个文件算 sha256,与主进程源码算出的
315
- 预期 hash 比对。任一文件缺失/被改 → 全量重推 + chmod 555。
316
- 旧的 test -f 存在性校验挡不住「用户把 sitecustomize 改成空文件」,
317
- sha256 内容校验才能挡。失败 fail-open(不阻塞业务)。
318
- """
319
- backend = self._backend
320
- if backend is None:
321
- return False
322
- try:
323
- expected = _expected_hashes()
324
- # 一条命令取三个文件的 sha256(缺失则该行输出 MISSING),换行拼接返回。
325
- files_quoted = " ".join(
326
- shlex.quote(f) for f in _WL_FILES)
327
- check_cmd = (
328
- f'for f in {files_quoted}; do '
329
- f'if [ -f "$SANDBOX_ROOT/$f" ]; then '
330
- f'sha256sum "$SANDBOX_ROOT/$f" 2>/dev/null | cut -d" " -f1; '
331
- f'else echo MISSING; fi; done'
332
- )
333
- try:
334
- resp = await backend.aexecute(check_cmd)
335
- actual_lines = (getattr(resp, "output", "") or "").strip().splitlines()
336
- except Exception:
337
- actual_lines = []
338
-
339
- # 逐文件比对:长度/顺序与 _WL_FILES 对应
340
- intact = (len(actual_lines) == len(_WL_FILES)
341
- and all(actual_lines[i] == expected[_WL_FILES[i]]
342
- for i in range(len(_WL_FILES))))
343
-
344
- if intact:
345
- return True # 内容完整,无需重推
346
-
347
- # 重推:loader 明文 + 2 个 payload
348
- files = [
349
- (".wl/sitecustomize.py", _LOADER_SRC),
350
- (".wl/sitecustomize.payload", _build_payload("sitecustomize.py")),
351
- (".wl/skill_wl_check.payload", _build_payload("skill_wl_check.py")),
352
- ]
353
- await backend.aupload_files(files)
354
- # chmod -R 555:挡普通用户改/删(root 仍可绕,靠 sha256 自愈兜底)
355
- try:
356
- await backend.aexecute(f'chmod -R 555 "{_WL_DIR}"')
357
- except Exception as _ce:
358
- SYLogger.warning("[Whitelist] chmod .wl 555 失败(非致命): %s", _ce)
359
- SYLogger.info("[Whitelist] 注入脚本已(重新)推送至沙箱 %s (sha256 校验触发)", _WL_DIR)
360
- return True
361
- except Exception as e:
362
- SYLogger.error("[Whitelist] 推送 sitecustomize 失败(fail-open): %s", e)
363
- return False
364
-
365
- # ---- 顶级技能名提取 ----
366
- def _extract_skill_name(self, command: str) -> Optional[str]:
367
- m = _SKILL_TOP_RE.search(command)
368
- if m:
369
- return m.group(1)
370
- return None
371
-
372
- # ---- 主入口(AgentMiddleware 异步钩子)----
373
- async def awrap_tool_call(
374
- self,
375
- request: ToolCallRequest,
376
- handler,
377
- ):
378
- tc = request.tool_call
379
- name = tc.get("name")
380
- args = tc.get("args", {}) or {}
381
-
382
- # ---- .wl 写拦截(短路 fail-closed,在任何业务逻辑之前)----
383
- # 防 agent 经 write_file/edit_file/execute 篡改注入目录。参考 SkillWriteGuard。
384
- if name in ("write_file", "edit_file"):
385
- fp = args.get("file_path") if isinstance(args, dict) else ""
386
- if _is_wl_path(fp):
387
- return ToolMessage(content=_WL_DENIED_MSG, name=name,
388
- tool_call_id=tc.get("id", ""), status="error")
389
- elif name == "execute":
390
- command = args.get("command", "") if isinstance(args, dict) else ""
391
- if _command_writes_wl(command):
392
- return ToolMessage(content=_WL_DENIED_MSG, name="execute",
393
- tool_call_id=tc.get("id", ""), status="error")
394
-
395
- if name != "execute":
396
- return await handler(request)
397
-
398
- command = args.get("command", "") if isinstance(args, dict) else ""
399
- if not command:
400
- return await handler(request)
401
-
402
- # 技能命令判定(宽松):任意 skills/.../ 前缀即算(含用户拷贝到非 system
403
- # 目录的技能)。header 注入等对所有技能命令生效的能力以此为门控。
404
- if not _SKILL_ANY_RE.search(command):
405
- # 非技能命令 → 不注入任何前缀
406
- return await handler(request)
407
-
408
- # 白名单配置提取(限定三层后取第二段)
409
- skill_name = self._extract_skill_name(command)
410
- from sycommon.config.SkillApiWhitelistConfig import SkillApiWhitelistConfig
411
- cfg = (SkillApiWhitelistConfig.from_config(skill_name)
412
- if skill_name is not None else None)
413
- whitelist_on = bool(cfg and cfg.enabled)
414
-
415
- # 主进程解析占位符 ${VAR}(数据源:os.environ + sandbox env 字典)
416
- env_lookup = self._env_lookup()
417
- resolved = []
418
- if whitelist_on:
419
- for pat in cfg.allowlist:
420
- if "${" in pat:
421
- expanded = _resolve_placeholders(pat, env_lookup)
422
- if expanded:
423
- resolved.append(expanded)
424
- else:
425
- resolved.append(pat)
426
-
427
- # header 注入所需:SYT_API_AUTHZ(缺则不注入)+ SYT_BASE_URL 的 host
428
- syt_authz = env_lookup("SYT_API_AUTHZ")
429
- header_on = bool(syt_authz and syt_authz.strip())
430
- syt_host = ""
431
- if header_on:
432
- syt_base = env_lookup("SYT_BASE_URL") or ""
433
- try:
434
- syt_host = (urlparse(syt_base).hostname or "").lower()
435
- except Exception:
436
- syt_host = ""
437
- header_on = bool(syt_host)
438
-
439
- # 推送注入脚本(惰性,每后端实例一次)。
440
- # 只要【白名单 或 header 注入】任一启用就要推 .wl(sitecustomize)。
441
- if whitelist_on or header_on:
442
- await self._ensure_sitecustomize()
443
-
444
- # 组装命令前缀
445
- prefix_parts: list[str] = []
446
- if whitelist_on:
447
- payload = json.dumps({"skill": skill_name, "allowlist": resolved},
448
- ensure_ascii=False)
449
- prefix_parts.append(f"export SKILL_API_WHITELIST={shlex.quote(payload)}")
450
- if header_on:
451
- prefix_parts.append(
452
- f"export SYT_API_AUTHZ={shlex.quote(syt_authz)}")
453
- prefix_parts.append(f'export __SYT_HOST__={shlex.quote(syt_host)}')
454
- if whitelist_on or header_on:
455
- # PYTHONPATH(shell 启动 python 时自动 import sitecustomize)
456
- prefix_parts.append(f'export PYTHONPATH="{_WL_DIR}:$PYTHONPATH"')
457
- guard = _build_shell_guard(with_whitelist=whitelist_on,
458
- with_header=header_on)
459
- if guard:
460
- prefix_parts.append(guard)
461
-
462
- if not prefix_parts:
463
- return await handler(request)
464
-
465
- # 每个 part 之间用 '; ' 分隔(而非空格)—— export 语句之间、函数定义与后续命令
466
- # 之间都需要语句分隔符;末尾留一个 ';' 与原命令隔开。
467
- prefix = "; ".join(prefix_parts) + "; "
468
-
469
- new_command = prefix + command
470
- new_args = {**args, "command": new_command} if isinstance(args, dict) else args
471
- new_tc = {**tc, "args": new_args}
472
- tags = []
473
- if whitelist_on:
474
- tags.append(f"白名单({len(resolved)}条)")
475
- if header_on:
476
- tags.append("Authz头注入")
477
- SYLogger.info("[Whitelist] 技能 %s 注入: %s", skill_name or "<user>",
478
- "+".join(tags) if tags else "(空)")
479
- return await handler(request.override(tool_call=new_tc))
@@ -1,123 +0,0 @@
1
- """技能接口白名单:URL 匹配核心逻辑(被 sitecustomize 与 shell 包装层共用)。
2
-
3
- 沙箱内运行。从环境变量 `SKILL_API_WHITELIST` 读取白名单 JSON:
4
-
5
- {"skill": "<子技能名>", "allowlist": ["<完整URL或path前缀>", ...]}
6
-
7
- 提供:
8
- - match(url) -> bool 供 sitecustomize(Python 库 monkeypatch)内存调用
9
- - main() CLI:`python3 -m skill_wl_check <url>`,exit 0=放行 / 1=拦截
10
-
11
- 匹配规则(path 级前缀,带斜杠边界):
12
- - 完整 URL 条目:scheme/host 小写比较,port 规则未写则任意,path 前缀匹配
13
- - 纯 path 前缀条目(无 host):只比 path(匹配任意 host,谨慎用)
14
- - /wf 匹配 /wf 与 /wf/...,不匹配 /wfcount(斜杠边界)
15
- - query string 不参与匹配
16
- """
17
-
18
- import json
19
- import os
20
- import sys
21
- from urllib.parse import urlparse
22
-
23
- _ENV = "SKILL_API_WHITELIST"
24
-
25
-
26
- def _load_allowlist() -> list[str]:
27
- """从环境变量读白名单。无 env / 解析失败 → 空列表(sitecustomize 据此决定是否启用)。"""
28
- raw = os.environ.get(_ENV, "")
29
- if not raw:
30
- return []
31
- try:
32
- data = json.loads(raw)
33
- if isinstance(data, dict):
34
- return [str(x) for x in data.get("allowlist", [])]
35
- if isinstance(data, list):
36
- return [str(x) for x in data]
37
- except Exception:
38
- pass
39
- return []
40
-
41
-
42
- def _normalize(entry: str) -> tuple:
43
- """把白名单条目规范化成 (scheme, host, port, path_prefix)。
44
-
45
- - 'https://oa.syholdings.com/services/WorkflowService'
46
- → ('https', 'oa.syholdings.com', None, '/services/WorkflowService')
47
- - 'http://10.100.8.72:8888'
48
- → ('http', '10.100.8.72', 8888, '')
49
- - '/wf' (纯 path 前缀,无 host)
50
- → (None, None, None, '/wf')
51
- """
52
- entry = entry.strip().rstrip("/")
53
- if entry.startswith(("http://", "https://")):
54
- p = urlparse(entry)
55
- path = (p.path or "").rstrip("/")
56
- return (
57
- (p.scheme or "").lower(),
58
- (p.hostname or "").lower(),
59
- p.port,
60
- path,
61
- )
62
- # 纯 path 前缀(无 host)
63
- if not entry.startswith("/"):
64
- entry = "/" + entry
65
- return (None, None, None, entry)
66
-
67
-
68
- def _compiled():
69
- return [_normalize(e) for e in _load_allowlist()]
70
-
71
-
72
- def _is_allowed(url: str, rules=None) -> bool:
73
- if rules is None:
74
- rules = _compiled()
75
- try:
76
- p = urlparse(url if "://" in url else "http://" + str(url))
77
- except Exception:
78
- return False
79
- req_scheme = (p.scheme or "").lower()
80
- req_host = (p.hostname or "").lower()
81
- req_port = p.port
82
- req_path = (p.path or "").rstrip("/")
83
- for (sch, host, port, pfx) in rules:
84
- if pfx == "":
85
- pfx_match = True # 规则没带 path → 该 host 下任意 path 放行
86
- elif req_path == pfx or req_path.startswith(pfx + "/"):
87
- pfx_match = True
88
- else:
89
- pfx_match = False
90
- if not pfx_match:
91
- continue
92
- if sch is None:
93
- # 纯 path 前缀规则:只比 path(host 任意)
94
- return True
95
- if req_scheme != sch or req_host != host:
96
- continue
97
- if port is not None and req_port not in (None, port):
98
- continue
99
- return True
100
- return False
101
-
102
-
103
- def match(url: str) -> bool:
104
- """供 Python 库内存调用:URL 是否在白名单内。"""
105
- return _is_allowed(url)
106
-
107
-
108
- def main(argv=None) -> int:
109
- argv = list(sys.argv[1:] if argv is None else argv)
110
- if not argv:
111
- sys.stderr.write("[skill_wl_check] 用法: python3 -m skill_wl_check <url>\n")
112
- return 2
113
- url = argv[0]
114
- if match(url):
115
- return 0
116
- sys.stderr.write(
117
- "[SkillApiWhitelist] 拦截: URL 不在白名单内 (url=%s)。"
118
- "请联系管理员修改 SkillApiWhitelist 配置。\n" % url)
119
- return 1
120
-
121
-
122
- if __name__ == "__main__":
123
- sys.exit(main())
@@ -1,87 +0,0 @@
1
- """技能接口白名单配置模型。
2
-
3
- 定义「单个顶级技能被允许访问的外部接口白名单」,配置来源是 Nacos 的
4
- `shengye-platform-digital-work` dataId 下的 `SkillApiWhitelist` 列表。
5
-
6
- 配置粒度按顶级技能(5 个域:sy-oa-skill / sy-syt-skill / sy-eas-skill /
7
- sy-metting-skill / skill-creator),每个域的 allowlist 是其下所有子技能
8
- 出网地址的并集。skill 字段取自 `skills/<顶级技能>/` 的第一段目录名:
9
-
10
- SkillApiWhitelist:
11
- - skill: sy-oa-skill # 顶级技能名(skills/<顶级技能>/ 里的 <顶级技能>)
12
- enabled: true
13
- allowlist:
14
- - "${OA_WORKFLOW_URL}" # BASE_URL 占位符,主进程解析成完整 URL
15
- - "${SYT_BASE_URL}/wf" # 占位符 + path 后缀
16
- - "https://oa.syholdings.com" # 完整 URL
17
- - "/wf" # 纯 path 前缀(匹配任意 host,谨慎用)
18
-
19
- 语义约定(务必在 Nacos 配置注释里写明):
20
- - 顶级技能【未出现】在配置里 → 不管控,全放行(向后兼容)
21
- - enabled: false → 全放行
22
- - allowlist: [](空数组) → 明确禁止该顶级技能任何外网请求(全拦截)
23
-
24
- 代码侧用 `SkillApiWhitelistConfig.from_config(skill)` 按 skill 取单条,
25
- 或 `list_from_config()` 取全部。
26
- """
27
-
28
- from typing import Optional
29
-
30
- from pydantic import BaseModel, Field
31
-
32
-
33
- class SkillApiWhitelistConfig(BaseModel):
34
- """单个子技能的接口白名单配置。"""
35
-
36
- skill: str = Field(
37
- description="顶级技能名,对应 skills/<顶级技能>/ 目录,"
38
- "如 'sy-oa-skill'、'sy-syt-skill'、'sy-eas-skill'、"
39
- "'sy-metting-skill'、'skill-creator'")
40
- enabled: bool = Field(default=True, description="是否启用白名单管控;False 则全放行")
41
- allowlist: list[str] = Field(
42
- default_factory=list,
43
- description=(
44
- "白名单条目(字符串数组)。每条可以是:完整 URL(https://...)、"
45
- "BASE_URL 占位符(${ENV_VAR} 或 ${ENV_VAR}/path)、纯 path 前缀(/wf)。"
46
- "主进程加载时把 ${ENV_VAR} 解析成完整 URL,运行期做 path 级前缀匹配。"
47
- "空数组 [] = 明确禁止该顶级技能任何外网请求。"
48
- ))
49
-
50
- @classmethod
51
- def from_config(cls, skill: str) -> Optional["SkillApiWhitelistConfig"]:
52
- """按 skill 名从 Nacos 配置取单个白名单配置。不存在返回 None。"""
53
- for cfg in list_from_config():
54
- if cfg.skill == skill:
55
- return cfg
56
- return None
57
-
58
- @classmethod
59
- def list_from_config(cls) -> list["SkillApiWhitelistConfig"]:
60
- """从 Nacos 配置取全部已配置的顶级技能白名单。
61
-
62
- 配置读 `Config().config.get("SkillApiWhitelist", [])`——即
63
- `shengye-platform-digital-work` dataId 下的 SkillApiWhitelist 列表
64
- (需在 Config.set_attr 的 override_keys 里登记,否则读不到)。
65
- """
66
- from sycommon.config.Config import Config
67
-
68
- raw_list = Config().config.get("SkillApiWhitelist", []) or []
69
- result: list[SkillApiWhitelistConfig] = []
70
- for item in raw_list:
71
- try:
72
- result.append(cls(**item))
73
- except Exception as e: # 单条配置错误不影响其它
74
- import logging
75
- logging.getLogger(__name__).warning(
76
- "Invalid SkillApiWhitelistConfig entry %s: %s", item, e)
77
- return result
78
-
79
- @classmethod
80
- def list_enabled_from_config(cls) -> list["SkillApiWhitelistConfig"]:
81
- """取全部 enabled=True 的配置。"""
82
- return [c for c in cls.list_from_config() if c.enabled]
83
-
84
-
85
- def list_from_config() -> list[SkillApiWhitelistConfig]:
86
- """模块级便捷函数,等价于 SkillApiWhitelistConfig.list_from_config()。"""
87
- return SkillApiWhitelistConfig.list_from_config()