feat(sandbox): Docker 只读执行叠加 Landlock 进程级只读域,封死 world-writable 绕 DAC 漏洞

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-01 15:17:23 +08:00
parent 8da53635c4
commit a3fa5e59c6
6 changed files with 370 additions and 17 deletions

View File

@ -412,10 +412,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。 只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。
- **docker/web 模式 = 非特权 uid 执行角色**`modules/docker_readonly_exec.py` - **docker/web 模式 = 非特权 uid 执行角色 + Landlock 进程级只读域**`modules/docker_readonly_exec.py`
- 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖 - 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖
- 强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径 - 第一层强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock云端已验证macOS Docker Desktopvirtiofs fakeowner不执行 uid 权限,仅 Linux 宿主生效 - **第二层 = Landlock 只读域**2026-09 新增,云端实测 kernel 6.8 / ABI V4 / Docker 28 默认 seccomp 放行):纯 DAC 的残留漏洞是工作区内历史遗留的 world-writable777/o+w路径对只读 uid 仍可写;只读执行时命令再经 `modules/landlock_launcher.py`(首次执行时 docker cp 进容器并自检)进入「工作区写类操作全拒」的内核域,最终权限 = DAC ∩ Landlock该洞封死。要点
- launcher 规则handled 只含写类操作ro 路径(工作区挂载点)不加规则(无覆盖即拒绝);/tmp、/var/tmp、/dev/shm 显式授写权以对齐纯 DAC 现状行为(只读身份 HOME=/tmp读/执行不进 handled仍由 DAC 管。注意不能写「/ 授全量 + ro 授空」的交集规则——空授权规则被内核拒绝ENOMSG/errno 42
- 自检失败(内核 <5.13 / seccomp 拦截 / 容器无 python3自动降级纯 DACwarning 日志标注 enforcement level`DOCKER_READONLY_LANDLOCK=0` 可整体停用
- 语义边界Landlock 不管 chmod/chown 等元数据修改,也不管未配置的网络——因此非特权 uid 层必须保留root+Landlock 的进程可 chmod 放宽权限位让域外进程受益)
- 前提:工作区属主与该 uid 不碰撞、容器未挂 docker.sock云端已验证macOS Docker Desktop 双重不适用virtiofs fakeowner 不按 uid 检查linuxkit 内核未编译 Landlock仅 Linux 宿主生效
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()` - 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()`
- Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益 - Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益
- **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py` - **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py`

View File

@ -19,7 +19,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
@ -225,9 +225,12 @@ class BackgroundCommandManager:
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"] exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access: if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args() exec_cmd += docker_readonly_exec_args()
# Landlock 加固:可用时再以进程级只读域封住工作区写;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_bin)
exec_cmd += [ exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -236,9 +239,7 @@ class BackgroundCommandManager:
"-w", "-w",
container_workdir, container_workdir,
container_name, container_name,
"/bin/bash", *inner_cmd,
"-lc",
command,
] ]
use_shell = False use_shell = False

View File

@ -19,11 +19,37 @@
权限检查本机制仅在 Linux 宿主机云端/ Linux 桌面生效属预期差异 权限检查本机制仅在 Linux 宿主机云端/ Linux 桌面生效属预期差异
持久终端在 approval/auto_approval 模式下同为只读身份写入命令请走 持久终端在 approval/auto_approval 模式下同为只读身份写入命令请走
run_command审批通过后以 root 重跑 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 from __future__ import annotations
import logging
import os import os
from typing import List, Tuple 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]: def docker_readonly_uid_gid() -> Tuple[str, str]:
@ -54,3 +80,105 @@ def docker_readonly_exec_args() -> List[str]:
"-e", "GIT_CONFIG_KEY_0=safe.directory", "-e", "GIT_CONFIG_KEY_0=safe.directory",
"-e", "GIT_CONFIG_VALUE_0=*", "-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)

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Astrion Landlock 只读域 launcher容器内运行非后端模块
由后端 modules/docker_readonly_exec.py 部署到容器内docker cp并调用
python3 launcher.py --ro /workspace -- <cmd> [args...]
python3 launcher.py --selftest --ro /workspace
语义2026-09 云端实测kernel 6.8 / Landlock ABI V4 / Docker 28 默认 seccomp 放行
- handled 集合只含写类操作写文件/建删文件目录/创建设备节点/符号链接/
rename-link 跨越/截断不授权的路径一律被内核拒绝
- ro 路径通常即工作区挂载点不加任何规则 写类操作全拒
- /tmp/var/tmp/dev/shm 显式授予写类权限 与纯 DAC 只读的历史行为对齐
只读身份 HOME=/tmp常见临时写入不受影响其余路径写权限收紧这正是
要修复的 world-writable777/o+w DAC 漏洞本身
- /执行不进入 handled 集合完全交给 DAC600 权限敏感文件仍不可读
- 域随 fork/exec 继承且不可自行解除配合 no_new_privs子进程同受限
不能采用/ 授全量 + ro 路径授空的交集写法landlock_add_rule
allowed_access=0 的规则返回 ENOMSGerrno 42内核拒绝空授权规则
仅依赖 python3 标准库ctypes 直调 syscallx86_64/aarch64 通用
"""
import ctypes
import os
import sys
libc = ctypes.CDLL(None, use_errno=True)
PR_SET_NO_NEW_PRIVS = 38
# x86_64 与 aarch64 编号一致
SYS_CREATE_RULESET = 444
SYS_ADD_RULE = 445
SYS_RESTRICT_SELF = 446
LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
# access_fs 位ABI V1 全集 + V2/V3 增量)
FS_EXECUTE = 1 << 0
FS_WRITE_FILE = 1 << 1
FS_READ_FILE = 1 << 2
FS_READ_DIR = 1 << 3
FS_REMOVE_DIR = 1 << 4
FS_REMOVE_FILE = 1 << 5
FS_MAKE_CHAR = 1 << 6
FS_MAKE_DIR = 1 << 7
FS_MAKE_REG = 1 << 8
FS_MAKE_SOCK = 1 << 9
FS_MAKE_FIFO = 1 << 10
FS_MAKE_BLOCK = 1 << 11
FS_MAKE_SYM = 1 << 12
FS_REFER = 1 << 13 # ABI V2跨目录 rename/link
FS_TRUNCATE = 1 << 14 # ABI V3truncate(2)
# 只读域要管的写类操作(读/执行刻意排除,留给 DAC 决定)
WRITE_OPS_V1 = (FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE |
FS_MAKE_CHAR | FS_MAKE_DIR | FS_MAKE_REG | FS_MAKE_SOCK |
FS_MAKE_FIFO | FS_MAKE_BLOCK | FS_MAKE_SYM)
class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
("_pad", ctypes.c_int32)]
def probe_abi() -> int:
"""返回内核 Landlock ABI 版本(>=1不支持/被拦返回 <1。"""
ctypes.set_errno(0)
ret = libc.syscall(SYS_CREATE_RULESET, None, 0,
LANDLOCK_CREATE_RULESET_VERSION, 0, 0, 0)
return ret
def handled_for_abi(abi: int) -> int:
handled = WRITE_OPS_V1
if abi >= 2:
handled |= FS_REFER
if abi >= 3:
handled |= FS_TRUNCATE
return handled
def install_readonly_domain(ro_paths, rw_paths=("/tmp", "/var/tmp", "/dev/shm")):
"""安装只读域。成功返回 None失败返回错误描述字符串。
ro_paths写类操作全拒的路径不加规则无覆盖即拒绝生效
rw_paths显式授予写类权限的路径对齐纯 DAC 只读下的常用可写区
"""
abi = probe_abi()
if abi < 1:
return f"kernel-unsupported(errno={ctypes.get_errno()})"
handled = handled_for_abi(abi)
attr = RulesetAttr(handled)
ctypes.set_errno(0)
ruleset_fd = libc.syscall(SYS_CREATE_RULESET, ctypes.byref(attr),
ctypes.sizeof(attr), 0, 0, 0)
if ruleset_fd < 0:
e = ctypes.get_errno()
return f"create_ruleset(errno={e}:{os.strerror(e)})"
# 只给存在的 rw 路径加授权规则ro 路径刻意不加规则(无覆盖 → 拒绝)
for path in rw_paths:
if not os.path.exists(path):
continue
try:
fd = os.open(path, os.O_PATH)
except OSError as e:
return f"open({path})(errno={e.errno}:{e.strerror})"
pba = PathBeneathAttr(handled, fd, 0)
ctypes.set_errno(0)
ret = libc.syscall(SYS_ADD_RULE, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
ctypes.byref(pba), 0, 0, 0)
os.close(fd)
if ret < 0:
e = ctypes.get_errno()
return f"add_rule({path})(errno={e}:{os.strerror(e)})"
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0:
return "prctl(PR_SET_NO_NEW_PRIVS) failed"
ctypes.set_errno(0)
ret = libc.syscall(SYS_RESTRICT_SELF, ruleset_fd, 0, 0, 0, 0, 0)
if ret < 0:
e = ctypes.get_errno()
return f"restrict_self(errno={e}:{os.strerror(e)})"
return None
def selftest(ro_paths) -> int:
"""自检:安装域后本进程与子进程写 ro 路径都必须被拒。"""
err = install_readonly_domain(ro_paths)
if err:
print(f"SELFTEST_FAIL install: {err}", file=sys.stderr)
return 2
probe = os.path.join(ro_paths[0], ".landlock_selftest_probe")
try:
with open(probe, "w") as f:
f.write("x")
print("SELFTEST_FAIL write-not-denied", file=sys.stderr)
return 3 # 残留文件由部署方以容器 root 清理
except OSError as e:
if e.errno != 13: # EACCES
print(f"SELFTEST_FAIL unexpected errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 4
import subprocess
code = f"open({probe!r},'w').write('x')"
r = subprocess.run([sys.executable, "-c", code], capture_output=True)
if r.returncode == 0:
print("SELFTEST_FAIL child-not-denied", file=sys.stderr)
return 5
# 白名单区(/tmp必须仍可写否则说明规则误配会造成行为回归
try:
tmp_probe = f"/tmp/.landlock_selftest_rw.{os.getpid()}"
with open(tmp_probe, "w") as f:
f.write("x")
os.remove(tmp_probe)
except OSError as e:
print(f"SELFTEST_FAIL tmp-not-writable: errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 6
print("LANDLOCK_SELFTEST_OK")
return 0
def main() -> None:
args = sys.argv[1:]
ro_paths = []
selftest_mode = False
cmd = []
i = 0
while i < len(args):
a = args[i]
if a == "--ro" and i + 1 < len(args):
ro_paths.append(os.path.normpath(args[i + 1]))
i += 2
elif a == "--selftest":
selftest_mode = True
i += 1
elif a == "--":
cmd = args[i + 1:]
break
else:
i += 1
if not ro_paths:
print("launcher: missing --ro <path>", file=sys.stderr)
sys.exit(2)
if selftest_mode:
sys.exit(selftest(ro_paths))
if not cmd:
print("launcher: missing command after --", file=sys.stderr)
sys.exit(2)
err = install_readonly_domain(ro_paths)
if err:
print(f"launcher: install readonly domain failed: {err}", file=sys.stderr)
sys.exit(2)
os.execvp(cmd[0], cmd)
if __name__ == "__main__":
main()

View File

@ -62,7 +62,7 @@ except ImportError:
TERMINAL_SANDBOX_REQUIRE, TERMINAL_SANDBOX_REQUIRE,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
@ -268,7 +268,8 @@ class StartMixin:
"exec", "exec",
"-i", "-i",
] ]
if self.sandbox_options.get("docker_readonly_exec"): readonly_exec = bool(self.sandbox_options.get("docker_readonly_exec"))
if readonly_exec:
# 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制) # 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制)
cmd += docker_readonly_exec_args() cmd += docker_readonly_exec_args()
if container_workdir: if container_workdir:
@ -284,10 +285,15 @@ class StartMixin:
for key, value in envs.items(): for key, value in envs.items():
cmd += ["-e", f"{key}={value}"] cmd += ["-e", f"{key}={value}"]
cmd.append(container_name) inner_cmd = [shell_path]
cmd.append(shell_path)
if shell_path.endswith("sh"): if shell_path.endswith("sh"):
cmd.append("-i") inner_cmd.append("-i")
if readonly_exec:
# Landlock 加固:可用时 shell 及其子进程全程处于工作区只读域;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_path)
cmd.append(container_name)
cmd.extend(inner_cmd)
env = os.environ.copy() env = os.environ.copy()
process = subprocess.Popen( process = subprocess.Popen(

View File

@ -40,7 +40,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
if TYPE_CHECKING: if TYPE_CHECKING:
@ -187,9 +187,15 @@ class RunMixin:
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"] exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access: if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args() exec_cmd += docker_readonly_exec_args()
# Landlock 加固可用时再以进程级只读域封住工作区写777/o+w 绕 DAC 的洞);
# 部署/自检有阻塞 docker 调用,放线程池;失败自动降级纯 DAC。
inner_cmd = await asyncio.to_thread(
docker_readonly_wrap_inner, container_name, mount_path, inner_cmd, docker_bin
)
exec_cmd += [ exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -198,9 +204,7 @@ class RunMixin:
"-w", "-w",
container_workdir, container_workdir,
container_name, container_name,
"/bin/bash", *inner_cmd,
"-lc",
command,
] ]
use_shell = False use_shell = False