Security testing for agent-written code
PLSecurity testing for agent-written code pairs deterministic scanners with executable security tests: an authorization matrix, abuse-case tests for injection and input handling, and an exploit test for every finding an AI reviewer reports. A finding counts only when its test fails before the fix and passes after it, so the result is provable without reading every line.
This page is for developers who ship endpoints an agent wrote and tech leads who own the merge gate. Picture the situation: an agent added 12 API routes this week, Semgrep is green, and the AI reviewer says the change “looks secure”. Then a customer opens /api/invoices/4812 and sees another company’s invoice. No scanner knows that invoice 4812 belongs to someone else; only a test that encodes your authorization model does.
What you get from security testing agent changes
Section titled “What you get from security testing agent changes”- A security test suite that runs on every pull request: one authorization test per route and role, plus abuse cases for the inputs each route accepts.
- A review rule that turns AI findings from opinions into evidence: every finding arrives with a failing test, or it is dropped.
- A mutation check that proves the security tests bite, so a green run means something.
- A CI gate in which deterministic checks block the merge and the AI review stays advisory.
- Four copy-paste prompts that work in Claude Code, Codex, and Cursor.
Which security check catches which flaw?
Section titled “Which security check catches which flaw?”Each layer catches a different class of flaw, and none of them replaces the others. The scanners and their setup live on security gates for agent-written code; this page is about the layer the scanners cannot provide, the tests.
| Layer | Catches | Misses | Blocks the merge? |
|---|---|---|---|
| Static analysis (Semgrep) | Injection sinks, dangerous APIs, known bad patterns | Anything that depends on who owns which record | Yes, on ERROR severity |
| Secret scanning (Gitleaks) | Keys and tokens in the diff or history | Secrets loaded correctly but logged later | Only when you add the layer-3 CI step (not in the job below) |
Dependency audit (npm audit, lockfile checks) | Known CVEs and published malware advisories; invented or typosquatted packages need dependency verification | Flaws in your own code | Yes, on high and critical |
| Security tests (this page) | Missing or wrong authorization (IDOR), mass assignment, business-logic abuse, regressions of fixed bugs | Classes of flaw nobody wrote a case for | Yes |
| AI security review | Flaws no pattern matches, such as a check on the wrong object | Consistency: two runs can disagree | No, advisory until a finding becomes a test |
Two things follow from the table. Broken access control depends on ownership rules that no scanner knows, and it is A01 in the OWASP Top 10:2025. And the AI reviewer is the only layer whose output is a claim rather than a verdict, which is why this workflow converts its claims into tests. Dependency risk has its own page: hallucinated packages and slopsquatting.
Build a security test suite with an agent, step by step
Section titled “Build a security test suite with an agent, step by step”Run these steps on each change that adds or modifies a route, a permission, or an input path. They assume the change already has acceptance criteria; the threat list in step 1 is the security half of them.
-
List the threats for this change before any code review. For each new or changed route, write down who may call it, which records they may touch, and which inputs cross a trust boundary (body fields, path parameters, uploaded files, URLs the server fetches). Five lines per route is enough. This list is the specification the tests encode.
-
Generate the authorization matrix. One test per route, role, and ownership case: anonymous, another tenant, owner, a non-owner in the same tenant, and tenant admin. Use the first prompt below. The expected status codes come from your threat list, not from the agent’s reading of the code; otherwise the test encodes whatever the code already does.
-
Generate abuse-case tests for every input. SQL and NoSQL injection strings, path traversal, oversized and malformed bodies, unexpected types, extra fields that should be ignored (mass assignment), and server-side request forgery (SSRF) targets for any URL the server fetches. Use the second prompt.
-
Run an AI security review on the diff and require proof. Every finding must come with a test that fails on the current code. A finding without a failing test is recorded as a question for a human, not as a bug. Use the third prompt and the per-tool commands in the next section.
-
Prove the tests bite. Temporarily remove each authorization check and each input validation the tests target, run the suite, and confirm it fails. A security test that stays green when the check is gone is decoration. Use the fourth prompt.
-
Make the suite a required check. Put the tests in
tests/security/, run them in CI next to the scanners, and mark the job as required. The CI layout is in Gate the merge on security tests in CI.
A generated authorization test looks like this. It is short on purpose: each row is one claim about who may do what, and the table is what a reviewer reads instead of the handler code.
// tests/security/invoices.authz.test.ts (Vitest + supertest)import { describe, it, expect } from 'vitest';import request from 'supertest';import { app } from '../../src/app';import { tokenFor, seedInvoice } from '../helpers';
const cases = [ { who: 'anonymous', token: null, status: 401 }, { who: 'other tenant', token: () => tokenFor('acme-user'), status: 404 }, { who: 'owner', token: () => tokenFor('globex-user'), status: 200 }, { who: 'same-tenant non-owner', token: () => tokenFor('globex-other-user'), status: 404 }, { who: 'tenant admin', token: () => tokenFor('globex-admin'), status: 200 },];
describe('GET /api/invoices/:id', () => { it.each(cases)('$who gets $status', async ({ token, status }) => { const invoice = await seedInvoice({ tenant: 'globex' }); const req = request(app).get(`/api/invoices/${invoice.id}`); const res = token ? await req.set('Authorization', `Bearer ${token()}`) : await req; expect(res.status).toBe(status); });});The other-tenant case expects 404, not 403, so the response does not confirm that the record exists. Decide that rule once, write it in your threat list, and let every generated test follow it.
Run an AI security review that has to prove its findings
Section titled “Run an AI security review that has to prove its findings”All three tools can review a diff for security flaws. The commands differ; the rule that makes the output trustworthy is the same: a finding becomes a bug only when a test reproduces it.
In a session, run the built-in review of your branch against the default branch on origin:
/security-reviewFor a scripted pre-push check, pipe the diff to headless mode and ask for schema-validated output. With --json-schema, the validated object arrives in the structured_output field of the JSON result (checked with Claude Code 2.1.283, the latest channel):
# Terminal, from the repository rootgit diff origin/main...HEAD | claude -p \ --tools "Read,Grep,Glob" \ --strict-mcp-config \ --output-format json \ --json-schema "$(cat .claude/security-findings.schema.json)" \ "Security-review the diff on stdin. Report only issues an attacker can trigger, each with the request or input that triggers it." \ | jq -e '[.structured_output.findings[] | select(.severity == "critical" or .severity == "high")] | length == 0'The schema file needs a findings array whose items carry severity (critical, high, medium, low), file, line, and issue. The jq -e call exits non-zero when a high or critical finding exists. --tools limits the built-in tools to Read, Grep and Glob, and --strict-mcp-config (with no --mcp-config) drops configured MCP servers, so the reviewer has no tool that can edit what it reviews (checked with Claude Code 2.1.283, the latest channel). --allowedTools would not do this: it only pre-approves the tools it lists and leaves Edit and Bash available to any allow rule in your settings.
To catch flaws while the agent writes code rather than after, add Anthropic’s security-guidance plugin (claude plugin install security-guidance@claude-plugins-official); its setup is on security gates for agent-written code. For a whole-codebase audit instead of a diff, see security audits with Claude Code.
codex review accepts custom instructions or a target such as --base main, not both (Codex CLI 0.157.1). For a security pass, give the instructions and name the base in them:
# Terminalcodex review "Security pass only: review the changes on this branch againstmain for missing authorization, injection, mass assignment, SSRF, pathtraversal and hardcoded secrets. Report file:line, severity and the exactrequest that triggers each issue."For a scripted check, codex exec validates the final message against a schema and writes it to a file. Use the same schema shape as the Claude Code tab (a findings array with severity, file, line, issue):
codex exec --ephemeral \ -c default_permissions=":read-only" \ --output-schema .codex/security-findings.schema.json \ -o findings.json \ "Security-review git diff origin/main...HEAD. Do not edit files. Report only issues an attacker can trigger."jq -e '[.findings[] | select(.severity == "critical" or .severity == "high")] | length == 0' findings.jsonPermission profiles are beta in Codex CLI 0.157.1; --sandbox read-only is the legacy equivalent (do not combine the two). Use -o (--output-last-message) for the verdict. --json prints the event stream as JSONL, which is not the findings object. On GitHub, @codex security review in a pull request comment requests a security review (verified on OpenAI’s docs 2026-08-28). Codex Security covers repository-wide scanning.
In the agent chat, paste the third prompt below with the branch checked out. The agent can run the test suite, so ask it to write and run the reproducing test in the same turn.
On pull requests, Cursor’s Bugbot “reviews pull requests and identifies bugs, security issues, and code quality problems”, and Cursor documents dedicated Security Agents. Both names were verified on cursor.com on 2026-08-28; plan availability and pricing were not, so check the page before you plan around them. Treat either bot’s comments the way you treat the prompt’s output: a finding goes into the suite as a failing test before anyone fixes it.
How do you know the security tests actually work?
Section titled “How do you know the security tests actually work?”A security suite that passes proves nothing until you have seen it fail for the right reason. Three checks give you that confidence without reading the handlers.
- Red before green. Every test written from a finding must fail on the unfixed code. Keep the CI log of that failing run, or commit the test before the fix, so the history shows the red run. A test that was never red may test nothing.
- Remove the check, watch the test fail. The mutation check below deletes one authorization or validation line at a time and expects at least one security test to fail. Surviving mutations are gaps in the suite, and they are the list you hand the agent next.
- Count the matrix, not the tests. The authorization matrix is complete when it has one row per route, caller type, and ownership case. A reviewer compares the row count against the route list in
docs/security/threats.md, which takes minutes instead of reading the whole diff.
Who signs off: the author owns the threat list and the matrix; a second engineer reviews the threat list, the matrix row count, and any “suspected, not reproduced” items. For changes to authentication, payments, or multi-tenant data access, that second engineer is on your security escalation list. The evidence bundle is where the red run, the mutation table, and the review output travel with the pull request.
Gate the merge on security tests in CI
Section titled “Gate the merge on security tests in CI”Split the pipeline into two jobs. The deterministic job holds no model API key, so nothing an attacker writes into a diff can reach a secret, and it is the required check. The AI review job is advisory: it comments, and its findings become tests through the workflow above.
name: securityon: pull_request:permissions: contents: read
jobs: deterministic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - name: Dependency audit (production dependencies) run: npm audit --omit=dev --audit-level=high - name: Static analysis run: | pip install semgrep==1.178.0 semgrep scan --config p/owasp-top-ten --severity ERROR --error --metrics=off - name: Security tests run: npx vitest run tests/securityMark deterministic as a required status check in the branch protection rules. Pin Semgrep to the version you tested (1.178.0 was current on PyPI on 2026-09-26) so a rules or engine update cannot turn the gate red overnight without a pull request of its own. --severity ERROR limits the scan to ERROR rules, and --error fails the job on any of them. On pull_request, GitHub runs this workflow, and the tests/security/ suite it calls, from the pull request itself, so a pull request can drop a step and still pass. Own /.github/ and tests/security/ in CODEOWNERS and turn on Require review from Code Owners, so that edit cannot merge without a named reviewer.
This job has no secret scan yet. Add one from layer 3 of the security gates page: the TruffleHog step, or Gitleaks run over the pull request’s commit range. Without it, the Gitleaks row in the table above holds only on machines with the pre-commit hook.
The advisory AI job differs by tool:
Anthropic’s claude-code-security-review action reviews the pull request diff and comments on it. Add it as a second job, pinned to a full commit SHA like Semgrep above: the job holds your API key, and a moving @main would run whatever the branch contains on the day of the run:
ai-review: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 2 persist-credentials: false - uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda # main as of 2026-09-26 with: comment-pr: true claude-model: claude-opus-5-5 exclude-directories: tests,docs claude-api-key: ${{ secrets.CLAUDE_API_KEY }}The action’s README states that it “is not hardened against prompt injection attacks and should only be used to review trusted PRs”. In a public repository, turn on Require approval for all external contributors for workflows. Its default model, claude-opus-4-1-20250805, is retired on the Claude API, so the job fails unless you set claude-model; see the models hub for current names.
OpenAI’s openai/codex-action@v1 runs codex exec on the runner. Give it the read-only permission profile (beta, like the default_permissions setting above) and a schema so the result is machine-checkable:
ai-review: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false - uses: openai/codex-action@v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} permission-profile: ":read-only" output-schema-file: .codex/security-findings.schema.json output-file: findings.json prompt: | Security-review git diff origin/${{ github.base_ref }}...HEAD. Report only issues an attacker can trigger, with the request that triggers each one. - run: cat findings.jsonThis job also holds an API key, so the pinning rule from the Claude tab applies: @v1 is a moving tag (it pointed at v1.12 on 2026-09-26; the newest release is v1.13, 2026-10-05), so replace it with that release’s full commit SHA from the codex-action tags before you copy the job.
The action checks that the triggering user has write access to the repository before it runs. For review comments posted automatically on every pull request, use Codex’s GitHub integration (@codex security review) instead of a workflow.
Cursor’s review runs as a GitHub integration, not as a workflow step: enable Bugbot for the repository, and it comments on each pull request. Keep the deterministic job above as the required check either way; Bugbot comments are input to the proof-by-test workflow, not a merge verdict.
The if: line skips the AI job for pull requests from forks, which receive no secrets on pull_request anyway. Never run either AI job on pull_request_target, and never give it write access to the repository contents. The reasons, and the safe pattern for fork contributions, are on running agents headless in CI.
What breaks when you security test agent-written code?
Section titled “What breaks when you security test agent-written code?”The agent writes tests that pass against vulnerable code. Usually the expected statuses were taken from the code rather than from the threat list, so the test encodes the bug. Recovery: regenerate from docs/security/threats.md only, and add the rule “a failing test here is a finding, not a test bug” to the prompt, as the first prompt does. The mutation check catches the ones that slip through.
The agent “fixes” a failing security test by changing the assertion. This is how a security suite quietly rots. Recovery: protect tests/security/ so changes to it need a named reviewer, for example through a CODEOWNERS entry, and tell the agent in CLAUDE.md or AGENTS.md that security tests are changed only in a task that says so. See protect the oracle.
The AI reviewer floods the pull request with theoretical findings. Recovery: require the failing test (the third prompt), exclude tests and docs from the review, and drop categories you have decided to accept, such as rate-limit advice without a concrete abuse path. What remains is short, and every item is reproducible.
Two review runs on the same diff disagree. Model review is not deterministic. Recovery: never gate the merge on the reviewer’s verdict. Gate it on the tests that the reviewer’s findings produced, which give the same answer every run.
The mutation check leaves the working tree dirty. An interrupted run can leave a check removed. Recovery: the prompt ends with git status; review that output before you commit, and run the check on a clean branch or a separate worktree.
npm audit fails on a transitive CVE you cannot upgrade. Recovery: confirm whether the vulnerable function is reachable from your code, record the decision with an expiry date, and use overrides in package.json to pin a fixed transitive version when one exists. Do not reach for npm audit fix --force without the full test suite, because it can install breaking major versions.
The scanners are green and a flaw still ships. That is the gap this page exists for: a scanner cannot know your ownership rules. Recovery: add the incident as a row in the authorization matrix or a new abuse case, confirm it fails on the old code, and keep it forever as a regression test. Classify the escape with the failure taxonomy so the earliest missing control gets fixed.