Outer hardening: AppArmor profile, pids limit, egress allowlist proxy

Inner codex sandbox stays danger-full-access by choice (bwrap
path is a separate decision); this hardens everything around it:
- DockerExecutor: --security-opt plumbing (repeatable, dedups
  no-new-privileges), --pids-limit (CLI default 512, fork-bomb
  guard), per-call extra_env (-e NAME=VALUE, non-secrets only,
  e.g. proxy URLs; the model key still goes by -e NAME only).
- Worker: --egress-proxy-port (0 = off). When on, Docker task
  containers get HTTP(S)_PROXY pointing at the host filtering
  proxy via their network gateway IP (tenant network gateway
  or default bridge gateway). Gateway unresolvable => the
  attempt fails instead of running unproxied (fail closed,
  same rule as tenant network creation failure).
- AppArmor profile deploy/apparmor/docker-codex: docker-default
  base + deny network raw/packet, deny ptrace, deny mount/
  umount/pivot_root, deny dangerous capabilities, deny writes
  to /proc/sys, /sys, /proc/keys. Deliberately NO userns rule
  (that would enable the bwrap path, not this one). File rules
  mirror docker-default (broad + sensitive denies); a strict
  file allowlist would need profiling codex's file access set
  and is not claimed here.
- Egress proxy deploy/egress/: Tinyproxy with
  FilterDefaultDeny + anchored domain allowlist (relay,
  github, pypi, npm). Anchoring matters: an unanchored
  'github.com' regex would also match 'evilgithub.com'.
- Seccomp honesty (README): Docker default seccomp already
  enforces on all task containers; no new stricter allowlist
  is claimed in this change (building one from memory risks
  silently breaking the toolchain; it needs syscall profiling
  first).
Tests: 3 outer-hardening tests (security-opt dedup, pids
limit, proxy env without leaking the key value, gateway
parse/fail, proxy fail-closed). Full suites pass.
This commit is contained in:
host committed 2026-10-26 07:42:14 +08:00
1 parent 4f7c7eb13e
commit 1e4623c61c
8 files changed
+399 -18

No files matched your search

+65 -12
View File
@@ -264,7 +264,8 @@ class DockerExecutor(Executor):
image="codex-app-server:latest", cpus="2", memory="2g",
user="1000:1000", worktrees_root=None, env=None,
env_key="XCM_API_KEY", timeout=None, extra_mounts=None,
model=None):
model=None, security_opts=None, pids_limit=None,
extra_env=None):
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")
@@ -284,6 +285,16 @@ class DockerExecutor(Executor):
self.env_key = env_key
self.timeout = timeout
self.extra_mounts = self._parse_extra_mounts(extra_mounts)
# 外层加固:security_opts 是 --security-opt 的值列表
# (如 apparmor=docker-codex;no-new-privileges 已硬编码,
# 这里传了也不重复);pids_limit 防 fork 炸弹(codex
# 工具链正常用量远低于 512,见 worker CLI 默认值);
# extra_env 是 -e NAME=VALUE 形式的非密钥环境变量
# (如 egress 代理地址——绝不放密钥值,密钥只走 env_key
# 的 -e 名方式)。
self.security_opts = list(security_opts or [])
self.pids_limit = pids_limit
self.extra_env = dict(extra_env or {})
@staticmethod
def _parse_extra_mounts(extra_mounts):
@@ -370,15 +381,17 @@ class DockerExecutor(Executor):
# -- 命令构造 ------------------------------------------------------
def build_run_command(self, task, inner_cmd, extra_mounts=None,
network=None):
network=None, extra_env=None):
"""构造完整的 docker run 命令(纯函数,可单测断言)。
顺序固定:docker run --rm --name <容器名> --user <用户>
--cpus <核数> --memory <内存> [--cap-drop=ALL
--security-opt=no-new-privileges] [--network <网络>]
--security-opt=no-new-privileges] [--security-opt <自定义>]
[--pids-limit <n>] [--network <网络>]
-v <worktree>:/work -v <代码目录>:/app:ro -w /work
[-e <env_key名>] <镜像> <容器内命令...>。-e 只传名字,
不带 =值。
[-e <env_key名>] [-e <NAME=VALUE>...] <镜像> <容器内命令...>。
-e 只传名字,不带 =值(env_key);extra_env 的 -e 带值,
只用于非密钥变量(如 egress 代理地址)。
extra_mounts 给定时替换构造时的静态挂载(多租户隔离
用:worker 按项目算出挂载后每次调用传入);不给时用
静态挂载。network 给定时容器进该 Docker 网络(多租户
@@ -397,6 +410,11 @@ class DockerExecutor(Executor):
"--memory", self.memory,
"--cap-drop=ALL",
"--security-opt=no-new-privileges"]
for opt in self.security_opts:
if opt != "no-new-privileges":
cmd += ["--security-opt", opt]
if self.pids_limit is not None:
cmd += ["--pids-limit", str(self.pids_limit)]
if network:
cmd += ["--network", network]
cmd += ["-v", "%s:/work" % self._worktree_for(task),
@@ -411,6 +429,11 @@ class DockerExecutor(Executor):
cmd += ["-v", "%s:%s:%s" % (host_p, cont_p, mode)]
if self.env_key:
cmd += ["-e", self.env_key]
merged_env = dict(self.extra_env)
if extra_env:
merged_env.update(extra_env)
for name, value in sorted(merged_env.items()):
cmd += ["-e", "%s=%s" % (name, value)]
cmd += [self.image]
cmd += list(inner_cmd)
return cmd
@@ -518,21 +541,24 @@ class DockerExecutor(Executor):
return ok
def _run_in_container(self, task, inner_cmd, timeout=None,
extra_mounts=None, network=None):
extra_mounts=None, network=None,
extra_env=None):
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
说明,returncode 为 None);其它 runner 异常也如实返回
failed,不编造成功。timeout 只影响本次调用,不改实例。
extra_mounts/network 只影响本次调用(多租户按项目传入)。
extra_mounts/network/extra_env 只影响本次调用(多租户
按项目传入、egress 代理按任务网络传入)。
"""
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,
extra_mounts=extra_mounts,
network=network)
network=network,
extra_env=extra_env)
try:
result = self._call_runner(cmd, timeout=timeout)
except Exception as exc:
@@ -558,7 +584,7 @@ class DockerExecutor(Executor):
return parsed
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None) -> dict:
network=None, extra_env=None) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -572,10 +598,11 @@ class DockerExecutor(Executor):
inner += ["--repo", repo]
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network)
network=network,
extra_env=extra_env)
def run_task(self, task, timeout=None, extra_mounts=None,
network=None) -> dict:
network=None, extra_env=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -586,7 +613,33 @@ class DockerExecutor(Executor):
"--prompt", task["prompt"], model=task.get("model"))
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network)
network=network,
extra_env=extra_env)
def network_gateway(self, network_name):
"""某 Docker 网络的网关 IP(宿主在该网桥上的地址)。
egress 代理跑在宿主上时,容器只能通过网关 IP 访问它
(容器内的 127.0.0.1 是自己,不是宿主)。返回 None
表示查不到(docker 不可用、网络不存在等),调用方
必须按失败处理,绝不能无代理跑容器(那样等于绕过
出口白名单)。网络名为空时查默认 bridge 网络。
"""
name = network_name or "bridge"
try:
result = self._call_runner(
["docker", "network", "inspect", name, "--format",
"{{(index .IPAM.Config 0).Gateway}}"],
timeout=30)
except Exception:
return None
try:
if getattr(result, "returncode", 1) != 0:
return None
gw = (getattr(result, "stdout", "") or "").strip()
except Exception:
return None
return gw or None
def ensure_network(self, name):
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。