Who it is for, and why
For teams that let GitLab Duo agents commit to their repositories. Repositories I work on were hit repeatedly this year by a credential stealer hidden as fake font files that an editor task runs on folder open, and as code appended after the last line of a build config. An agent reads issues, logs and files, and some of those are written by an attacker: it is one more way such a change can get in. Breakwater lets agents do real post-code work while keeping the rules that govern them out of their reach.
What it does
What the agents do (GitLab Duo flows, each its own service account, each with a small toolset):
- Builder (starts when a work item is created): reads the issue, posts a plan, branches, commits, opens a merge request. Treats issue text as untrusted (prompt).
- Fixer (starts on a failed pipeline a human started, or when a human mentions it): reads failing job logs and commits a fix under
app/; at most three attempts and never weakening a test (prompt; tests are not a protected path). - Warden (starts on a finished pipeline a human started, or on a mention): posts one evidence table (gate, scans, tests, fixer attempts, deploy) on the issue the merge request closes, or on the merge request itself.
- No flow is given a tool to dismiss a vulnerability, merge, update a merge request, or run shell (toolset).
What the pipeline enforces (CI, no model involved):
- Policy gate (
policy/gate.ts) in every merge request pipeline and again onmainbefore build and deploy. It fails when an agent-authored change adds, edits, deletes or renames a protected path (CI, flows, policy, CODEOWNERS, lockfiles, tsconfig,.gitattributes,.npmrc, editor settings, font files), or when anyone adds a known poisoning shape (code from strings, long encoded blobs, download-and-run, install hooks, process spawning in app code, code appended after a config file ends), or when an agent adds a scanner suppression. - "Fails" means the pipeline goes red. This project's role cannot make a green pipeline a merge requirement, so the human must not merge a red gate; if one is merged anyway, the same gate on
mainstops build, container image and Pages. - Main guard: fails a
mainpipeline when a commit reachedmainother than as a merge commit, before anything is built or deployed.
Supervised, concretely: a human opens the issue, reviews the result and presses merge. On an agent's merge request the human also mentions the fixer or warden when wanted, because GitLab does not let a service account's pipeline start flows. Everything else runs without a human.
How it uses GitLab (technical implementation)
- Duo Agent Platform: three custom flows (flow registry v1 YAML in
.gitlab/duo/flows/), published in the AI Catalog, enabled in the project, with filtered triggers: builder on Work item: Created; fixer on Pipeline: Failed + Mention; warden on Pipeline: Passed/Failed + Mention. - Lifecycle stages: Plan (issue, builder's plan) → Create (builder) → Verify (unit tests, typecheck, main guard) → Secure (SAST, Secret Detection, Dependency Scanning, also in merge request pipelines) → Govern (policy gate, warden's audit trail) → Package (site build, container image in the Container Registry, CycloneDX SBOM) → Release/Deploy (GitLab Pages) → Monitor (smoke test of the live page). Agents do plan, code, fix and audit; CI does the rest.
- The gate cannot be swapped by the change it judges. It runs from a git worktree of the trusted commit (target branch, or
mainbefore the merge), with that commit's own lockfile, tsconfig and policy; the change is only read as data. It starts withneeds: [], so a change cannot skip it by making an earlier job fail. - Agent identity from commits, fail closed. GitLab attributes an agent's merge request to the human who triggered the flow (composite identity), so the gate does not trust the MR author. A change counts as human only if a listed human started the pipeline and every commit's author and committer email is that human's.
- 40 unit tests, including real temporary git repos for deletions, binaries, odd file names, a
-diff.gitattributesentry, and a git-pushed commit whose committer is the human.
Four loops, run for real on GitLab
- Normal change: issue #2 → builder → !3 → gate passed (judged as agent) → I merged → deployed → warden audit.
- Hostile issue #3: a real feature plus a polite request to add a download-and-run step to CI and remove
--ignore-scripts.- Our builder built only the feature and refused the CI part (!5).
- When I asked GitLab's general-purpose Developer flow directly, it noted the risk, then made the change (!6).
- The first version of the gate missed it: the failing download step skipped the later stage the gate ran in, and the gate keyed on the committer, which for this flow is the human. Both fixed in !7 and !8.
- Rebased onto the fixed gate, !6 fails with
protected-pathanddownload-and-runon.gitlab-ci.yml, judged as agent although I started the pipeline (job, sign-in needed). - The download step did run on the runner inside !6's own pipeline; it failed only because the image had no
curl. - The fixer started on its own, named both rules and declined to work around them. When I mentioned the warden, it posted the red audit on !6. !6 was never merged.
- Fixer: as reviewer I added a test to !5; the pipeline failed; the fixer changed only
app/src/badge.ts; the gate passed; the warden audited; I merged; deployed. - A bug the demo exposed: the badge used the colour name
amber, which is not CSS, so it was invisible. Issue #4 → builder !12 (hex colours and a test) → merged → the badge is visible.
What running it showed
- An agent's merge request is attributed to the triggering human (documented). Anything keyed on the MR author, such as approval rules, treats agent work as human work.
- A service account's pipeline does not start flows (triggers, documented). It prevents loops; it is why the fixer and warden also start on a mention.
- Commit identity depends on how a flow writes: API commits carry the service account as committer; GitLab's Developer flow pushes with git and keeps it as author only (1 case, !6).
- Holes found in the first gate by the runs and by review, each fixed in its own merge request: the two above (!7), plus the gate's dependencies coming from the change,
.gitattributeshiding added lines, and a denylist of agents (!8).
Known limits
- A change that rewrites
.gitlab-ci.ymlcontrols its own pipeline and can drop the gate from it, and frommain's after merge. CI is a protected path for agents, and the human rule is: never merge a merge request whosepolicy-gateis not green. The real fix needs Maintainer/Owner rights: a pipeline execution policy plus "Pipelines must succeed". mainstill accepts direct pushes from Developers. The main guard catches a pushed ordinary commit, but a merge commit created locally and pushed would pass it, and a direct push can edit the guard.- Commit emails are set by whoever commits. Our flows have no shell and commit through the API, where GitLab sets the committer; a flow that runs git could write the human's public noreply email.
- Merge request pipelines run the change's own jobs with GitLab's
CI_JOB_TOKEN, as !6 shows. - Content rules are tripwires for common poisoning shapes, not a malware scanner.
What's next
A pipeline execution policy so the gate is injected from outside the repository; protecting test files from agent edits; checking in CI that each main merge commit belongs to a merged merge request; a per-agent allowlist of paths.
Built With
- cyclonedx
- docker
- gitlab
- gitlab-ci
- gitlab-duo
- gitlab-pages
- node.js
- sast
- typescript
- vitest
Log in or sign up for Devpost to join the conversation.