diff --git a/AGENTS.md b/AGENTS.md index 5160b8b8..63cbd27d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,10 +412,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: 只读权限的强制由各平台原生机制兜底;`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` 可覆盖 - - 强制力 = 内核 DAC:工作区属主为宿主机 root,非属主无写权;600 权限文件(如 .env)不可读;逃逸需提权(setuid/内核漏洞),无 umount 类捷径 - - 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock(云端已验证);macOS Docker Desktop(virtiofs fakeowner)不执行 uid 权限,仅 Linux 宿主生效 + - 第一层强制力 = 内核 DAC:工作区属主为宿主机 root,非属主无写权;600 权限文件(如 .env)不可读;逃逸需提权(setuid/内核漏洞),无 umount 类捷径 + - **第二层 = Landlock 只读域**(2026-09 新增,云端实测 kernel 6.8 / ABI V4 / Docker 28 默认 seccomp 放行):纯 DAC 的残留漏洞是工作区内历史遗留的 world-writable(777/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)自动降级纯 DAC,warning 日志标注 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()`) - Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益 - **macOS 宿主机 = Seatbelt 白名单读模型**(`modules/host_sandbox_runner.py`): diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 9cc6ffc4..1d39fdd6 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -19,7 +19,7 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, 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 @@ -225,9 +225,12 @@ class BackgroundCommandManager: if relative: container_workdir = f"{container_workdir}/{relative}" exec_cmd = [docker_bin, "exec"] + inner_cmd = ["/bin/bash", "-lc", command] if not sandbox_write_access: # 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py) exec_cmd += docker_readonly_exec_args() + # Landlock 加固:可用时再以进程级只读域封住工作区写;失败自动降级纯 DAC。 + inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_bin) exec_cmd += [ "-e", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", @@ -236,9 +239,7 @@ class BackgroundCommandManager: "-w", container_workdir, container_name, - "/bin/bash", - "-lc", - command, + *inner_cmd, ] use_shell = False diff --git a/modules/docker_readonly_exec.py b/modules/docker_readonly_exec.py index 6e3af2ad..2a79660c 100644 --- a/modules/docker_readonly_exec.py +++ b/modules/docker_readonly_exec.py @@ -19,11 +19,37 @@ 权限检查,本机制仅在 Linux 宿主机(云端/ Linux 桌面)生效,属预期差异; 持久终端在 approval/auto_approval 模式下同为只读身份,写入命令请走 run_command(审批通过后以 root 重跑)。 + +Landlock 加固(2026-09 起): + +- 纯 DAC 的残留漏洞:工作区内历史遗留的 world-writable(777/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 -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:";None 表示尚未探测 +_landlock_states: Dict[str, str] = {} +_landlock_states_lock = threading.Lock() 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_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) diff --git a/modules/landlock_launcher.py b/modules/landlock_launcher.py new file mode 100644 index 00000000..eb0b753c --- /dev/null +++ b/modules/landlock_launcher.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Astrion Landlock 只读域 launcher(容器内运行,非后端模块)。 + +由后端 modules/docker_readonly_exec.py 部署到容器内(docker cp)并调用: + + python3 launcher.py --ro /workspace -- [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-writable(777/o+w)绕 DAC 漏洞本身; +- 读/执行不进入 handled 集合,完全交给 DAC(600 权限敏感文件仍不可读); +- 域随 fork/exec 继承且不可自行解除(配合 no_new_privs),子进程同受限。 + +注:不能采用「/ 授全量 + ro 路径授空」的交集写法——landlock_add_rule 对 +allowed_access=0 的规则返回 ENOMSG(errno 42),内核拒绝空授权规则。 + +仅依赖 python3 标准库(ctypes 直调 syscall),x86_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 V3:truncate(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 ", 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() diff --git a/modules/persistent_terminal/start.py b/modules/persistent_terminal/start.py index 8b751fb6..335c3691 100644 --- a/modules/persistent_terminal/start.py +++ b/modules/persistent_terminal/start.py @@ -62,7 +62,7 @@ except ImportError: 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 @@ -268,7 +268,8 @@ class StartMixin: "exec", "-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 强制) cmd += docker_readonly_exec_args() if container_workdir: @@ -284,10 +285,15 @@ class StartMixin: for key, value in envs.items(): cmd += ["-e", f"{key}={value}"] - cmd.append(container_name) - cmd.append(shell_path) + inner_cmd = [shell_path] 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() process = subprocess.Popen( diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index e52ef9bb..783ce59d 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -40,7 +40,7 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, 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 if TYPE_CHECKING: @@ -187,9 +187,15 @@ class RunMixin: if relative: container_workdir = f"{container_workdir}/{relative}" exec_cmd = [docker_bin, "exec"] + inner_cmd = ["/bin/bash", "-lc", command] if not sandbox_write_access: # 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py) 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 += [ "-e", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", @@ -198,9 +204,7 @@ class RunMixin: "-w", container_workdir, container_name, - "/bin/bash", - "-lc", - command, + *inner_cmd, ] use_shell = False