From c9d914b9ffbe805d9888785dcca52ee7e8ed92c5 Mon Sep 17 00:00:00 2001 From: ymq Date: Fri, 28 Aug 2026 14:26:32 +0800 Subject: [PATCH] =?UTF-8?q?security(agent):=20=5Frun=5Fshell=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20bwrap=20=E6=B2=99=E7=AE=B1=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=9A=94=E7=A6=BB=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent 的 run_shell/run_command 从裸 create_subprocess_shell 改为 bwrap 沙箱 - user/pid/ipc/uts namespace 隔离,网络保留(git push/ssh 部署依赖) - 系统目录+/d/pipeline+/d/doit 只读(平台代码/配置/其他机构工作区不可写) - 当前机构工作空间目录可写(跨机构写隔离);/tmp 会话私有 - DNS 兼容 systemd-resolved(/etc/resolv.conf 软链→/run 只读挂载) - 无 bwrap 降级为原目录隔离并标 sandbox: false(不阻断现有功能) --- pipeline_service/agent_loop.py | 133 ++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 9 deletions(-) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index c637ad4..73ded22 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -243,28 +243,143 @@ async def _is_safe_workdir_async(workdir): return False +# ── bwrap 沙箱执行(agent 命令隔离) ── +# run_shell/run_command 的出口统一走 bwrap: +# - user/pid/ipc/uts namespace 隔离,网络保留(git push / ssh 部署需要) +# - 系统目录 + /d/pipeline、/d/doit(平台代码/配置/其他机构工作区)只读 +# - 当前机构工作空间目录可写(叠在只读挂载之上 → 跨机构写隔离) +# - /tmp 为会话私有 tmpfs +# 无 bwrap → 降级为原有目录隔离(返回 sandbox: False)。 + +_BWRAP_CACHE = None + + +def _find_bwrap(): + """定位 bwrap 二进制(缓存结果,找不到返回 None)。""" + global _BWRAP_CACHE + if _BWRAP_CACHE is not None: + return _BWRAP_CACHE or None + import shutil as _sh + cand = _sh.which("bwrap") + if not cand: + for p in ( + os.environ.get("BWRAP_PATH", ""), + "/d/pipeline/bin/bwrap", + "/d/pipeline/pipeline-app/bin/bwrap", + "/d/doit/bin/bwrap", + "/d/doit/pipeline-app/bin/bwrap", + ): + if p and os.path.exists(p) and os.access(p, os.X_OK): + cand = p + break + _BWRAP_CACHE = cand or "" + return cand + + +async def _sandbox_writable_root(cwd: str) -> str: + """计算沙箱可写挂载根: + - cwd 在 workspace_base(含 params 表动态值)下 → 取机构级子目录(机构隔离) + - 其他允许目录 → cwd 本身 + """ + bases = [] + try: + db = _get_db() + async with db.sqlorContext("pipeline") as sor: + from .workspace import get_workspace_base + b = await get_workspace_base(sor) + if b: + bases.append(os.path.abspath(b)) + except Exception: + pass + bases.append(os.path.abspath(WORKSPACE_BASE)) + seen = set() + for base in bases: + bp = base.rstrip("/") + if bp in seen: + continue + seen.add(bp) + if cwd == bp or cwd.startswith(bp + "/"): + rel = cwd[len(bp):].lstrip("/") + seg = rel.split("/")[0] if rel else "" + if seg: + root = os.path.join(bp, seg) + if os.path.isdir(root): + return root + return cwd + return cwd + + +def _build_agent_bwrap_cmd(bwrap: str, cwd: str, writable_root: str, command: str) -> list: + """构建 agent 命令的 bwrap 参数(列表传参,防注入)。""" + parts = [ + bwrap, + "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", + "--die-with-parent", + "--ro-bind", "/usr", "/usr", + "--ro-bind", "/bin", "/bin", + "--ro-bind", "/sbin", "/sbin", + "--ro-bind", "/lib", "/lib", + ] + if os.path.exists("/lib64"): + parts += ["--ro-bind", "/lib64", "/lib64"] + parts += [ + "--ro-bind", "/etc", "/etc", + "--ro-bind", "/sys", "/sys", + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ] + # DNS:systemd-resolved 系统 /etc/resolv.conf 是软链 → /run/resolvconf/resolv.conf, + # 沙箱内软链断 → 把真实文件所在目录只读挂进去(普通文件已被 /etc 挂载覆盖)。 + try: + rc = os.path.realpath("/etc/resolv.conf") + if os.path.isfile(rc) and os.path.dirname(rc) != "/etc": + parts += ["--ro-bind", os.path.dirname(rc), os.path.dirname(rc)] + except Exception: + pass + # 平台目录整体只读(代码/配置/密钥/其他机构工作区可读不可写) + for ro_dir in ("/d/pipeline", "/d/doit"): + if os.path.isdir(ro_dir): + parts += ["--ro-bind", ro_dir, ro_dir] + # 当前机构工作目录可写(叠在只读挂载之上;/tmp 下的工作目录叠在 tmpfs 之上) + if writable_root and os.path.isdir(writable_root): + parts += ["--bind", writable_root, writable_root] + parts += ["--chdir", cwd, "--", "bash", "-c", command] + return parts + + async def _run_shell(command, workdir, timeout=120): - """安全执行 shell 命令。返回 {"rc": int, "stdout": str, "stderr": str}""" + """安全执行 shell 命令(优先 bwrap 沙箱)。返回 {"rc","stdout","stderr","sandbox"}""" cwd = os.path.abspath(workdir) if workdir else WORKSPACE_BASE if not await _is_safe_workdir_async(cwd): - return {"rc": -1, "stdout": "", "stderr": f"安全限制:目录 {cwd} 不在允许范围"} + return {"rc": -1, "stdout": "", "stderr": f"安全限制:目录 {cwd} 不在允许范围", "sandbox": False} if not os.path.isdir(cwd): - return {"rc": -1, "stdout": "", "stderr": f"目录不存在: {cwd}"} + return {"rc": -1, "stdout": "", "stderr": f"目录不存在: {cwd}", "sandbox": False} + bwrap = _find_bwrap() try: - proc = await asyncio.create_subprocess_shell( - command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=cwd, executable='/bin/bash') + if bwrap: + writable_root = await _sandbox_writable_root(cwd) + cmd = _build_agent_bwrap_cmd(bwrap, cwd, writable_root, command) + # bwrap 用列表直接执行(非 shell 拼接);命令本身仍由沙箱内 bash -c 解释 + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + else: + # 降级:无 bwrap,保持原有目录隔离(弱隔离),标记 sandbox: False + proc = await asyncio.create_subprocess_shell( + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + cwd=cwd, executable='/bin/bash') try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() - return {"rc": -1, "stdout": "", "stderr": f"命令超时({timeout}s)"} + return {"rc": -1, "stdout": "", "stderr": f"命令超时({timeout}s)", "sandbox": bool(bwrap)} return {"rc": proc.returncode or 0, "stdout": stdout.decode('utf-8', 'replace')[-8000:], - "stderr": stderr.decode('utf-8', 'replace')[-4000:]} + "stderr": stderr.decode('utf-8', 'replace')[-4000:], + "sandbox": bool(bwrap)} except Exception as e: - return {"rc": -1, "stdout": "", "stderr": str(e)[:500]} + return {"rc": -1, "stdout": "", "stderr": str(e)[:500], "sandbox": bool(bwrap)} def _inject_org_ssh_key(command: str, org_id: str) -> str: