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:
1 parent
4f7c7eb13e
commit
1e4623c61c
8 files changed
+399
-18
No files matched your search
+65
-12
@@ -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 网络存在:不存在就创建,已存在就忽略。
|
||||
|
||||
Reference in new issue
Block a user