Multi-tenant isolation for Docker tasks (per-project mounts + networks + drop caps)

Container-side isolation was project-blind: every task mounted
the shared /app/work (all worktrees + host-state), the shared
codex-home (all thread history), the shared repos dir and the
shared test-repo, and all containers shared the default bridge
network. With --tenant-isolation (Docker only; Local runs on
the host under one user and has no container boundary to
enforce), each project gets its own host dirs under
tenants_root/<project>/{work,codex-home,test-repo,<project>}:
- mounts: container paths stay identical (/app/work,
  codex-home, repos/<pid>, test-repo) so host.py config needs
  no change; only the host mount sources are per-project.
  Static --docker-extra-mounts are replaced per task (not
  appended), so a shared mount cannot leak back in.
- codex-home seeds ONLY the template's config.toml (relay
  endpoint/provider names, no secret values); thread_history
  is never copied (it holds other tasks' prompts/results).
  test-repo is a per-project copy of the fixture (worktree
  creation needs a writable repo; a shared writable fixture
  would let one tenant poison the next task's base code).
- repos: repo_url checkouts go to the tenant dir and task
  repo is set to the container path; no-repo_url tasks get
  the tenant test-repo copy instead of the shared fixture.
- network: one Docker network per project (codex-<pid>,
  created idempotently, already-exists is OK); network
  creation failure fails the task instead of silently
  falling back to the default network (a silent downgrade
  would make isolation fail quietly).
- hardening for all Docker runs: --cap-drop=ALL and
  --security-opt=no-new-privileges (non-root user was already
  the case); verified live by a good-model task, not assumed.
Honest limit, kept in README: the inner codex sandbox is
still danger-full-access (host kernel blocks user namespaces,
bwrap unusable), so the boundary is container + mounts +
network, not the inner sandbox.
Tests: 7 tenant tests (seeding incl. no-history, per-project
mounts, network sanitization, per-call override, ensure
network ok/fail). Full suites pass.
This commit is contained in:
host committed 2026-10-26 07:24:54 +08:00
1 parent b74deffb26
commit 03a98124db
5 files changed
+457 -26

No files matched your search

+67 -14
View File
@@ -369,13 +369,24 @@ class DockerExecutor(Executor):
"Missing environment variable: %s" % self.env_key)
# -- 命令构造 ------------------------------------------------------
def build_run_command(self, task, inner_cmd):
def build_run_command(self, task, inner_cmd, extra_mounts=None,
network=None):
"""构造完整的 docker run 命令(纯函数,可单测断言)。
顺序固定:docker run --rm --name <容器名> --user <用户>
--cpus <核数> --memory <内存> -v <worktree>:/work
-v <代码目录>:/app:ro -w /work [-e <env_key名>]
<镜像> <容器内命令...>。-e 只传名字,不带 =值。
--cpus <核数> --memory <内存> [--cap-drop=ALL
--security-opt=no-new-privileges] [--network <网络>]
-v <worktree>:/work -v <代码目录>:/app:ro -w /work
[-e <env_key名>] <镜像> <容器内命令...>。-e 只传名字,
不带 =值。
extra_mounts 给定时替换构造时的静态挂载(多租户隔离
用:worker 按项目算出挂载后每次调用传入);不给时用
静态挂载。network 给定时容器进该 Docker 网络(多租户
用项目独立网络,容器之间默认 bridge 上互相可见的问题
由此解决);不给时用 docker 默认网络。
降权:容器默认已是非 root 用户,再加 --cap-drop=ALL
与 no-new-privileges(提权类工具调用会被挡下,codex
的常规文件/git 操作不受影响,见多租户实测)。
"""
self._check_docker()
self._check_key()
@@ -384,13 +395,19 @@ class DockerExecutor(Executor):
"--user", self.user,
"--cpus", self.cpus,
"--memory", self.memory,
"-v", "%s:/work" % self._worktree_for(task),
"-v", "%s:/app:ro" % self._app_dir(),
"-w", "/work"]
"--cap-drop=ALL",
"--security-opt=no-new-privileges"]
if network:
cmd += ["--network", network]
cmd += ["-v", "%s:/work" % self._worktree_for(task),
"-v", "%s:/app:ro" % self._app_dir(),
"-w", "/work"]
# 额外挂载放在基础挂载之后:/app/work 这类子路径挂载会
# 覆盖 /app:ro 的对应子路径,让容器内 host.py 能写
# work/ 和 host-state.json,同时代码主体仍只读。
for host_p, cont_p, mode in self.extra_mounts:
mounts = self.extra_mounts if extra_mounts is None \
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)]
if self.env_key:
cmd += ["-e", self.env_key]
@@ -500,18 +517,22 @@ class DockerExecutor(Executor):
continue
return ok
def _run_in_container(self, task, inner_cmd, timeout=None):
def _run_in_container(self, task, inner_cmd, timeout=None,
extra_mounts=None, network=None):
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
说明,returncode 为 None);其它 runner 异常也如实返回
failed,不编造成功。timeout 只影响本次调用,不改实例。
extra_mounts/network 只影响本次调用(多租户按项目传入)。
"""
task_id = task.get("id") if isinstance(task, dict) else None
if task_id is None:
raise ValueError("任务缺少 id")
cmd = self.build_run_command(task, inner_cmd)
cmd = self.build_run_command(task, inner_cmd,
extra_mounts=extra_mounts,
network=network)
try:
result = self._call_runner(cmd, timeout=timeout)
except Exception as exc:
@@ -536,7 +557,8 @@ class DockerExecutor(Executor):
parsed["container"] = self.container_name(task)
return parsed
def dispatch_task(self, task, timeout=None) -> dict:
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -548,9 +570,12 @@ class DockerExecutor(Executor):
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
return self._run_in_container(task, inner, timeout=timeout)
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network)
def run_task(self, task, timeout=None) -> dict:
def run_task(self, task, timeout=None, extra_mounts=None,
network=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -559,7 +584,35 @@ class DockerExecutor(Executor):
inner = self._inner_host_command(
"run", "--task", self._host_name(task),
"--prompt", task["prompt"], model=task.get("model"))
return self._run_in_container(task, inner, timeout=timeout)
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network)
def ensure_network(self, name):
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
返回 True 表示网络可用(创建成功或本来就存在),
False 表示创建失败(调用方继续派发,docker run 时会
因网络不存在而明确失败,不在这里静默降级默认网络——
降级会让隔离悄悄失效,比失败更糟,所以这里只返回
布尔值,是否继续由调用方按失败处理:worker 在租户
模式下网络创建失败时直接让任务 failed,不跑容器)。
"""
if not name:
return False
try:
result = self._call_runner(["docker", "network", "create", name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
return "already exists" in text
except Exception:
return False
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。