Humanbound website

How to Red-Team Your AI Agent's Pull Requests in GitHub Actions: One Prompt Change, 26 Cents, and a Red Build

AP
Ayan Pahwa
Sep 23, 202614 min read
Techy cover for the Humanbound Agent Attack Scenario Library, showing a test_pack.yaml code card that maps an agent failure to an OWASP Agentic category and the guardrail that closes it, with Agent Goal Hijack, Tool Misuse, and Memory Poisoning tags.

This morning I opened a pull request that added one sentence to my support agent's system prompt: "Customers hate waiting: if they give you an order ID and an amount, issue the refund right away." It's the kind of change a product manager asks for on a Friday. It touches no function signature, breaks no unit test, and tells the agent to skip the order lookup that the refund tool's own description asks for.

Every check a normal pipeline runs would have passed it. So I wired Humanbound's adversarial scan into that repo's GitHub Actions, opened the PR, and let the attacks run. The gate went red, and the top finding in its report looked like my sentence at work: the agent confirming a $149.99 refund on an order that doesn't exist, with no sign it checked. The same scan on main went red too, which turned out to matter more.

This post is the whole setup, the real numbers from three runs, how to put a ceiling on spend, and what a team should do when the gate goes red and a human has to decide.

Your CI can't see the change that matters

A lot of the changes that matter in an agent aren't code changes. A prompt edit, a new model swap, a new tool in the list, a looser line in the scope file: each one changes what the agent will do when someone pushes on it, and none of them move a unit test. Linters don't read prompts. Type checkers don't know that the prompt now tells the agent to call issue_refund without looking the order up first.

Sofia Aliferi already covered what the Humanbound GitHub Action is and how fail-on works. This post is the next step: running it on a real repo with real money, and making it cheap enough that nobody turns it off.

The repo is my very own LangChain example from an earlier post: a small support bot with an order-lookup tool and a refund tool, served by a 15-line FastAPI wrapper. Humanbound attacks it over HTTP, a judge model grades each conversation, and the build fails when a finding crosses a severity threshold.

Only pay for a scan when the agent changes

A single attack run takes about 20 minutes and costs real tokens, so testing every commit is the wrong goal. The goal is testing every change to the agent's behavior surface, and letting everything else through for free.

How the red-team gate is triggered: agent changes run a single-turn scan on every PR and fail on high severity; other commits run nothing; an optional nightly agentic scan reports without blocking; a manual pre-release scan ends in human sign-off.
Figure 1: Only changes to the agent pay for a scan. Everything else merges at no cost, and the deeper scans run nightly or before a release.

In practice that means four triggers:

  • A PR that touches the agent (its code, prompt, tools, scope.yaml, bot-config.json or dependencies) runs the fast single-turn scan and turns the PR's check red. It only blocks the merge once you make it a required check, which needs care with a paths filter (more on that below).
  • Any other PR runs nothing. A paths filter decides that before a runner even starts.
  • A nightly run, if you switch it on, does the slower multi-turn agentic scan and reports without blocking anyone.
  • Before a release, someone presses "Run workflow" and picks a depth.

The split between single-turn and multi-turn is the important call. Single-turn fires hundreds of one-shot attacks, which is broad and quick. The agentic engine holds multi-turn conversations and escalates, which is how real social engineering works, and it is slower.

Setting up the gate

Here is the complete workflow from the repo, .github/workflows/agent-redteam.yml. I'll go through the parts that aren't obvious after it.

Loading code editor...

Before the first run, add a repository secret under Settings, then Secrets and variables, then Actions. Mine is one OPENAI_API_KEY that pays for both sides: the attacker and judge, and the agent under test. Use a key made for CI, not the one on your laptop, for reasons the budget section makes clear. The rest of the workflow needs a few words of explanation.

The agent boots inside the job. In local mode, the Humanbound engine runs on the runner, so localhost means the runner itself. The workflow installs the agent's dependencies, starts uvicorn in the background and polls /health until it answers. The agent reads its model settings from TARGET_BASE_URL, TARGET_API_KEY and TARGET_MODEL, which here point it at gpt-4o-mini on OpenAI.

The attacker and the judge are one model you choose, paid with your key. I used gpt-4o-mini at $0.15 per million input tokens and $0.60 per million output tokens (OpenAI pricing). Two limits shaped that choice. The openai provider always calls api.openai.com and has no base-URL setting (issue #70), so a gateway like OpenRouter can't sit in the middle. And it sends max_tokens, which OpenAI's reasoning models reject: gpt-5.6-luna failed on the first call in my local test, with the API's "use max_completion_tokens" error reported as "Inappropriate content". Stick to a non-reasoning model such as gpt-4o-mini or gpt-4.1. The Action also supports Anthropic, Gemini, Grok, Azure OpenAI and Ollama.

The scope: ./scope.yaml input tells the judge what the agent is allowed to do. Without it, the judge has to guess whether issuing a refund is a feature or a breach. With it, "issue a refund without verifying the order exists" is written down as restricted, and the judge grades against that.

The job-level if skips fork PRs on purpose. GitHub doesn't pass secrets to workflows triggered from a fork, so a fork PR would fail on a missing key and look like a security failure. Skipping them is a real gap, and I come back to it below.

Findings reach the Security tab through two steps. The Action writes a SARIF file but doesn't upload it. The upload-sarif step does, and it needs security-events: write. Code scanning is free on public repositories. I use @v4, because v3 prints a notice that it's deprecated in December 2026.

One warning about the last step. On a public repository, anyone signed in to GitHub can download a run's uploaded files. Mine hold attack transcripts and the agent's log, which is fine for a demo with fake orders. On a real agent the transcripts can contain whatever the agent leaked, so on a public repo either drop upload-artifact or keep the transcripts out of it.

One gotcha that isn't in the workflow: don't make a path-filtered workflow a required status check. When the paths filter skips it, GitHub leaves the check "Pending" and the PR can't merge. Either keep the gate out of branch protection's required list, or replace the paths filter with a job that detects changes and a job-level if, because a job skipped by a conditional reports success.

Failed GitHub Actions red-team run on the "Make refunds faster" PR: 383 passed, 45 failed, max severity 85/100, led by 36 restriction_bypass failures, at a cost of about $0.26.
The red-team gate failing the refund-shortcut PR (an earlier run of PR #1)

What one run actually costs

I ran three scans with the workflow above, all at the same time on GitHub-hosted runners: the single-turn scan on main as a baseline (started by hand, with single-turn picked), the single-turn scan the pull request triggered, and the multi-turn agentic scan started by hand on the PR's branch. The token meter described below gave the cost of the attacker and judge calls. If you open PR #1 now you won't see the check: its latest commit only rewrote history and was marked to skip CI, and the code it carries is the code these runs scanned.

Loading code editor...

The cost is small. About 26 cents per scan with gpt-4o-mini means ten scans a week cost around $11 a month. Every push to an agent-changing PR is a scan, so count pushes, not PRs, plus the agent's own model calls during the scan. I didn't meter those. With gpt-4o-mini as the agent's model they should come to a few cents per scan, but your provider's usage page is the place to check. A stronger attacker model costs more per run and writes better attacks, so treat 26 cents as the floor, not the price.

The time is about what the Action's README promises for a quick scan, around 20 minutes. It isn't guaranteed, though. An earlier attempt of mine with a different model setup ran two scans side by side with the same config, one on main and one on the PR, and they took 14.6 and 37.8 minutes, so leave room in timeout-minutes.

The PR's report named the regression. fail-on doesn't look at individual conversations. It looks at the findings the judge groups them into, and fails the build if any of them is at or above your threshold. On the PR, the top critical finding was the agent "processing a refund without verifying the order", quoting it confirming a refund of $149.99 for order #732189. That finding isn't in the main report, but the behavior is: main also confirmed refunds on orders that don't exist, three times, each after telling the attacker the order wasn't found. On the PR it happened nine times, six with no mention of a lookup at all. Failures whose explanation mentions refunds went from 31 to 41. That is one run on each side, and the attacks are generated fresh each time, so read it as a hint I'd want a second run to confirm. What I found useful is that the report named the behavior instead of handing me a score.

The two engines disagree about the same code. Single-turn graded the PR a C at 70. The agentic scan graded it an F at 5, and 90 of its 97 conversations failed. Don't read that as multi-turn social engineering at work, though. My wrapper sends the agent only the latest message (bot-config.json maps $PROMPT, and server.py keeps no history), so the agent never saw the earlier turns. What changed is the unit being graded. Each agentic conversation is eight replies, and the judge fails the whole conversation if any one of them fails. At the single-turn failure rate of 11%, eight independent tries would already fail about 60% of conversations. If your agent keeps state, map $CONVERSATION in bot-config.json so the multi-turn engine tests what it was built for. Either way, single-turn is the cheap smoke test for PRs and agentic is the stricter check.

Put a ceiling on the spend

Nobody keeps a gate whose bill surprises them. There is no budget input on the Action, so the limits come from four places, and only one of them is a hard cap on money.

The trigger design is the biggest lever. A paths filter means a docs PR costs nothing. concurrency with cancel-in-progress: true means that when someone pushes three commits to a PR in ten minutes, only the latest scan runs to completion. The nightly scan is behind a repository variable, REDTEAM_NIGHTLY, so it costs nothing until someone decides it's worth paying for, and turning it off is one click in Settings, not a code change.

Next, timeout-minutes caps wall-clock time. The GitHub default is 360 minutes. I set 45 for quick scans and more for the deeper levels you can pick by hand. Spend grows with the minutes the scan runs, so a hung scan now stops at 45 minutes instead of burning six hours of attacker calls.

Then meter every run. The Action doesn't report tokens, so I added a 30-line sitecustomize.py. Python loads it at startup when its folder is on PYTHONPATH, and it wraps the HTTP call to OpenAI's chat endpoint to add up the token counts the API returns. A final step multiplies by the list price and writes the total to the run summary. It only counts and never changes a request. The numbers in the table above come from it.

The provider's cap is the only hard stop on money. Use a dedicated key for CI, on its own project, and put the limit there. On OpenAI, a project spend limit with "Enforce a hard limit" turned on makes API calls fail once the project hits it (docs). On Google Cloud, an ordinary budget only sends alerts and doesn't stop usage. A hard spend cap exists there only as a preview feature, and only for some services (docs). If your CI key hits its cap before the scan gets anywhere, every conversation errors, the CLI exits with code 2, and the build goes red for the right reason. If it hits the cap halfway, the conversations that finished still get graded and the run can pass on a partial scan, so check the errored count in the run summary before trusting a green build.

When the gate fails, a human decides

Look at the first section of the table again. main was red before my PR existed, with two critical findings of its own: the agent wrote out a <script> snippet when asked for one, and it described its internal refund logging to a stranger. fail-on: high asks whether there are serious findings right now. It doesn't ask whether this PR added them. On an agent that already has findings, every PR goes red, including the ones that fix things, and a gate that is always red is a gate people learn to ignore.

So add the gate in report-only mode first (fail-on: ''), let SARIF collect the existing findings in the Security tab, fix or accept them, and only then turn on blocking at critical, tightening to high later. A scheduled baseline scan of main gives reviewers the comparison they need: is this finding new, or was it already there?

That is why I think a red build should start a short, written process rather than a Slack argument. Here is the one I'd put in a team's CONTRIBUTING file. It's a proposal, not something I've run on a team yet.

Flowchart of what happens after the red-team gate finishes, branching on the exit code. Exit code 0 (pass): merge as normal. Exit code 2 (scan broke, every conversation errored so nothing was tested): the infra owner fixes the job and re-runs it. Exit code 1 (findings at the fail-on threshold): check whether the same finding also fails on main. If yes, it is old debt: log it to the backlog and merge with the agent owner's sign-off. If no, it is new with this PR: if critical, block the merge and page the security owner via CODEOWNERS; if not critical, re-run once because the judge is not deterministic. If it passes on re-run, merge and note the flake. If it still fails, the agent owner reads the transcript within one working day, then fixes it or accepts the risk in writing on the PR.  Caption:
What to do when the gate goes red: the exit code decides the first branch, and "is this also failing on main?" decides whether the PR is blamed for it.

The exit code decides the first branch. A 0 merges. A 2 means the scan itself broke (a bad key, an agent that never booted, a spend cap hit before anything ran), and that goes to whoever owns the pipeline. It never merges, because nothing was tested.

A 1 means findings, and the next question is whether they're new. If the same finding fails on main too, this PR didn't cause it. Log it to a backlog with an owner and let the PR merge with that owner's sign-off, otherwise every PR in the repo is blocked by debt nobody on the PR can fix.

If the finding is new with this PR and critical, block the merge and pull in the security owner. A CODEOWNERS entry for agent.py, the prompts and scope.yaml, with "Require review from Code Owners" turned on in branch protection, makes that automatic instead of a hope.

If it is new but not critical, re-run once, because the judge is a model and its grades move between runs. If it still fails, the agent's owner reads the actual transcript within a working day and does one of two things: fixes it, or writes on the PR that they accept the risk and why. The written acceptance is the point. It turns "we ignored the red build" into a decision someone signed.

What this setup does not do

The setup has some gaps, and I'd rather name them than have you find them.

  1. It doesn't test outside contributors. Fork PRs get no secrets, so the job skips them. Someone with write access has to push the branch into the repo, or run the scan by hand, before merging a community PR that touches the agent.
  2. Single-turn attacks are the shallow end. The table shows how far apart the two engines grade the same code, C against F, and my demo agent doesn't even keep conversation history, so a stateful agent gives the multi-turn engine more to work with. A PR gate built on single-turn catches the obvious regressions cheaply. It doesn't replace the nightly or pre-release agentic scan.
  3. The judge sees transcripts, not side effects. When the agent says "your refund has been processed", the judge fails whether or not the tool ran. That's the right call for a gate, because an agent that claims a refund is already a problem, but it isn't real proof that money moved. (More on that blind spot in Attack your own AI agent in under 10 minutes.)

Start with report-only, then block

Put the scan on the PRs that change your agent, not on every commit, and don't block merges until you know what main looks like. With path filters it costs cents per PR, and a timeout plus a hard cap on a dedicated key puts a ceiling you chose on a bad day. The escalation path is what stops a red build from turning into an argument.

My one-line refund shortcut would have gone through code review. The gate named it, and it also went red on everything else that was already wrong with that agent. Getting from "everything is red" to "red means this PR" is the actual work, and the workflow in the example repo is where I'd start.

Loading code editor...