diff --git a/cloud/sandbox.py b/cloud/sandbox.py index a2fab57..9456251 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -896,7 +896,8 @@ class DockerExecutor(Executor): user="1000:1000", worktrees_root=None, env=None, env_key="XCM_API_KEY", timeout=None, extra_mounts=None, model=None, security_opts=None, pids_limit=None, - runtime=None, extra_env=None, ignore_host_budget=False): + runtime=None, extra_env=None, ignore_host_budget=False, + readonly_root=False): if host_py_path is None: here = os.path.dirname(os.path.abspath(__file__)) host_py_path = os.path.join(os.path.dirname(here), "host.py") @@ -935,6 +936,11 @@ class DockerExecutor(Executor): if not self.runtime: self.runtime = None self.extra_env = dict(extra_env or {}) + # 严格模式:容器根文件系统只读(/work 挂载仍可写、/tmp 与 + # /var/tmp 为 tmpfs,见 build_run_command)。默认关:codex + # 可能往 HOME 等容器内路径写,默认开会改现有任务行为; + # 开启是显式的 worker 旗标 --docker-readonly-root。 + self.readonly_root = bool(readonly_root) @staticmethod def _parse_extra_mounts(extra_mounts): @@ -966,6 +972,9 @@ class DockerExecutor(Executor): raise ValueError("extra mount 拒绝危险宿主路径: %s" % spec) if cont_p in ("/", "/app"): raise ValueError("extra mount 拒绝覆盖容器根或 /app 本身: %s" % spec) + if cont_p in ("/tmp", "/var/tmp"): + raise ValueError( + "extra mount 拒绝覆盖 /tmp(容器 /tmp 固定为 tmpfs 隔离): %s" % spec) if not os.path.exists(host_p): raise ValueError("extra mount 宿主路径不存在,拒绝挂载: %s" % spec) out.append((host_p, cont_p, mode)) @@ -1052,6 +1061,8 @@ class DockerExecutor(Executor): "--memory", self.memory, "--cap-drop=ALL", "--security-opt=no-new-privileges"] + if self.readonly_root: + cmd += ["--read-only"] for opt in self.security_opts: if opt != "no-new-privileges": cmd += ["--security-opt", opt] @@ -1071,6 +1082,13 @@ class DockerExecutor(Executor): else self._parse_extra_mounts(extra_mounts) for host_p, cont_p, mode in mounts: cmd += ["-v", "%s:%s:%s" % (host_p, cont_p, mode)] + # /tmp 固定为 tmpfs:容器内 /tmp 绝不落在宿主 /tmp,也不受 + # 额外挂载影响(/tmp 目标的额外挂载已在解析时拒绝)。 + # 诚实边界:这是 Docker 外层能做的;内层 codex 的 bwrap + # workspace-write 仍由 codex 二进制控制,本仓库改不了它 + # 的 --proc 挂载,宿主级 bwrap 的 /tmp 不隔离问题也不在 + # 本仓库的修复范围内——不要把本改动说成“内层沙箱已修”。 + cmd += ["--tmpfs", "/tmp", "--tmpfs", "/var/tmp"] if self.env_key: cmd += ["-e", self.env_key] merged_env = dict(self.extra_env) diff --git a/cloud/tests/test_docker_sandbox.py b/cloud/tests/test_docker_sandbox.py index 97a3ae7..278633e 100644 --- a/cloud/tests/test_docker_sandbox.py +++ b/cloud/tests/test_docker_sandbox.py @@ -362,6 +362,28 @@ class TestDockerExtraMounts(DockerCommandTestCase): self.assertIn(f"{work}:/app/work:rw", mounts) self.assertLess(mounts.index("/fake:/app:ro"), mounts.index(f"{work}:/app/work:rw")) + def test_tmpfs_tmp_and_readonly_root_flag(self): + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + ex = self._executor([]) + cmd = ex.build_run_command( + {"id": "t1", "worktree": "/tmp/wt"}, ["echo", "hi"]) + self.assertIn("--tmpfs", cmd) + self.assertIn("/tmp", cmd) + self.assertIn("/var/tmp", cmd) + self.assertNotIn("--read-only", cmd) + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + ex = DockerExecutor("/fake/host.py", runner=_ok_runner_factory([]), + env={"XCM_API_KEY": "placeholder-not-a-real-key"}, + readonly_root=True) + cmd = ex.build_run_command( + {"id": "t1", "worktree": "/tmp/wt"}, ["echo", "hi"]) + self.assertIn("--read-only", cmd) + with self.assertRaises(ValueError): + DockerExecutor("/fake/host.py", + extra_mounts=["/tmp/host-tmp:/tmp"]) + def test_extra_mounts_reject_dangerous_paths(self): with self.assertRaises(ValueError): DockerExecutor("/fake/host.py", extra_mounts=["/: /x"]) diff --git a/cloud/worker.py b/cloud/worker.py index 562e4e4..016f086 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -5843,7 +5843,8 @@ def _build_cli_executor(args, host_py): extra_mounts=args.docker_extra_mounts, security_opts=args.docker_security_opt, pids_limit=args.docker_pids_limit, - runtime=getattr(args, "docker_runtime", None)) + runtime=getattr(args, "docker_runtime", None), + readonly_root=getattr(args, "docker_readonly_root", False)) def main(argv=None): @@ -5951,6 +5952,11 @@ def main(argv=None): help="docker --runtime(不传 = docker 默认 runc;" "传 runsc = 任务容器走 gVisor 沙箱,需 daemon" "已注册该 runtime,否则起容器失败)") + parser.add_argument("--docker-readonly-root", action="store_true", + help="docker --read-only:容器根只读(/work 挂载" + "仍可写、/tmp 与 /var/tmp 为 tmpfs)。默认关:" + "codex 可能往容器内 HOME 等路径写,开之前" + "需先在生产任务上实测,勿直接全量开") parser.add_argument("--egress-proxy-port", type=int, default=0, help="egress 过滤代理端口(0 = 不启用;生产配" " 8888):启用后 Docker 任务容器的 HTTP(S)"