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.
- sycommon/agent/deep_agent.py +0 -15
- sycommon/middleware/egress_wall.py +2 -2
- sycommon/middleware/sandbox.py +14 -0
- sycommon/middleware/sandbox_gateway.py +36 -11
- {sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/RECORD +9 -13
- sycommon/agent/middleware/sitecustomize.py +0 -300
- sycommon/agent/middleware/skill_api_whitelist.py +0 -479
- sycommon/agent/middleware/skill_wl_check.py +0 -123
- sycommon/config/SkillApiWhitelistConfig.py +0 -87
- {sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/top_level.txt +0 -0
sycommon/agent/deep_agent.py
CHANGED
|
@@ -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
|
|
17
|
-
LD_PRELOAD
|
|
16
|
+
这是 L3/L4 内核级硬墙,语言无关、绕不过——比旧应用层 hook(已删的
|
|
17
|
+
monkeypatch/LD_PRELOAD 等)强且无盲区。
|
|
18
18
|
|
|
19
19
|
设计要点:
|
|
20
20
|
- owner-match(-m owner --uid-owner):只锁 child uid,沙箱服务进程(root/其它 uid)
|
sycommon/middleware/sandbox.py
CHANGED
|
@@ -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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
"""
|
|
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,免鉴权,打穿即跨用户任意执行)
|
|
416
|
-
#
|
|
417
|
-
|
|
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
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
243
|
-
sycommon/middleware/sandbox_gateway.py,sha256=
|
|
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.
|
|
337
|
-
sycommon_python_lib-0.2.
|
|
338
|
-
sycommon_python_lib-0.2.
|
|
339
|
-
sycommon_python_lib-0.2.
|
|
340
|
-
sycommon_python_lib-0.2.
|
|
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()
|
|
File without changes
|
{sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.8a4.dist-info → sycommon_python_lib-0.2.8a6.dist-info}/top_level.txt
RENAMED
|
File without changes
|