How to Run a Pentest with Two Claude AI Agents?

How to Run a Pentest with Two Claude AI Agents?

Lessons learned from automating pentesting with Claude Code AI

How to Run a Pentest with Two Claude AI Agents

Lessons learned from automating pentesting with Claude Code AI.


TL;DR: Two Claude agents, each on its own Kali VM, solved a PortSwigger lab in 3:50 and a full VulnHub machine ("Mr. Robot: 1", 3 flags + root) in 9:07, collaborating through a shared blackboard and stopping on their own the moment the objective was already solved. But getting there meant fixing half a dozen real bugs — and the most interesting finding wasn't about artificial intelligence, it was about systems engineering.

Full code: github.com/digilabmx/agentic-pentest-orchestration


"The AI went rogue" — no, not really

Over the past few months we've all read headlines about agents "hacking" systems on their own, models extorting users, AI going out of control. The reality — at least the one I built and broke several times in my own lab — is a lot less cinematic and a lot more interesting: this isn't a story about AI out of control. It's a story about tools, permissions, and orchestration.

A language model doesn't get access to a system by itself. It needs tools, credentials, and permissions someone exposed to it beforehand, and it always acts within an architecture a human designed. The real problem was never how "smart" the model is. It was governance, oversight, and control of the system around it. This article is the honest log of building that system: what worked, what broke, and why.

⚠️ Notice. Everything described here ran against explicitly authorized targets: my own VMs and PortSwigger Web Security Academy labs. None of this is an invitation to test anything without authorization — misuse of these techniques is the sole responsibility of whoever carries it out.


The architecture: two agents, one blackboard

The system has three layers, running across two different machines:

┌──────────────────────────────┐
│         WINDOWS HOST          │
│                               │
│     orquestador.py (GUI)      │
│   ┌─────────┐   ┌─────────┐       │
│   │Agente 1       │       Agente 2            │
│   │  (CLI)        │     (Browser)             │
│   └────┬────┘   └────┬────┘       │
│        └──────┬──────┘                │
│      PIZARRA COMPARTIDA                        │
│   (objeto en memoria, con lock)                │
└───────────────┬───────────────┘
                │ MCP (stdio)
          client.py × 2
                │ HTTP + JSON
       ┌────────┴────────┐
       ▼                 ▼
 ┌───────────┐     ┌───────────┐
 │  KALI #1         │     │  KALI #2         │
 │ server.py        │     │ server.py        │
 │  (Flask)         │     │  (Flask)         │
 │ nmap · curl      │     │ nmap · curl      │
 │ Firefox ·        │     │ Firefox ·        │
 │ xdotool          │     │ xdotool          │
 └───────────┘     └───────────┘

The model never touches Kali directly. Every action goes through: MCP tool → HTTP → Flask → real subprocess. That chain of layers is, on purpose, the only place where you can build in control, auditability, and — as you'll see below — an emergency brake.

Both agents can run the same phases (recon, enumeration, exploitation); one tends to work from the terminal, the other through a real browser using computer-use (clicks and screenshots, not a headless API). What connects them is the blackboard: a shared, in-memory board where either agent can post a finding and read what the other one posted.


Code box: the mechanism that actually matters

Of the hundreds of lines in this project, this is the one that carries the most weight. It's the Pizarra (blackboard) class, in its essential form:

class Pizarra:
    """Tablero de hallazgos compartido entre los agentes de una misma
    ejecucion conjunta. Thread-safe: cada agente corre en su propio hilo."""

    def __init__(self):
        self._lock = threading.Lock()
        self._hallazgos = []
        self._resuelto = None  # {"agente", "texto"} o None

    def agregar(self, agente, texto):
        with self._lock:
            self._hallazgos.append({"agente": agente, "texto": texto, ...})
            if self._resuelto is None and texto.strip().upper().startswith("RESUELTO:"):
                self._resuelto = {"agente": agente, "texto": texto}

There's nothing "AI" about this snippet. It's an if statement on a string. But that if is the difference between an agent that keeps working for 10 extra minutes on a challenge that's already solved, and one that stops in the very same second its teammate wins.

Why it matters: once _resuelto stops being None, the orchestrator checks that value before authorizing any tool call from the other agent (via a can_use_tool hook in the Agent SDK). It isn't a text instruction the model can choose to follow or ignore — it's code that runs before the model can act at all. I confirmed this in the logs: when Agent 2 posted RESUELTO: ("RESOLVED:") on the PortSwigger lab at 22:18:17, Agent 1's next tool call was rejected in the same second, with the literal message "The user doesn't want to proceed with this tool use... STOP". That wasn't a timing coincidence, and it wasn't the model "deciding" to check the blackboard — it was the hard interrupt cutting off execution mid-tool-call. It happened again, identically, in the Mr. Robot run.


Three ways to orchestrate (and why I picked the middle one... sort of)

  1. Chaos — loose agents, no memory between calls, no ordering guidance. Unpredictable behavior, impossible to reproduce or debug. It's the starting point of almost any improvised agentic demo.
  2. Hard pipeline — a state machine forces the order (recon → enum → exploit → report). Reproducible and auditable, but rigid: it doesn't adapt when the real objective doesn't match the script.
  3. Soft pipeline — memory, a blackboard, and a suggested, not mandatory, methodology exist. The agent can start wherever it wants, backtrack, skip phases — but it always has the option to check what's already known.

I picked the soft pipeline. And the first real finding was that "soft" doesn't mean it works on its own.


What went wrong (and is the part most worth reading)

I ran this system over and over against the same kind of target, documenting every real run. Here's the honest summary — not the pretty version:

RunWhat happenedRoot causeThe fix
1record_finding already existed as a real MCP tool... and got 0 usesThe orchestrator's prompt never mentioned it — exposing a tool isn't enough if the model doesn't know it existsAdd an explicit mention in the methodology prompt
2Cascading timeouts, one agent blocked by Anthropic's safety classifierA client/server timeout race plus an external content filter — neither one "our" bugPer-command dynamic timeouts; honest logging instead of a generic "cycles exhausted"
34 hangs of ~5 minutes on local commands, with no network involvedA recycled X11 window_id — the server was killing the wrong terminalVerify the terminal is still the current one before closing it
4The team declared "RESOLVED" in 7 minutes... with the lab not actually solvedAny finding with at least one block triggered the success state, with no evidence checkMandatory ESTADO: CONFIRMADO/PARCIAL/NO_RESUELTO ("STATE: CONFIRMED/PARTIAL/UNSOLVED") field, with explicit criteria
5Real, verified resolution, in 3:50The run-4 fix working as designed, plus the hard interrupt cutting off the teammate in time—

The pattern behind the whole table: the results depended a lot more on system design than on how "smart" the underlying model was.

And there's an important nuance in row 1 worth saying out loud, because it cuts against the easy narrative: the fix that actually got record_finding used was adding text to the prompt, not just exposing the tool. Hard mechanisms (like the interrupt in row 5) win when behavior is critical and binary. But for an agent to simply know something exists, sometimes you still have to spell it out in words.


The results, with real timings

PortSwigger — "Web shell upload via extension blacklist bypass"

3:50, start to finish. One agent identified the lab from the HTML <title>, uploaded an .htaccess (AddType application/x-httpd-php .l33t) followed by a one-line webshell with that unfiltered extension, and confirmed RCE with ?cmd=id. Verified across three independent layers: the command output, the flag read from the filesystem, and the platform's own "Congratulations, you solved the lab!".

Mr. Robot: 1 (VulnHub) — 3 flags, root included

9:07. robots.txt → known WordPress credentials for the lab → RCE via the theme editor → reverse shell with nc+mkfifo → an MD5 hash identified without john or hashcat (a one-line Python script did the job) → privilege escalation through a SUID binary (nmap --interactive) → root. The most interesting moment wasn't the win itself: it was that su robot failed three times in a row due to a shell-synchronization issue — and instead of insisting in a loop, the agent pivoted on its own toward the SUID vector. I didn't design that kind of self-recovery; it came from the model itself.

⚠️ The honest part almost nobody mentions in these demos: neither of these times is "superhuman pentesting speed." It's largely recalled knowledge, not live discovery. "Mr. Robot: 1" is one of the most thoroughly documented CTF machines out there — the model recognized the full methodology (robots.txt → WordPress → SUID nmap) before running a single command. Comparing 9:07 to a human solving it blind (the community estimates 1–3 hours for an intermediate skill level) is unfair to the human. Compared to someone who also already knows the walkthrough — TryHackMe estimates its own copy of the box at ~30 min, Root-Me at ~2h with at least one real logged attempt at 20 min — the gap still exists, but it's a lot less dramatic. That's the intellectually honest comparison.


The verdict

Still fiction:

  • An agent that solves full pentests, reliably and universally, with no human oversight.
  • That determines on its own, with no ambiguity, what's in or out of scope.
  • That operates with no tuning, monitoring, or infrastructure upkeep.

Already works today:

  • Sharing findings between agents and across runs, with structured, verifiable memory.
  • Stopping redundant work the moment the objective is met — through a code mechanism, not a prompt suggestion.
  • Verifying success against real evidence, not the model's own confidence in its own text.
  • Recovering from infrastructure errors through real observe-analyze-retry cycles.

This isn't science fiction. It isn't magic either. It's systems engineering applied to a new component — and like with any new system, most of the bugs weren't in the model. They were in the wiring.


Full code

Everything behind this article — client.py, server.py, orquestador.py, and the 10 real logs from these runs (including the ones that failed) — is public:

github.com/digilabmx/agentic-pentest-orchestration

The repo includes step-by-step instructions to run it in your own environment (VirtualBox + Kali + Windows), and the full technical notes with a log-line reference behind every claim in this article.


Author: Edu Quijano