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:
parent
9ec38df6b4
commit
c9d914b9ff
@ -243,28 +243,143 @@ async def _is_safe_workdir_async(workdir):
|
|||||||
return False
|
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):
|
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
|
cwd = os.path.abspath(workdir) if workdir else WORKSPACE_BASE
|
||||||
if not await _is_safe_workdir_async(cwd):
|
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):
|
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:
|
try:
|
||||||
proc = await asyncio.create_subprocess_shell(
|
if bwrap:
|
||||||
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
writable_root = await _sandbox_writable_root(cwd)
|
||||||
cwd=cwd, executable='/bin/bash')
|
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:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
await proc.wait()
|
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,
|
return {"rc": proc.returncode or 0,
|
||||||
"stdout": stdout.decode('utf-8', 'replace')[-8000:],
|
"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:
|
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:
|
def _inject_org_ssh_key(command: str, org_id: str) -> str:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user