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
|
||||
|
||||
|
||||
# ── 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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user