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.
@@ -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 = os.environ.copy()
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):