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

+55 -5
View File
@@ -235,7 +235,8 @@ class Worker:
repos_runner=None, heartbeat_file=None,
worker_id=None, capacity=1, fallback_models=None,
tenant_isolation=False, tenants_root=None,
tenant_template_home=None, tenant_template_repo=None):
tenant_template_home=None, tenant_template_repo=None,
egress_proxy_port=0):
self.db_path = str(db_path)
self.host_py_path = host_py_path
self.config_path = config_path
@@ -316,6 +317,11 @@ class Worker:
"/var/lib/codex-app-server/codex-home"
self.tenant_template_repo = tenant_template_repo or \
"/var/lib/codex-app-server/test-repo"
# egress 代理端口(0 = 不启用):启用后 Docker 任务容器
# 的 HTTP(S) 出口全部走宿主上的过滤代理(网关 IP + 该
# 端口),代理只放行白名单域名;网关查不到时任务直接
# failed,绝不无代理跑(无代理等于绕过白名单)。
self.egress_proxy_port = egress_proxy_port or 0
# 运维心跳:路径优先显式参数,其次环境变量,默认当前目录下
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
@@ -1101,6 +1107,7 @@ class Worker:
if is_docker:
tenant_mounts = None
tenant_network = None
proxy_env = None
if self.tenant_isolation:
try:
tenant_mounts = self._tenant_mounts(
@@ -1131,10 +1138,34 @@ class Worker:
"stderr": ""},
"租户 Docker 网络创建失败: %s"
% tenant_network)
if self.egress_proxy_port:
# 代理在宿主上,容器经所在网络的网关访问;
# 租户模式用项目网络,非租户用默认 bridge。
gw = self.executor.network_gateway(tenant_network)
if not gw:
self._running_clear(task_id)
return ({"executor": "docker",
"task_id": task_id,
"status": "failed", "stdout": "",
"returncode": None, "host_status": None,
"stderr": ""},
"egress 代理网关解析失败,拒绝无代理"
"运行(避免绕过出口白名单)")
proxy_url = "http://%s:%s" % (
gw, self.egress_proxy_port)
proxy_env = {
"HTTP_PROXY": proxy_url,
"HTTPS_PROXY": proxy_url,
"http_proxy": proxy_url,
"https_proxy": proxy_url,
"NO_PROXY": "localhost,127.0.0.1",
"no_proxy": "localhost,127.0.0.1",
}
result = self.executor.dispatch_task(
task, timeout=effective_docker_timeout,
extra_mounts=tenant_mounts,
network=tenant_network)
network=tenant_network,
extra_env=proxy_env)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -1147,7 +1178,9 @@ class Worker:
extra_mounts=tenant_mounts
if self.tenant_isolation else None,
network=tenant_network
if self.tenant_isolation else None)
if self.tenant_isolation else None,
extra_env=proxy_env
if self.egress_proxy_port else None)
else:
result = self.executor.run_task(task)
except Exception as exc: # runner 异常也如实写回 failed
@@ -1737,7 +1770,9 @@ def _build_cli_executor(args, host_py):
user=args.docker_user, worktrees_root=args.docker_worktrees_root,
env_key=resolve_env_key(args.env_key, args.codex_config),
timeout=args.docker_timeout,
extra_mounts=args.docker_extra_mounts)
extra_mounts=args.docker_extra_mounts,
security_opts=args.docker_security_opt,
pids_limit=args.docker_pids_limit)
def main(argv=None):
@@ -1814,6 +1849,20 @@ def main(argv=None):
"用于容器内 host.py 写 work/、host-state.json,"
"以及挂载原仓库和 codex-home(同路径挂载,"
"容器内配置无需改路径)")
parser.add_argument("--docker-security-opt", action="append",
default=None,
help="docker --security-opt,可重复(如"
" apparmor=docker-codex;no-new-privileges"
" 已硬编码,重复传也不重复)")
parser.add_argument("--docker-pids-limit", type=int, default=512,
help="docker --pids-limit(默认 512,防 fork 炸弹;"
"codex 工具链正常用量远低于此)")
parser.add_argument("--egress-proxy-port", type=int, default=0,
help="egress 过滤代理端口(0 = 不启用;生产配"
" 8888):启用后 Docker 任务容器的 HTTP(S)"
" 出口全部走宿主该端口的代理,代理只放行"
"白名单域名;网关查不到时任务直接 failed,"
"绝不无代理运行")
parser.add_argument("--task-timeout", type=float, default=1800,
help="每任务截止时间(秒,默认 1800);超时后真杀"
"容器/进程组、任务置 failed;与 --docker-timeout"
@@ -1862,7 +1911,8 @@ def main(argv=None):
tenant_isolation=args.tenant_isolation,
tenants_root=args.tenants_root,
tenant_template_home=args.tenant_template_home,
tenant_template_repo=args.tenant_template_repo)
tenant_template_repo=args.tenant_template_repo,
egress_proxy_port=args.egress_proxy_port)
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
worker.cleanup_stale_containers()
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。