AWS Builder Center

A multi-agent security review loop: find, challenge, prove, and fix

Claude Code and Codex review each other's work on Amazon Bedrock, proving an SSRF bug in an AgentCore sandbox before fixing it: find, challenge, prove, and fix.

AI-driven security review is everywhere right now. The results that make headlines tend to come from specialized, gated systems most teams can't actually use, yet the frontier models anyone can call are already strong at the same work. OpenAI's GPT-5.5, GPT-5.4, and Codex are now generally available on Amazon Bedrock  alongside Anthropic's Claude models, so you can put two of them in one AWS account and have one review the other's work. Security review is one use case that benefits from this, because a finding from one model is only a hypothesis until a second model, and ideally a real execution, backs it up.
As coding agents take on more of the work, the bottleneck shifts from generating a change to verifying it. One way to verify is to execute the code, and the Amazon Bedrock AgentCore  Code Interpreter gives you somewhere to do that: an isolated sandbox where an agent can run code to check whether a claim holds.
This post walks one pattern built on that: a review loop you run on a change before merging it, using two AI coding agents and the sandbox, all on Amazon Bedrock . Claude Code drives the loop, Codex  reviews the change and argues with the finding, and the AgentCore sandbox runs a small test that confirms the bug reproduces and, after the fix, that it doesn't. The "proof" is a generated abuse-case test: feed the malicious input and watch whether the bad outcome actually happens.
Figure 1: The review loop. Claude Code (on Claude Opus 4.8) finds, Codex (on OpenAI gpt-5.4) challenges, and an AgentCore Code Interpreter sandbox proves the finding by running it. Codex then writes the patch, Claude Code reviews it and adjusts it if it falls short, and the same sandbox re-verifies against the patched code.
Figure 1: The review loop. Claude Code (on Claude Opus 4.8) finds, Codex (on OpenAI gpt-5.4) challenges, and an AgentCore Code Interpreter sandbox proves the finding by running it. Codex then writes the patch, Claude Code reviews it and adjusts it if it falls short, and the same sandbox re-verifies against the patched code.

Stage 1, Find: Claude Code reads the PR

Consider a pull request that adds a URL fetcher (for webhooks or link previews) with a check meant to stop server-side request forgery: it pulls the host out of the URL and rejects anything internal.
Our example consists of a single module that takes a user-supplied URL and fetches it, with is_allowed() as the SSRF guard. A developer opens a pull request adding it, and you run the loop on that change before merging it. Here is the file as submitted:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# fetcher.py (from the PR)
import re

# Reject obviously-internal hosts before fetching.
BLOCKED = re.compile(r"^(169\.254\.169\.254|localhost|127\.0\.0\.1|10\.)")

def is_allowed(url):
host = url.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0]
if BLOCKED.match(host):
return False
return True

def fetch(url):
if not is_allowed(url):
return {"statusCode": 403, "body": {"error": "blocked"}}
# ... real code performs the outbound HTTP request here ...
return {"statusCode": 200, "body": {"fetched": url}}
The first step is just opening Claude Code in the repo and asking it to review the change for security issues, nothing pointing at SSRF or this function. That generic ask was enough to surface the bug here, though later, in The prompt sets the ceiling, a sharper prompt changes the outcome. Claude Code's response, verbatim:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Claude Code review
Most serious issue in fetcher.py

Hypothesis: SSRF via a bypassable denylist. is_allowed() blocks only four
literal host patterns (169.254.169.254, localhost, 127.0.0.1, 10.), letting an
attacker reach the cloud metadata endpoint and internal services through any
other representation.

Rationale: a denylist can't enumerate every alias for an internal IP.
2130706433, 127.1, [::1], 192.168.0.1, 172.16.0.1, and LOCALHOST all pass
BLOCKED.match, and there's no re-validation after DNS resolution or redirects.

To verify: confirm this is the live SSRF guard (no upstream allowlist / egress
control
), then check that is_allowed("http://2130706433/") and
is_allowed("http://127.1/") both return True.

Stage 2, Challenge: Codex pressure-tests the finding

The second opinion comes from Codex, through the Codex plugin for Claude Code . The plugin is not a separate runtime; it delegates to the local Codex CLI, reusing its configuration and auth, and exposes Codex as slash commands inside Claude Code. The one we use, /codex:adversarial-review, sends the current change to Codex with a prompt that tells it to challenge the change and look for reasons it should not ship, rather than confirm that it looks fine. Codex runs on gpt-5.4 on Bedrock here:
1
2
/codex:adversarial-review challenge the SSRF guard in fetcher.py: can a
user-supplied URL reach an internal address or the cloud metadata endpoint despite is_allowed?
Codex's response (gpt-5.4 on Bedrock, abridged):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Codex Adversarial Review (verdict: needs-attention)

No-ship: the current SSRF guard is trivially bypassed and cannot be trusted to
keep requests away from metadata or internal networks.

Findings:
- [critical] Userinfo in the URL authority bypasses the metadata/loopback block
(fetcher.py:8-10)
`is_allowed` extracts the host with raw string splits instead of URL parsing.
For `http://public.example@169.254.169.254/latest/meta-data/`, it produces host
`public.example@169.254.169.254`, so the guard allows it. A normal parser resolves
that URL's hostname to `169.254.169.254`, so the outbound client targets the
metadata IP anyway. The same bypass works for `foo@127.0.0.1`.
Recommendation: parse with `urllib.parse.urlsplit`, use the canonical `.hostname`
field, reject URLs containing userinfo, and apply the deny check to the parsed
host/IP, not to a manually split token.
- [high] The denylist misses common internal destinations even without syntax tricks
The regex only blocks exact `localhost`, `127.0.0.1`, `169.254.169.254`, and `10.*`,
so `http://LOCALHOST/`, `http://172.16.0.10/`, `http://192.168.1.10/`, and
`http://[::1]/` all pass.
Recommendation: resolve the parsed hostname to all addresses and reject loopback,
private, link-local, and reserved ranges via `ipaddress`.
The two models overlap and diverge in useful ways. Claude Code led with the unresolved-host gap (alternate IP encodings like the decimal 2130706433, plus missing ranges and LOCALHOST); Codex led with the userinfo trick (public.example@169.254.169.254, where the part before the @ is ignored by the network stack but not by the string split) and added the plain missing ranges the denylist never covered. Between them the picture is fuller than either gave alone, which is the practical case for a second, independent model. Both findings are still hypotheses produced by reading, though. The next step is to find out whether they are true.

Stage 3, Prove: run it in an AgentCore sandbox

The sandbox lets us stage a real-world consequence of the bug in miniature. Claude Code generates a proof-of-concept and ships it to an AgentCore Code Interpreter session via execute_code; the agent stays outside, only the PoC runs inside. It runs a mock internal service that serves a fake secret, then drives the bypass URLs through is_allowed and, for any the guard lets through, actually fetches them, so we see not just the guard's decision but whether a request reaches the internal target and pulls the secret back.
1
2
3
4
5
6
# PoC core, executed INSIDE the sandbox (abridged): the agent also writes the
# loopback service serving SECRET=ASIA_FAKE_STOLEN_KEY, the URL list, and helpers
for label, url in tests:
allowed = is_allowed(url)
if allowed and internal(url):
leaked |= "SECRET" in fetch_body(url) # did the request actually reach it?
The sandbox prints:
1
2
3
4
5
6
7
# AgentCore Code Interpreter: vulnerable PR code
direct 127.0.0.1 (control, should block) BLOCKED
userinfo bypass ALLOWED
decimal-int 2130706433 ALLOWED
127.1 short form ALLOWED

[VERDICT] REPRODUCED guard allowed an internal URL and creds were exfiltrated
Claude Code reproduced the issue by generating the proof code and running it in the Code Interpreter. The guard blocks the literal 127.0.0.1, but the userinfo, decimal-integer, and short-form spellings all pass, and the PoC fetched one and pulled back the fake secret. If a finding doesn't reproduce, it gets set aside for a human to judge rather than auto-fixed, so the loop spends its effort on confirmed bugs without discarding the uncertain ones.
The sandbox gives the loop a clean, disposable place to run the PoC twice: once to reproduce the bug, once to confirm the fix closed it. For our loopback PoC the isolation is mostly convenience, since nothing it does would harm a dev box. The safety payoff shows up when the proof itself is the dangerous act, like command injection or unsafe deserialization, where you want attacker-controlled code running nowhere near your machine.

Stage 4, Fix and re-verify: patch, then run the same PoC again

Hand the fix to Codex without leaving Claude Code. How you word this prompt steers the fix you get back (see The prompt sets the ceiling); Codex reads the repo and edits the file itself:
1
2
/codex:rescue Fix the SSRF bypass in fetcher.py with the smallest safe change.
Keep legitimate external URLs working.
The version Codex wrote replaces the string splits with urlsplit().hostname, rejects localhost, resolves the host, and allows it only if every resolved address is globally routable, which rejects loopback, private, link-local, and reserved ranges in one check:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import ipaddress, socket
from urllib.parse import urlsplit

def _is_public_address(host):
try:
addrinfo = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror:
return False
for family, _, _, _, sockaddr in addrinfo:
if family not in (socket.AF_INET, socket.AF_INET6):
return False
if not ipaddress.ip_address(sockaddr[0]).is_global:
return False
return True

def is_allowed(url):
host = urlsplit(url).hostname
if not host:
return False
host = host.rstrip(".").lower()
if host == "localhost":
return False
return _is_public_address(host)
Claude Code then reviews that patch before anything else runs. Its verdict here: the patch closes the bypasses at the root (the parsed hostname defeats the userinfo trick; getaddrinfo normalizes the integer and short forms; classifying the resolved IP catches every internal range), so it accepts it unchanged, while noting the residual gaps it does not cover (DNS rebinding, redirects, scheme). Only then does the same PoC run against the patched file in a fresh sandbox:
1
2
3
4
5
6
7
8
# AgentCore Code Interpreter: Codex's patched code
userinfo bypass BLOCKED
decimal-int 2130706433 BLOCKED
127.1 short form BLOCKED
direct loopback BLOCKED
public 8.8.8.8 (legit) ALLOWED

[VERDICT] CODEX PATCH VERIFIED userinfo bypass closed, internal ranges blocked, public allowed
Running the fix through the same test that caught the bug, every internal-address form is now blocked and a legitimate public address still passes. That is enough to say the demonstrated bypass is closed, with evidence attached to the PR, and that the loop caught it before merge rather than after.

The prompt sets the ceiling

Notice the ceiling on what the loop just produced. Asked for the fix, Codex hardened the function and stopped at its edges, and Claude Code's review accepted it. Neither reached for the control that actually neutralizes this attack class on AWS: the EC2 Instance Metadata Service v2 (IMDSv2 ). Even a perfect URL check is one in-process gate, and if some later code path skips it or parses a host differently than the real HTTP client does, the credentials at 169.254.169.254 are back in reach.
It is not that the models did not know. When we asked the same two the broader question, "beyond the diff, what is the systemic AWS mitigation?", both answered well: require IMDSv2 (HttpTokens=required) with HttpPutResponseHopLimit=1, disable IMDS where the workload does not need it, enforce it through launch templates, SCPs, and AWS Config so it cannot drift, scope the instance or task role to least privilege, and block egress to the link-local range. Codex added the subtlety that security groups and NACLs do not help here because metadata traffic is link-local; Claude Code pointed at GuardDuty for detection and named DNS rebinding as a gap the patched guard still has, since it resolves the host to check it and the real fetch resolves again, so a hostile domain can answer with a safe IP at check time and the metadata IP at connect time.
The capability was there; the narrow prompt never asked for it, and that is on the operator, not the model. These agents are good at the locally-correct short path: given a function to fix, they fix it convincingly and stop, and that polished, complete-looking answer is exactly what hides the missing systemic control. The prompt has to steer them toward the durable fix, by asking the broader question outright ("what is the platform-level mitigation, not just the code change?") or building it into the loop as its own step. Design the prompt for the leading solution, not the nearest one.

Harnesses and models

Claude Code and Codex are harnesses leveraging different models (here Opus 4.8 and gpt-5.4), and that difference helps in two ways:
  • Capability diversity. We saw this directly: on the same function, Claude Code flagged the IP-encoding bypass and Codex flagged the userinfo bypass, two different real gaps. Public reproductions show the same divergence on real CVEs, where one model reproduced a decades-old OpenBSD bug another missed entirely (see References). A heterogeneous pair finds more than either alone.
  • Boundary diversity. Vendors deliberately gate high-risk cyber capability, and they draw the line in different places. For authorized work on your own code, an independent second model reduces spurious stalls when one model is over-cautious about a legitimately-safe task (for example, "explain this auth bypass in my repo so I can patch it").
Figure 2: Claude Code is the hub: it calls Codex through the plugin (/codex:…) and drives the AgentCore sandbox (execute_code). Both harnesses reason on Amazon Bedrock; only candidate exploit code runs inside the sandbox.
Figure 2: Claude Code is the hub: it calls Codex through the plugin (/codex:…) and drives the AgentCore sandbox (execute_code). Both harnesses reason on Amazon Bedrock; only candidate exploit code runs inside the sandbox.and the same sandbox re-verifies against the patched code.

Running it by hand in Claude Code

The walkthrough above is one interactive Claude Code session: every step runs from there, including the Codex slash commands and the sandbox. Start with this version, since you see each step and can intervene. Here is the setup.

Prerequisites

The versions below are what these runs used; newer ones should work:
  • Claude Code 2.1+ and the Codex CLI 0.136+ (codex --version), on Node.js 18.18+ (these runs used Node 24).
  • An AWS account with Amazon Bedrock model access enabled for the models you intend to use (request access in the Bedrock console under Model access). These runs used Claude Opus 4.8 for Claude Code and OpenAI gpt-5.4 for Codex.
  • AgentCore Code Interpreter available in your Region, reached here through the AgentCore MCP server connected to Claude Code.
  • AWS credentials resolvable by both CLIs (environment, shared profile, SSO, or an instance/role).

1. A starter IAM policy

This is a starting point, not a least-privilege policy. It uses a wildcard action and Resource: "*" for the sandbox to keep the example short; before any real use, pin both down (see the note after):
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "Inference",
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "arn:aws:bedrock:*::foundation-model/*" },
{ "Sid": "Sandbox",
"Effect": "Allow",
"Action": ["bedrock-agentcore:*CodeInterpreter*"],
"Resource": "*" }
]
}
For real use, replace bedrock-agentcore:*CodeInterpreter* with the exact actions the loop calls (StartCodeInterpreterSession, InvokeCodeInterpreter, StopCodeInterpreterSession), scope both statements' Resource to specific model and code-interpreter ARNs, grant it only to the principals running the loop, and keep it off any role that can reach customer data.

2. Point the Codex CLI at Bedrock

Set this once in ~/.codex/config.toml (or a trusted project-level .codex/config.toml); the plugin inherits it automatically. This is the actual configuration these runs used, with the Region called out explicitly:
1
2
3
4
5
6
model = "openai.gpt-5.4"
model_provider = "amazon-bedrock"
model_reasoning_effort = "xhigh"

[model_providers.amazon-bedrock.aws]
region = "us-east-2"
Claude Code is pointed at Bedrock separately through its environment: CLAUDE_CODE_USE_BEDROCK=1, an AWS_REGION where your Claude model is available, resolvable AWS credentials, and (if you want to pin it) ANTHROPIC_MODEL set to the Bedrock model ID. If a Codex step comes back empty, try a different model with --model; completion reliability on the Bedrock endpoint varies between models.

3. Install the plugin and confirm

1
2
3
4
/plugin marketplace add openai/codex-plugin-cc
/plugin install codex@openai-codex
/reload-plugins
/codex:setup
When /codex:setup reports an amazon-bedrock provider, Codex is running on Bedrock. The Codex CLI controls that the safety story relies on, namely sandbox modes (read-only, workspace-write), approval policy, and command rules, are client-side and apply unchanged regardless of provider; Bedrock only decides where inference happens.

4. Connect the AgentCore sandbox

Stage 3 ("prove") needs a sandbox to run the proof-of-concept in. That comes from the AgentCore MCP server, which exposes the Code Interpreter as tools Claude Code can call. The command below launches it with uvx (from uv ), so install uv first, or swap uvx ... for however you prefer to run the awslabs.amazon-bedrock-agentcore-mcp-server package. Add it once:
1
2
claude mcp add bedrock-agentcore-mcp-server \
-- uvx awslabs.amazon-bedrock-agentcore-mcp-server@latest
After this, claude mcp list should show bedrock-agentcore-mcp-server as connected, and the session has the start_code_interpreter_session / execute_code tools. Now when you ask Claude Code to run a PoC in a sandbox (stage 3 below), it has somewhere to run it. Without this server, there is no sandbox and the prove step has nothing to call.

5. The commands you run

From inside the repository under review, in a Claude Code session:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. find: just ask Claude Code
review the changes for security issues

# 2. challenge: independent second model, read-only
/codex:adversarial-review challenge the SSRF guard in fetcher.py

# 3. prove: ask Claude Code to run a PoC in an AgentCore
# Code Interpreter session against an isolated, synthetic copy

# 4. patch: hand it to Codex, which edits the file itself
/codex:rescue fix the SSRF bypass with the smallest safe change; keep external URLs working

# 5. review: ask Claude Code to read the patch and adjust it if it falls short
# 6. re-verify: ask Claude Code to re-run the same PoC in a fresh sandbox
Steps 1, 3, 5, and 6 are plain requests to Claude Code; steps 2 and 4 are Codex slash commands. The wording is just a starting point: step 1 can be as open as "review the changes for security issues" (in our runs, that alone surfaced the bypass), and the sandbox steps run in the AgentCore session you connected above.

Automating the loop

You can run the whole loop unattended by handing the procedure to a headless Claude Code (claude -p) as a single prompt. The plugin's slash commands are interactive, but they are thin wrappers over the Codex CLI, so the agent can shell out to the same companion script and drive the AgentCore sandbox over MCP, becoming the orchestrator: it finds the issue, has Codex challenge it and write the fix, reviews that fix and adjusts it if needed, and runs the proof in the sandbox before and after.
The prompt gives the agent the commands but not the answers: it still decides the vulnerability, the questions it puts to Codex, and the proof-of-concept.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# discover the companion path (the version segment changes between releases)
export CODEX="$(ls "$HOME"/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs | tail -1)"

claude -p "Review this repo for security issues and run this loop on the most serious one:
1. FIND: state the issue (your own finding).
2. CHALLENGE: get an independent Codex review and reconcile it with yours:
node \"\$CODEX\" adversarial-review --wait \"<a focused question you write yourself>\"
3. PROVE: in the bedrock-agentcore sandbox, write a PoC and run it to confirm the
issue reproduces against the current code. Report REPRODUCED or SAFE.
4. PATCH: have Codex write the fix:
node \"\$CODEX\" task --write \"<a fix instruction you write yourself>\"
5. REVIEW THE PATCH: read it; if it fully addresses the finding, proceed; if not,
adjust the fix yourself and say what you changed.
6. RE-VERIFY: re-run the same PoC against the final patch in the sandbox. Stop the session.
Decide the vulnerability, the Codex prompts, and the PoC yourself; don't hardcode the fix."
\
--mcp-config agentcore-mcp.json \
--allowedTools "Bash(node $CODEX:*)" "Bash(git status:*)" "Bash(git diff:*)" \
"mcp__bedrock-agentcore-mcp-server__start_code_interpreter_session" \
"mcp__bedrock-agentcore-mcp-server__execute_code" \
"mcp__bedrock-agentcore-mcp-server__stop_code_interpreter_session" \
"Read" "Edit" \
--permission-mode default
agentcore-mcp.json points at the same AgentCore MCP server from the manual setup. Run this against a throwaway copy of the code, not your working tree: a headless agent with edit and Bash access touches what it can reach, so give it a directory with only the file under review.

Limitations

The sandbox does not run your real, deployed application. The agent rebuilds an isolated copy of the suspect code, stubs its dependencies, and runs that instead. It's an emulation, and emulations drift from the real thing in ways that matter here:
  • The agent rebuilds the code path, and may rebuild it wrong. If it misreads how the function is called in production, or stubs a dependency that does not behave like the real one, the test can show a bug that is not real or miss one that is. The result is only as trustworthy as that reconstruction.
  • It only sees the layer it isolated, which is a thin slice of a real security review. Checking one function in isolation says nothing about how auth, data flow, and configuration interact across the system. A control that lives in an API Gateway authorizer, middleware, a database query, or an IAM policy is invisible to a function-level test, so a "reproduced" verdict on the handler does not account for a control sitting one layer up.
  • It proves reproducibility, not exploitability. Showing the behavior fires against synthetic data is not the same as a working exploit against a defended, deployed system.
So treat a green or red verdict as a strong signal to act on, not as ground truth. When the emulation can't faithfully represent a finding, the loop should send it to a human rather than emit a verdict at all.
Given all that, what does it actually buy you? One real thing: it turns an unverified model finding into a reproduced one, and confirms the fix closes it. Plain AI review hands you a pile of candidates, some real and some not, and triaging them is the expensive part; a finding that fires against a runnable proof, and a fix that makes the same proof stop firing, is what's useful.

Conclusion

The value is in the loop, not any one model: two frontier models from different vendors working the same problem, one sharpening what the other found, and a sandbox that settles it by running the code rather than trusting either model's word. A second opinion plus a way to verify is what turns a suspect line into a confirmed-and-fixed finding.
On AWS the pieces line up with no extra plumbing: Claude Code and Codex on Bedrock for the review, AgentCore for the sandbox, in one account. The other thing the runs made clear is how much the prompt matters: the models had the knowledge to reach the systemic fix, but only surfaced it when the prompt asked the right question, so domain knowledge in how you steer them is as much a part of the loop as the models themselves.
And the whole loop is only as good as its verifier: a harness that reproduces production faithfully gives verdicts you can act on, while a rough approximation gives guesses. Our sandbox runs an isolated copy of one function; the richer version is to stand the whole agent up in an environment that mirrors prod and let it exercise the real paths. That is harder than it sounds, an isolated sandbox is by definition not production, and closing that gap (real dependencies, data, network, without the risk) is most of the work, but it is the direction that makes a loop like this worth trusting with more.
This particular check is narrow and fallible, one small slice of security work. But the shape behind it, frontier models that are getting sharper at finding real flaws, paired with a way to verify what they claim and to act on it, is the part worth watching. As those models keep improving, the open question is how much of security work, from review to fuzzing to exploitation, agents can credibly take on, and how much verification it takes to trust them when they do.

References and further reading

  1. Server-side request forgery background: OWASP, Server Side Request Forgery (owasp.org/www-community/attacks/Server_Side_Request_Forgery). On defending the instance metadata endpoint, AWS guidance on IMDSv2: docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
  2. On models that locate the right code but mischaracterize its impact, with reproduced CVE examples (OpenBSD, Botan, FreeBSD): Vidoc Security Lab, We reproduced Anthropic's Mythos findings with public models. blog.vidocsecurity.com/blog/we-reproduced-anthropics-mythos-findings-with-public-models
  3. Background on AI for security used while framing this post: MindFort, How good is Opus 4.8 for cybersecurity? (mindfort.ai/blog/how-good-is-opus-4-8-for-cybersecurity); UK AI Security Institute, Our evaluation of OpenAI's GPT-5.5 cyber capabilities (aisi.gov.uk/blog/our-evaluation-of-openais-gpt-5-5-cyber-capabilities).
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article