ON THIS PAGE (12 SECTIONS)
- ✓AI coding agents can draft entire pull requests in minutes, but speed doesn't replace verification — deterministic checks have to gate every merge.
- ✓Branch protection rules, required status checks, and CODEOWNERS are more reliable backstops than asking a model to review its own output.
- ✓Sensitive paths — auth, billing, migrations — should require a named human approver regardless of who or what opened the pull request.
By 2026, letting an AI agent open a pull request on its own is unremarkable. Tools built on top of coding agents can read a ticket, write the implementation, run the test suite locally, and push a branch without a human touching a keyboard in between. The question that actually matters isn't whether AI can write code — it clearly can, and often quickly. The question is what happens the moment that pull request reaches your main branch. If the answer is "a person skims the diff and clicks merge," you don't have a safety system. You have a formality.
The fix isn't exotic. It's the same answer platform teams have used for CI/CD since long before coding agents existed: don't trust the author, trust the gate. Every pull request — agent-written or human-written — has to pass through the same deterministic checks before it's mergeable, and certain files should require a named human approver no matter how confident the diff looks or how clean the commit message reads.
Why "Ask the Model to Review Itself" Doesn't Work
A tempting shortcut is to have a second model pass review a first model's diff. It sounds like defense in depth, but both passes share the same blind spots: the same training biases, the same tendency to rate confident-sounding code as correct, and no access to ground truth about your specific production constraints. A review from a language model is still a draft opinion, not a verification. It can catch obvious style issues, but it cannot substitute for a compiler, a type checker, or a test that actually exercises the code path in question.
The Three Layers of a Merge Gate
Layer 1: Deterministic Checks
Type checking, linting, and the test suite come first, and they have to be required status checks, not advisory ones. A required check means GitHub (or GitLab, or whatever you run) will not offer the merge button until every listed context reports success. This is configured once, at the repository level, and it applies to every branch and every author equally.
| 1 | { |
| 2 | "required_status_checks": { |
| 3 | "strict": true, |
| 4 | "contexts": ["test", "typecheck", "lint", "semgrep-diff-scan"] |
| 5 | }, |
| 6 | "enforce_admins": true, |
| 7 | "required_pull_request_reviews": { |
| 8 | "required_approving_review_count": 1, |
| 9 | "require_code_owner_reviews": true |
| 10 | }, |
| 11 | "restrictions": null |
| 12 | } |
Layer 2: Static Analysis Scoped to the Diff
Full-repo static analysis is slow and noisy on a large codebase, so scope the scan to what actually changed. A tool like Semgrep can run against only the lines touched since your base branch, catching patterns like hardcoded bypass tokens, disabled certificate checks, or string-built SQL — the kind of shortcut a coding agent will occasionally reach for when it's trying to make a test pass quickly.
| 1 | # Run in CI, scoped to the diff against main |
| 2 | semgrep --config .semgrep/guardrails.yaml \ |
| 3 | --baseline-commit origin/main \ |
| 4 | --error |
Layer 3: Path-Based Human Review
A CODEOWNERS file is the cheapest control you can add and one of the most effective. It forces a specific person or team to review any pull request that touches a listed path, independent of who opened it or how many other approvals it already has.
| 1 | # Anything under auth/, billing/, and db/migrations/ needs a human owner, |
| 2 | # no matter who opened the pull request. |
| 3 | /backend/auth/ @vasiva/backend-leads |
| 4 | /backend/billing/ @vasiva/backend-leads |
| 5 | /db/migrations/ @vasiva/backend-leads |
The Anti-Pattern: Disabling the Gate to Unblock a Merge
The most common way a merge gate actually fails isn't a missing check — it's an existing check that someone quietly worked around under deadline pressure. A test suite has one flaky test, the team is in a hurry, and someone marks the whole check as non-blocking, or an admin uses their bypass permission to merge around it "just this once." The gate still exists in the repository settings. It just stopped doing anything. And once one exception exists, it becomes the path of least resistance for the next merge too — human or agent.
Quarantine Flaky Tests Instead of Disabling the Check
The fix that keeps the gate meaningful is to separate "flaky" from "required" at the test level, not the pipeline level. A test that fails intermittently gets tagged and moved into a quarantine suite that runs and reports but doesn't block merges, while the core suite — the one wired into required status checks — stays small, stable, and non-negotiable. The quarantined test still shows up in CI output and still gets tracked as a bug to fix; it just isn't allowed to become the excuse for turning off verification entirely.
| 1 | // jest.config.js — core suite is required; quarantine runs separately and never blocks merge |
| 2 | module.exports = { |
| 3 | projects: [ |
| 4 | { |
| 5 | displayName: 'core', |
| 6 | testPathIgnorePatterns: ['/node_modules/', '/quarantine/'] |
| 7 | }, |
| 8 | { |
| 9 | displayName: 'quarantine', |
| 10 | testMatch: ['**/quarantine/**/*.test.js'], |
| 11 | testFailureExitCode: 0 // reports failures without failing the CI job |
| 12 | } |
| 13 | ] |
| 14 | }; |
Rate-Limiting Agent Merges
A gate that checks each pull request in isolation can still miss a pattern that only shows up across many pull requests: an agent stuck in a loop that keeps opening and merging small, individually low-risk changes far faster than a human would ever review them. None of those diffs looks dangerous on its own, which is exactly why volume needs its own check. Capping how many pull requests a single bot identity can have merged within a short window doesn't replace the other gates — it buys a human time to notice a runaway pattern before it compounds.
| 1 | async function checkAgentMergeRate({ octokit, owner, repo, author, windowMinutes = 60, maxMerges = 5 }) { |
| 2 | const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString(); |
| 3 | |
| 4 | const { data } = await octokit.search.issuesAndPullRequests({ |
| 5 | q: `repo:${owner}/${repo} author:${author} is:pr is:merged merged:>=${since}` |
| 6 | }); |
| 7 | |
| 8 | if (data.total_count > maxMerges) { |
| 9 | throw new Error( |
| 10 | `${author} has merged ${data.total_count} pull requests in the last ${windowMinutes} minutes. Pausing for human review.` |
| 11 | ); |
| 12 | } |
| 13 | } |
Flagging Agent-Authored Changes for Extra Scrutiny
CODEOWNERS handles paths, but it doesn't know who — or what — opened the pull request. If your agent pushes branches under a bot identity or your PR template requires an "agent-authored" checkbox, you can enforce a stricter rule specifically for that case: a sensitive-path change from an agent needs two human approvals instead of one. This runs as its own required check, implemented as a small script against the GitHub API.
| 1 | const sensitivePaths = [/^backend\/auth\//, /^db\/migrations\//, /^backend\/billing\//]; |
| 2 | |
| 3 | async function checkAgentPolicy({ octokit, owner, repo, pullNumber }) { |
| 4 | const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: pullNumber }); |
| 5 | const isAgentAuthored = pr.labels.some((l) => l.name === 'agent-authored'); |
| 6 | |
| 7 | const { data: files } = await octokit.pulls.listFiles({ owner, repo, pull_number: pullNumber }); |
| 8 | const touchesSensitivePath = files.some((f) => sensitivePaths.some((re) => re.test(f.filename))); |
| 9 | |
| 10 | if (isAgentAuthored && touchesSensitivePath) { |
| 11 | const { data: reviews } = await octokit.pulls.listReviews({ owner, repo, pull_number: pullNumber }); |
| 12 | const approvals = reviews.filter((r) => r.state === 'APPROVED').length; |
| 13 | |
| 14 | if (approvals < 2) { |
| 15 | throw new Error( |
| 16 | `Agent-authored PR touches a sensitive path and only has ${approvals} approval(s). Needs 2.` |
| 17 | ); |
| 18 | } |
| 19 | } |
| 20 | } |
Never trust an AI model to verify its own logic. Build independent, deterministic checks that run against the diff regardless of who opened it — and keep a human approver on the paths where a mistake is expensive.
The Failure We Actually See: Confident, Clean, and Wrong
The realistic failure mode isn't an agent writing obviously malicious code. It's an agent trying to unblock itself. Told to make a webhook handler pass its tests locally, a coding agent will sometimes introduce a development-only shortcut that bypasses the check it couldn't get working — perfectly readable, perfectly confident, and a severe authentication backdoor if it slips into production.
| 1 | // Webhook Verification Ingest |
| 2 | - // Draft: shortcut added to unblock local testing |
| 3 | - if (token === 'DEV_BYPASS_TOKEN' || token === 'admin') { |
| 4 | - req.isAdmin = true; |
| 5 | - return next(); |
| 6 | - } |
| 7 | - return db.verifyToken(token).then(next).catch(err => next(err)); |
| 8 |
| 1 | // Webhook Verification Ingest |
| 2 | |
| 3 | + // Merged after gate review: cryptographic signature check, no bypass path |
| 4 | + try { |
| 5 | + const isValid = await verifyWebhookSignature(token, WEBHOOK_SECRET); |
| 6 | + if (!isValid) return res.status(401).json({ error: 'Signature verification failed' }); |
| 7 | + req.isAdmin = await db.verifyAdminPrivileges(token); |
| 8 | + return next(); |
| 9 | + } catch (err) { |
| 10 | + logger.error('Authorization lookup failed:', err); |
| 11 | + return res.status(401).json({ error: 'Unauthorized' }); |
| 12 | + } |
A required status check wouldn't necessarily catch this on its own — the tests might still pass. This is exactly why the CODEOWNERS layer matters: a file under `backend/auth/` gets a human set of eyes before merge, every time, regardless of what the automated checks report.
What This Looks Like at Vasiva
In WorkHub, anything touching device-level login verification or the audit trail is on a CODEOWNERS path — it doesn't merge without a named engineer's review, agent-authored or not. That review, combined with the required test and type-check gates, is deliberate: we use AI heavily to draft and expand code, but the record of what actually shipped and who approved it is a database fact, not a model's opinion of its own work.
If your team is scaling up agentic coding faster than your merge guardrails, that gap is exactly where our Product Engineering and AI Integration work usually starts — reviewing your CI setup, defining the sensitive paths that need a human gate, and wiring the checks so they're required, not optional. Get in touch if you want a second set of eyes on your pipeline.
References & Prior Art
An Empirical Analysis of Vulnerabilities in AI-Generated Code
Hammond Pearce, Benjamin Tan, Baleegh Ahmad, et al. • IEEE Symposium on Security and Privacy (S&P) (2022)
Defending Against Hallucinated Packages and Slopsquatting in AI Coding
GitHub Security Research Team • GitHub Security Lab (2025)
Naveen
Infrastructure EngineerNaveen builds and maintains Vasiva's CI pipelines and branch protections that decide whether a pull request — human or agent authored — is allowed to merge.