A Self-Hosted Code Review Bot Invented a CVE That Was Never Issued

A self-hosted GitLab reviewer rejected a PostgreSQL version bump with a fabricated CVE, invented cluster resources and a remediation script, and a registry lookup catches that class of repor

Netics editorial card on the fabricated code review CVE, showing a read-only review agent, an invented identifier and a registry lookup.
Netics editorial card on the Docker code review bot story, covering the read-only rule, the fabricated CVE identifier, the sandbox mount and the NVD lookup on the runner.

TL;DR

  • A code review bot is a program that reads a proposed code change and writes a review comment into a pipeline before a person looks at the change.
  • On 29 October 2026 Docker published the sixth story in its AI coding agent incident series, about a self-hosted reviewer called BuaJi that rejected a routine PostgreSQL version bump with roughly 1,500 words of security findings.
  • The findings named a third-party company that does not exist, Kubernetes resources that never existed, and a CVE identifier that no registry has ever issued.
  • BuaJi ran local models through Ollama inside a GitLab job and was limited by a read-only rule: read the code, write a comment, nothing else.
  • A CVE identifier is a public registry entry, so one lookup against the NIST National Vulnerability Database settles whether the ID exists.
  • Docker's remediation moves the read-only rule into a Docker Sandbox microVM: a read-only mount, no credentials and one open network path to the model server.
  • The verification that catches this class of report belongs on the runner, where a registry lookup turns an unverifiable identifier into a failed job.
  • Agent output arrives at the next tool as an input, and something downstream trusts whatever it receives.

A version number moved from 16.11 to 16.13 in a bundled PostgreSQL build, which is the kind of change that closes public vulnerabilities and asks nothing of a reviewer. The bot answered with a security report describing an exfiltration campaign, listing cluster objects it claimed to have inspected, and proposing a script to isolate the affected nodes. Every specific in that report was invented, and the engineer reading it needed about two minutes inside the CI logs to see it.

Docker post header on the invented CVE review, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist
Capture of the Docker post, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist, taken 30 October 2026.

The part worth studying is the shape of the failure. A model produced confident wrong text, which every team running one learns to expect. Here a governed, self-hosted, deliberately read-only reviewer produced findings in the exact format a security team acts on, and nothing in the pipeline could separate a real CVE string from an invented one. That gap is mechanical, and it closes with a lookup.

BuaJi runs a local model inside a GitLab pipeline and writes review comments

The engineer built the bot for his own team: every push, the bot reads the diff and posts a first-pass review, the kind a colleague gives before a human maintainer opens the change. He named it BuaJi, after the aunt in many Indian families who inspects everything and always finds something wrong, which is a fair description of a code reviewer.

The design was careful. Model weights sat on hardware the company owned, requests went to a local Ollama server, and the job ran inside the team's own CI pipeline, so company source code stayed inside the building. BuaJi also carried a firm rule: it could read and comment, and it could not run terraform apply, could not run kubectl apply, and could not push a commit.

Docker is candid about the weak point in that setup. The model, the script, the repository checkout and every CI variable, including CI_JOB_TOKEN, shared one container. BuaJi held read-only status because the script happened to write nothing, which makes the restriction a property of the code as written on a given afternoon.

Docker diagram of a read-only agent's reach, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist
Docker's own illustration from the post, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist: a reviewer that cannot act still hands instructions to someone who can.

A PostgreSQL bump from 16.11 to 16.13 came back as an architectural takeover

The change under review was two lines of configuration moving POSTGRES_VERSION from 16.11 to 16.13, the routine patch upgrade that retires already-published vulnerabilities. Docker's own description of the diff is that it changes a version number and its only purpose is to close public holes.

BuaJi rejected it in about 1,500 words. The report called the bump an "architectural takeover", a structural change smuggled in under cover of maintenance. It said the new PostgreSQL build shipped internal data to a third-party company, and it named the company. It referred to specific Kubernetes resources as though it had inspected the cluster, wrote Go scripts to "isolate the infected PostgreSQL nodes", and told the engineer to revoke all IAM access immediately.

Docker's own framing of that last instruction lands hard: revoking all IAM access would lock out every person in the organisation, including the people who would be needed to fix a real problem. The report was well formatted, its YAML would have parsed, and its tone carried the urgency of a senior engineer who has found something serious.

Netics comparison card of a fabricated review report against the verified facts
Netics editorial visual: the claims in the report next to what the logs actually contained.

A CVE identifier is a registry entry anyone can check in seconds

A CVE identifier is an entry in a public catalogue of disclosed vulnerabilities, listed in the NIST National Vulnerability Database, and each record carries an ID of the form CVE-year-number. An ID with no record behind it is only text.

The bot wove a fabricated ID into a report whose other claims were equally invented, and Docker treats that detail as the one to pause on. The reasoning holds: an identifier is among the easiest claims in security to verify, since a registry query answers it in a few seconds, so an invented ID shows where a fabricated report is weakest.

The read-only rule bounded what the bot could do and left its findings unverified

Docker states plainly that the read-only rule did its job. BuaJi could not execute any of its own recommendations and never had cluster access, so the episode cost the engineer two minutes of an evening.

The rule governed actions. Nobody had specified what the bot could assert. A reviewer restricted to comments still hands recommendations to a reader, and the reader usually holds exactly the access the agent was denied. Here the engineer knew his own cluster, so the invented resources were obvious to him, and Docker names the scenario that worries us more: a newer engineer on call at 02:00, half awake, reading a long and alarming report that ends with a command sequence.

Picture a hypothetical case: a four-person logistics company runs one pipeline, and the reviewer bot comments on every merge request. The senior developer reads the bot's notes; the apprentice merges on them. The bot invents a compromised dependency and pastes a removal command that also drops a database role, and the only defence in that pipeline is how much the reader happens to know at 18:00 on a Friday. Docker's point generalises: an agent's output becomes an input, and every downstream reader decides how much authority it carries.

Docker comic panel on a fabricated report, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist
Docker's illustration from the same post, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist: a report whose formatting and confidence carry it past a tired reader.

Docker's fix moves the rule into a microVM with a read-only mount and one open port

The remediation keeps the model where the GPU is and moves the reviewer into a Docker Sandbox, where each agent gets its own kernel, its own filesystem and a network that denies everything by default. The steps are worth reading as a template, because each one converts a remembered convention into an environment property.

The script reads the model address from OLLAMA_URL with the old localhost value as default, so it behaves the same outside a sandbox. A small image carries python3, python3-requests and git, so the job downloads nothing at run time. Ollama stays on the runner with the GPU. The sandbox is created with sbx run -d and the repository mounted :ro, with an empty scratch directory as the only writable path, and no GitLab token, kubeconfig or cloud credentials passed in.

One network rule opens the model port with sbx policy allow network --sandbox "$SBX_NAME" localhost:11434, and the run itself passes exactly two environment variables. Cleanup sits in after_script, so the sandbox is logged and removed even when the job fails.

Docker is honest about the limit of that work: a sandbox checks no facts, and no microVM boundary makes a review comment accurate. The boundary changes what a fabricated report can do once it exists, and Docker says directly that this protection does nothing for the human who reads the output.

Docker card on the sandbox's one open path, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist
Docker's own diagram from the post, docker.com/blog/coding-agent-horror-stories-the-cve-that-did-not-exist: the model port as the only open path, and the registry check on the way out.

A registry lookup on the runner fails the job on an unverifiable identifier

The step that addresses the fabricated content is a shell loop on the runner. It greps the review log for CVE- identifiers, queries https://services.nvd.nist.gov/rest/json/cves/2.0 for each one, reads totalResults with jq, and collects any ID that returns zero. If the list is non-empty, the job prints that the output mentions IDs that do not exist, tells the reader to treat the review as unverified, and exits non-zero.

Because the job carries allow_failure: true, GitLab records it as passed with warnings and the pipeline continues. Docker calls that a nudge, and is explicit that a tired engineer can still scroll past the warning. On the fabricated report above, the loop would have failed the job in seconds and attached a visible warning to the exact artefact.

Two properties make this the right place for the check. It runs outside the agent, so the agent cannot skip or rewrite it, and it depends on a public registry. A lookup covers identifiers; the invented Kubernetes resource names need a different check, which is why reading the report still matters.

Netics checklist of the verification steps that stop a fabricated review
Netics editorial visual: the five steps that turn agent review output into something a pipeline verifies.

Agent review output belongs in the untrusted column

Docker places this incident in a line of others where an agent reached somewhere it should not have. Their reading of the pattern is that the boundary held here and the trust boundary was the thing that failed: a file written in one place was accepted in another.

For a small team running agents on written rules, the practical translation is a short list. Label bot comments as bot output so they stop borrowing a colleague's authority. Run the registry check on every review, in the pipeline. Read any command an agent proposes before running it, with the attention you would give an unfamiliar script from the internet. Treat a claimed cluster inspection as a claim, and let the policy log answer it.

The permission model and the verification step cover different halves of one problem. The model address, the read-only mount and the single open port decide what a reviewer can do; the NVD lookup decides whether anyone should believe what it says. Teams already running an AI reviewer can add the second half before the next merge request with the check Docker published. For teams building the first half, the sandbox route in the same post is a reasonable starting architecture. Hosting it on hardware the team owns is a sovereign infrastructure and cloud exit question.

Sources

Source: the Docker blog post of 2026-10-29, read in full on 2026-10-30. The bot's name and its read-only rule, the shared container with CI_JOB_TOKEN, the PostgreSQL 16.11 to 16.13 bump, the roughly 1,500-word rejection, the non-existent third-party company, the named Kubernetes resources, the Go isolation scripts, the IAM revocation instruction, the sandbox steps with OLLAMA_URL and sbx policy allow network, the NVD lookup loop, and the allow_failure behaviour all come from that post. The CVE identifier definition and the pipeline advice are Netics analysis.