sycommon-python-lib 0.2.8a3__py3-none-any.whl → 0.2.8a4__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 +7 -3
- sycommon/agent/middleware/skill_api_whitelist.py +49 -10
- sycommon/config/Config.py +22 -12
- sycommon/config/EgressPolicyConfig.py +183 -0
- sycommon/middleware/egress_wall.py +268 -0
- sycommon/middleware/sandbox.py +183 -2
- sycommon/middleware/sandbox_gateway.py +542 -0
- sycommon/models/sandbox.py +26 -1
- sycommon/services.py +29 -1
- {sycommon_python_lib-0.2.8a3.dist-info → sycommon_python_lib-0.2.8a4.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.8a3.dist-info → sycommon_python_lib-0.2.8a4.dist-info}/RECORD +14 -11
- {sycommon_python_lib-0.2.8a3.dist-info → sycommon_python_lib-0.2.8a4.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.8a3.dist-info → sycommon_python_lib-0.2.8a4.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.8a3.dist-info → sycommon_python_lib-0.2.8a4.dist-info}/top_level.txt +0 -0
sycommon/middleware/sandbox.py
CHANGED
|
@@ -60,6 +60,7 @@ from sycommon.models.sandbox import (
|
|
|
60
60
|
ExecuteStreamRequest,
|
|
61
61
|
PortForwardRequest, PortForwardResponse,
|
|
62
62
|
WatchRequest, WatchEvent, WatchResponse,
|
|
63
|
+
CredentialPushRequest, CredentialPushResponse,
|
|
63
64
|
)
|
|
64
65
|
|
|
65
66
|
|
|
@@ -581,6 +582,102 @@ def resolve_sandbox_path(path: str, workspace: str) -> str:
|
|
|
581
582
|
_SUBPROCESS_NEW_SESSION = platform.system() != "Windows"
|
|
582
583
|
|
|
583
584
|
|
|
585
|
+
# 技能 scope 提取:命令里出现的 skills/<scope>/<技能>/ 路径 → scope。
|
|
586
|
+
# 用于按技能目录分级出网策略:
|
|
587
|
+
# system / shengye-platform → 全管制(名单外 DROP)
|
|
588
|
+
# user → 只管制 SYT/星瀚/OA,其余放行
|
|
589
|
+
# 多个 scope 同时出现(极少)取最严(system/shengye-platform 优先)。
|
|
590
|
+
_SKILL_SCOPE_RE = None # 延迟编译
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _extract_skill_scope(command: str) -> str:
|
|
594
|
+
"""从命令字符串提取技能 scope:'system' | 'shengye-platform' | 'user' | ''。
|
|
595
|
+
|
|
596
|
+
匹配 skills/<scope>/<技能名>/ 形态;同严优先:system > shengye-platform > user。
|
|
597
|
+
命令不含技能路径 → ''(按 user 宽松策略处理,不误伤普通命令)。
|
|
598
|
+
"""
|
|
599
|
+
global _SKILL_SCOPE_RE
|
|
600
|
+
if _SKILL_SCOPE_RE is None:
|
|
601
|
+
import re
|
|
602
|
+
_SKILL_SCOPE_RE = re.compile(
|
|
603
|
+
r'(?<![\w])skills/(system|shengye-platform|user)/[A-Za-z0-9_\-]+(?:/|$)')
|
|
604
|
+
found = set(_SKILL_SCOPE_RE.findall(command or ""))
|
|
605
|
+
# 严格优先:任一 system/shengye-platform 出现即按其处理
|
|
606
|
+
if "system" in found or "shengye-platform" in found:
|
|
607
|
+
return "system" # 两者策略一致(全管制),归并为 system 语义
|
|
608
|
+
if "user" in found:
|
|
609
|
+
return "user"
|
|
610
|
+
return ""
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# 出网收口:child 子进程降权到的固定非特权 uid。
|
|
614
|
+
# 收口硬墙(iptables OUTPUT DROP -m owner --uid-owner)按此 uid 区分 child 与
|
|
615
|
+
# 沙箱服务进程——服务进程连 Nacos/Redis/MinIO 用别的 uid,不受 child 规则影响;
|
|
616
|
+
# child 降为该 uid 后既改不了 iptables(无 CAP_NET_ADMIN),又被 owner-match 锁死出网。
|
|
617
|
+
# 65534 = Linux "nobody",公认的非特权 uid。
|
|
618
|
+
CHILD_DROP_UID = int(os.environ.get("SANDBOX_CHILD_UID", "65534"))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _preexec_drop_privileges():
|
|
622
|
+
"""preexec_fn:fork 后、exec 前把 child 降到非特权 uid(仅 Linux)。
|
|
623
|
+
|
|
624
|
+
在 start_new_session(新会话/进程组)之后、exec 之前执行。降权使 child:
|
|
625
|
+
1. 无 CAP_NET_ADMIN → 改不了 iptables 规则(硬墙不可被 child 自清)。
|
|
626
|
+
2. 与服务进程 uid 不同 → iptables owner-match 能精确只锁 child 出网。
|
|
627
|
+
降权后 child 仍需能写自己的工作区目录,故 workspace 在 aensure_subdirs 后
|
|
628
|
+
会 chown 给该 uid(见 _chown_workspace_for_child)。
|
|
629
|
+
"""
|
|
630
|
+
if platform.system() == "Windows":
|
|
631
|
+
return
|
|
632
|
+
try:
|
|
633
|
+
uid = CHILD_DROP_UID
|
|
634
|
+
# 保留 gid 为 uid 同值(nobody:nobody),彻底非特权。
|
|
635
|
+
os.setgid(uid)
|
|
636
|
+
os.setuid(uid)
|
|
637
|
+
except Exception as e:
|
|
638
|
+
# 降权失败(如非 root 运行沙箱服务、或 uid 不存在):不阻断执行,
|
|
639
|
+
# 但记录——此时 child 仍为服务进程 uid,iptables owner-match 会连带影响
|
|
640
|
+
# 服务进程(需运维确保沙箱服务以 root 起、child 可降权)。
|
|
641
|
+
SYLogger.warning(f"[Sandbox Server] child 降权失败(uid={CHILD_DROP_UID}): {e}")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async def _chown_workspace_for_child(workspace: str):
|
|
645
|
+
"""把用户工作区 chown 给 child uid,使降权后的 child 能读写自己的文件。
|
|
646
|
+
|
|
647
|
+
必须在 spawn child 之前完成,否则降权后的 child 因无写权限报错。
|
|
648
|
+
幂等:已是该 uid 时不重复 chown。
|
|
649
|
+
"""
|
|
650
|
+
if platform.system() == "Windows":
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
def _chown():
|
|
654
|
+
uid = CHILD_DROP_UID
|
|
655
|
+
try:
|
|
656
|
+
import pwd
|
|
657
|
+
# 确认 uid 存在(nobody 通常存在);不存在则不 chown,靠降权失败兜底。
|
|
658
|
+
try:
|
|
659
|
+
pwd.getpwuid(uid)
|
|
660
|
+
except KeyError:
|
|
661
|
+
SYLogger.warning(f"[Sandbox Server] uid={uid} 不存在,跳过 chown(child 降权将失败并保留服务 uid)")
|
|
662
|
+
return
|
|
663
|
+
# 递归 chown 工作区(顶层 + 子目录/文件),使降权 child 可读写。
|
|
664
|
+
for root, dirs, files in os.walk(workspace):
|
|
665
|
+
try:
|
|
666
|
+
os.chown(root, uid, uid)
|
|
667
|
+
except OSError:
|
|
668
|
+
pass
|
|
669
|
+
for name in dirs + files:
|
|
670
|
+
p = os.path.join(root, name)
|
|
671
|
+
try:
|
|
672
|
+
os.chown(p, uid, uid)
|
|
673
|
+
except OSError:
|
|
674
|
+
pass
|
|
675
|
+
except Exception as e:
|
|
676
|
+
SYLogger.warning(f"[Sandbox Server] chown 工作区失败: {e}")
|
|
677
|
+
|
|
678
|
+
await asyncio.to_thread(_chown)
|
|
679
|
+
|
|
680
|
+
|
|
584
681
|
@functools.lru_cache(maxsize=1)
|
|
585
682
|
def _grep_supports_null_separator() -> bool:
|
|
586
683
|
"""探测系统 grep 是否为 GNU 语义的 `-Z`(文件名后 NUL 分隔)。
|
|
@@ -792,6 +889,41 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
792
889
|
type="remote",
|
|
793
890
|
)
|
|
794
891
|
|
|
892
|
+
# ============== 出网收口凭据推送(out-of-band)==============
|
|
893
|
+
# 主进程把凭据推给沙箱内嵌网关。凭据【绝不】进子进程 env、不进命令串;
|
|
894
|
+
# 子进程 SYT_BASE_URL 指向网关,请求经网关时按目标 host 注入正确凭据。
|
|
895
|
+
# 详见 sandbox_gateway.py。local 模式单用户;shared 模式按 user_id 绑定。
|
|
896
|
+
|
|
897
|
+
@sandbox_router.post("/credentials", response_model=CredentialPushResponse)
|
|
898
|
+
async def push_credentials(req: CredentialPushRequest):
|
|
899
|
+
"""推送凭据(覆盖该 user_id)。返回网关地址,主进程据此构造子进程 SYT_BASE_URL。"""
|
|
900
|
+
from sycommon.middleware.sandbox_gateway import credential_store, gateway_base_url
|
|
901
|
+
await credential_store().set(req.user_id, req.creds)
|
|
902
|
+
SYLogger.info(
|
|
903
|
+
f"[Sandbox Server] 凭据已推送: user={req.user_id}, "
|
|
904
|
+
f"keys={list(req.creds.keys())}")
|
|
905
|
+
return CredentialPushResponse(
|
|
906
|
+
user_id=req.user_id, gateway_base_url=gateway_base_url())
|
|
907
|
+
|
|
908
|
+
@sandbox_router.put("/credentials", response_model=CredentialPushResponse)
|
|
909
|
+
async def refresh_credentials(req: CredentialPushRequest):
|
|
910
|
+
"""刷新凭据(token 更新)。语义同 POST(整体替换该 user_id 的凭据)。"""
|
|
911
|
+
from sycommon.middleware.sandbox_gateway import credential_store, gateway_base_url
|
|
912
|
+
await credential_store().set(req.user_id, req.creds)
|
|
913
|
+
SYLogger.info(
|
|
914
|
+
f"[Sandbox Server] 凭据已刷新: user={req.user_id}, "
|
|
915
|
+
f"keys={list(req.creds.keys())}")
|
|
916
|
+
return CredentialPushResponse(
|
|
917
|
+
user_id=req.user_id, gateway_base_url=gateway_base_url())
|
|
918
|
+
|
|
919
|
+
@sandbox_router.delete("/credentials")
|
|
920
|
+
async def delete_credentials(user_id: str):
|
|
921
|
+
"""清除该 user_id 的凭据(token 失效/登出)。返回 {deleted: bool}。"""
|
|
922
|
+
from sycommon.middleware.sandbox_gateway import credential_store
|
|
923
|
+
deleted = await credential_store().delete(user_id)
|
|
924
|
+
SYLogger.info(f"[Sandbox Server] 凭据清除: user={user_id}, deleted={deleted}")
|
|
925
|
+
return {"deleted": deleted}
|
|
926
|
+
|
|
795
927
|
@sandbox_router.post("/execute", response_model=ExecuteResponse)
|
|
796
928
|
async def execute(req: ExecuteRequest):
|
|
797
929
|
"""执行 shell 命令"""
|
|
@@ -807,6 +939,8 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
807
939
|
f"[Sandbox Server] 执行命令: {req.command} (user={req.user_id}, timeout={req.timeout}s)")
|
|
808
940
|
|
|
809
941
|
await aensure_subdirs(workspace)
|
|
942
|
+
# 降权前置:把工作区 chown 给 child uid,使降权后的 child 能读写自己的文件。
|
|
943
|
+
await _chown_workspace_for_child(workspace)
|
|
810
944
|
|
|
811
945
|
# 使用统一初始化脚本
|
|
812
946
|
init_script = _build_init_script(workspace)
|
|
@@ -821,12 +955,50 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
821
955
|
# 构建跨平台执行命令
|
|
822
956
|
full_command, _ = _build_execute_command(init_script, translated_command)
|
|
823
957
|
|
|
824
|
-
#
|
|
825
|
-
env
|
|
958
|
+
# 设置环境变量:**白名单构造,不 os.environ.copy()**。
|
|
959
|
+
# 收口目标:子进程 env 零密钥(SYT_TOKEN / SYT_API_AUTHZ / WECOM_* 一律不进 env)。
|
|
960
|
+
# 凭据由沙箱网关在请求时按目标 host 注入(见 sandbox_gateway.py),
|
|
961
|
+
# 主进程经 /v1/sandbox/credentials 推送给网关(out-of-band)。
|
|
962
|
+
# 故这里只给子进程:PATH 等运行必需项 + 临时目录 + 沙箱根 + 网关地址。
|
|
963
|
+
env = {}
|
|
964
|
+
# 运行必需的非敏感 env 白名单(PATH/HOME/LANG 等),其余丢弃以防泄漏服务端密钥。
|
|
965
|
+
for k in ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
|
|
966
|
+
"PYTHONPATH", "PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE"):
|
|
967
|
+
v = os.environ.get(k)
|
|
968
|
+
if v is not None:
|
|
969
|
+
env[k] = v
|
|
826
970
|
env["TMPDIR"] = os.path.join(workspace, "tmp")
|
|
827
971
|
env["TEMP"] = os.path.join(workspace, "tmp")
|
|
828
972
|
env["TMP"] = os.path.join(workspace, "tmp")
|
|
829
973
|
env["SANDBOX_ROOT"] = workspace
|
|
974
|
+
# 出网网关:子进程 SYT_BASE_URL 指向网关(http://127.0.0.1:port),
|
|
975
|
+
# HTTPS_PROXY 让 requests/httpx/curl/wget 自动走网关。node/裸 socket 不读
|
|
976
|
+
# proxy 的运行时,由后续 seccomp 硬墙兜底(只放行网关 host:port)。
|
|
977
|
+
from sycommon.middleware.sandbox_gateway import gateway_base_url
|
|
978
|
+
gw = gateway_base_url()
|
|
979
|
+
if gw:
|
|
980
|
+
env["SYT_BASE_URL"] = gw
|
|
981
|
+
# ⚠️ 审核修正(P0-1):不再设 HTTP_PROXY/HTTPS_PROXY。网关不支持
|
|
982
|
+
# CONNECT 隧道,设了反而把所有硬编码 https:// 的技能(OA/星瀚/亿联/
|
|
983
|
+
# 企微)打成 404 tunnel 错误。SYT 流量本来就经 SYT_BASE_URL 指向网关;
|
|
984
|
+
# 其他域名按 EgressPolicy(defaultMode=allow)直连即可。
|
|
985
|
+
env["NO_PROXY"] = "*"
|
|
986
|
+
# 技能 scope(system/shengye-platform/user):网关据此分级出网策略。
|
|
987
|
+
# 命令级权威注入(审核 P0-3):env 只写 scope 供脚本读取展示;
|
|
988
|
+
# 真正的分级判定在网关侧按【本命令的 scope】执行——见下方
|
|
989
|
+
# _gateway_scope 注册(每次 /execute 前把 scope 告知网关,
|
|
990
|
+
# 子进程无法伪造/降级,因为它根本不经子进程)。
|
|
991
|
+
scope = _extract_skill_scope(req.command)
|
|
992
|
+
if scope:
|
|
993
|
+
env["SANDBOX_SKILL_SCOPE"] = scope
|
|
994
|
+
try:
|
|
995
|
+
from sycommon.middleware import sandbox_gateway as _sgw
|
|
996
|
+
_sgw.set_active_scope(scope or "user")
|
|
997
|
+
except Exception as _se:
|
|
998
|
+
SYLogger.warning(f"[Sandbox Server] 网关 scope 注册失败(降级user): {_se}")
|
|
999
|
+
else:
|
|
1000
|
+
# 网关未启动(降级):仍尽量保持环境可用,但记警告。
|
|
1001
|
+
SYLogger.warning("[Sandbox Server] 出网网关未就绪,子进程将无法经收口出网")
|
|
830
1002
|
|
|
831
1003
|
try:
|
|
832
1004
|
# 使用 asyncio.create_subprocess_shell 异步执行命令,避免阻塞事件循环
|
|
@@ -841,6 +1013,7 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
841
1013
|
cwd=workspace,
|
|
842
1014
|
env=env,
|
|
843
1015
|
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
1016
|
+
preexec_fn=_preexec_drop_privileges if platform.system() != "Windows" else None,
|
|
844
1017
|
)
|
|
845
1018
|
|
|
846
1019
|
try:
|
|
@@ -900,6 +1073,14 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
900
1073
|
exit_code=1,
|
|
901
1074
|
truncated=False
|
|
902
1075
|
)
|
|
1076
|
+
finally:
|
|
1077
|
+
# 命令结束:清除命令级 scope 注册(下一条命令重新注册;
|
|
1078
|
+
# 防止残留 scope 影响后续普通命令的网关分级)
|
|
1079
|
+
try:
|
|
1080
|
+
from sycommon.middleware import sandbox_gateway as _sgw
|
|
1081
|
+
_sgw.set_active_scope("user")
|
|
1082
|
+
except Exception:
|
|
1083
|
+
pass
|
|
903
1084
|
|
|
904
1085
|
@sandbox_router.post("/ls") # response_model 不固定:dict(error)/list[FileInfo]
|
|
905
1086
|
async def ls_info(req: LsRequest):
|