agent-Specialization/modules/docker_readonly_exec.py
JOJO a3fa5e59c6 feat(sandbox): Docker 只读执行叠加 Landlock 进程级只读域,封死 world-writable 绕 DAC 漏洞
Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-01 15:17:23 +08:00

185 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""docker 容器内只读执行非特权执行角色uid/gid
设计2026-08-30 云端实测验证,详见项目记忆 sandbox_readonly_reform_research
- 容器主进程与可写执行保持 rootsandbox_write_access=False 的执行通道
run_command 前台/后台、只读语境下创建的持久终端)统一改用非特权 uid
(默认 10001:10001可用环境变量覆盖
- 强制力来自内核 DAC工作区 bind 挂载的文件属主是宿主机 root非属主且
无 o+w → 写/删/chmod/umount 一律 EACCES/EPERM600 权限的 .env 等敏感
文件天然不可读。此前的「命令文本特征识别」降级为审批决策的启发式,
不再是 docker 只读的安全边界。
- 不依赖镜像中存在该用户(数字 uid 直接生效),旧镜像直接受益;
docker/terminal.Dockerfile 同步创建 agent 用户与 git safe.directory
供未来构建的镜像使用。
- 逃逸门槛需要先提权setuid 漏洞/内核漏洞),远高于嵌套 namespace
方案的 umount 逃逸;云端默认 seccomp 会拦 unshare(CLONE_NEWUSER)
嵌套方案已被本方案取代。
- 已知边界macOS Docker Desktop 的 virtiofsfakeowner不按 uid 执行
权限检查,本机制仅在 Linux 宿主机(云端/ Linux 桌面)生效,属预期差异;
持久终端在 approval/auto_approval 模式下同为只读身份,写入命令请走
run_command审批通过后以 root 重跑)。
Landlock 加固2026-09 起):
- 纯 DAC 的残留漏洞:工作区内历史遗留的 world-writable777/o+w路径
对只读 uid 仍开放写权限。为此在只读执行时额外用 Landlock 给进程套上
「工作区写类操作全拒」的内核域(最终权限 = DAC ∩ Landlock封死该洞。
- launcher 为本目录 landlock_launcher.py首次只读执行时 docker cp 进容器
并以只读身份自检(本进程+子进程写工作区都必须被拒),通过后才启用;
任何一步失败都静默降级为纯 DAC并以 warning 日志标注 enforcement level。
- 可用环境变量 DOCKER_READONLY_LANDLOCK=0 整体停用(运维逃生门)。
- 语义刻意对齐纯 DAC 现状:仅工作区写类操作被拒,/tmp 等其余路径写、
全部读/执行行为不变(读保护仍由 DAC 承担)。
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import threading
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("docker_readonly_exec")
LANDLOCK_LAUNCHER_SRC = Path(__file__).resolve().with_name("landlock_launcher.py")
LANDLOCK_LAUNCHER_CONTAINER_PATH = "/opt/astrion-landlock/launcher.py"
# 容器名 -> "available" / "unavailable:<reason>"None 表示尚未探测
_landlock_states: Dict[str, str] = {}
_landlock_states_lock = threading.Lock()
def docker_readonly_uid_gid() -> Tuple[str, str]:
"""只读执行身份的 uid/gid默认 10001:10001环境变量可覆盖
选取原则:避开宿主机工作区文件属主 uid否则会被 DAC 认成主人而放行),
10001 为冷门值;如部署环境文件属主恰为该值,用环境变量改。
"""
uid = os.environ.get("DOCKER_READONLY_EXEC_UID", "10001").strip() or "10001"
gid = os.environ.get("DOCKER_READONLY_EXEC_GID", uid).strip() or uid
return uid, gid
def docker_readonly_exec_args() -> List[str]:
"""构造只读执行的 docker exec 前缀参数(紧跟在 "exec" 之后)。
含:
- ``-u uid:gid``:非特权执行身份(内核 DAC 强制只读的核心);
- ``HOME=/tmp``uid 在 /etc/passwd 无条目时提供可写 HOME
- git safe.directory 修复:工作区属主是 root非属主 git 会报
"detected dubious ownership",用 env 方式注入,不依赖镜像内 gitconfig。
"""
uid, gid = docker_readonly_uid_gid()
return [
"-u", f"{uid}:{gid}",
"-e", "HOME=/tmp",
"-e", "GIT_CONFIG_COUNT=1",
"-e", "GIT_CONFIG_KEY_0=safe.directory",
"-e", "GIT_CONFIG_VALUE_0=*",
]
def _landlock_enabled() -> bool:
return os.environ.get("DOCKER_READONLY_LANDLOCK", "1").strip().lower() not in (
"0", "false", "off", "no",
)
def _docker_run(docker_bin: str, args: List[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(
[docker_bin] + args, capture_output=True, timeout=timeout,
)
def _deploy_and_selftest(container_name: str, mount_path: str,
docker_bin: str) -> Tuple[bool, str]:
"""把 launcher 部署进容器并以只读身份自检。返回 (是否可用, 失败原因)。"""
mount_path = (mount_path or "/workspace").rstrip("/") or "/"
try:
# 1. 容器内 python3 可用性
r = _docker_run(docker_bin, ["exec", container_name, "sh", "-c",
"command -v python3"], timeout=10)
if r.returncode != 0:
return False, "no-python3-in-container"
# 2. 部署 launcher 文件
r = _docker_run(docker_bin, ["exec", container_name, "mkdir", "-p",
os.path.dirname(LANDLOCK_LAUNCHER_CONTAINER_PATH)],
timeout=10)
if r.returncode != 0:
return False, f"mkdir-failed:{r.stderr.decode(errors='replace')[:200]}"
r = _docker_run(docker_bin, ["cp", str(LANDLOCK_LAUNCHER_SRC),
f"{container_name}:{LANDLOCK_LAUNCHER_CONTAINER_PATH}"],
timeout=30)
if r.returncode != 0:
return False, f"docker-cp-failed:{r.stderr.decode(errors='replace')[:200]}"
# 3. 以只读身份自检(与真实只读执行同一 uid/环境)
r = _docker_run(
docker_bin,
["exec", *docker_readonly_exec_args(), container_name,
"python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--selftest", "--ro", mount_path],
timeout=30,
)
out = (r.stdout + r.stderr).decode(errors="replace")
# 4. 清理自检意外成功时的残留(此时说明 Landlock 未生效,但仍要扫尾)
try:
_docker_run(docker_bin, ["exec", container_name, "rm", "-f",
f"{mount_path}/.landlock_selftest_probe"],
timeout=10)
except Exception:
pass
if r.returncode == 0 and "LANDLOCK_SELFTEST_OK" in out:
return True, ""
return False, f"selftest-failed:{out.strip()[:300]}"
except subprocess.TimeoutExpired:
return False, "probe-timeout"
except Exception as e: # noqa: BLE001 - 部署探测必须兜底为降级而非异常
return False, f"{type(e).__name__}:{e}"
def ensure_landlock_ready(container_name: str, mount_path: str,
docker_bin: Optional[str] = None) -> bool:
"""确保容器内 Landlock 只读域可用(部署+自检,按容器缓存结果)。
任何失败都返回 False调用方回退纯 DAC不会抛出。
"""
if not _landlock_enabled():
return False
with _landlock_states_lock:
state = _landlock_states.get(container_name)
if state is not None:
return state == "available"
docker_bin = docker_bin or shutil.which("docker") or "docker"
ok, reason = _deploy_and_selftest(container_name, mount_path, docker_bin)
with _landlock_states_lock:
_landlock_states[container_name] = "available" if ok else f"unavailable:{reason}"
if ok:
logger.info("landlock readonly ready: container=%s mount=%s",
container_name, mount_path)
else:
logger.warning(
"landlock unavailable: container=%s reason=%s"
"readonly enforcement falls back to DAC-only (world-writable "
"paths remain writable by the readonly uid)",
container_name, reason,
)
return ok
def docker_readonly_wrap_inner(container_name: str, mount_path: str,
inner_cmd: List[str],
docker_bin: Optional[str] = None) -> List[str]:
"""只读执行时包装容器内命令Landlock 可用则经 launcher 进入只读域。
可用: [python3, launcher, --ro, mount, --] + inner_cmd
不可用:原样返回 inner_cmd纯 DAC 降级)。
"""
if ensure_landlock_ready(container_name, mount_path, docker_bin):
mount = (mount_path or "/workspace").rstrip("/") or "/"
return ["python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--ro", mount, "--", *inner_cmd]
return list(inner_cmd)