security(agent): _run_shell 接入 bwrap 沙箱——命令隔离落地

- 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(不阻断现有功能)
This commit is contained in:
ymq 2026-08-28 14:26:32 +08:00
parent 9ec38df6b4
commit c9d914b9ff

View File

@ -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",
]
# DNSsystemd-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: