My AI pentest agent reported 23 root shells. It had actually popped zero. A developer building an autonomous penetration-testing agent found that an LLM-driven system falsely reported 23 root shells when it had actually popped zero. The agent's heuristic for confirming breaches relied on string matches like "Shellcodes" in searchsploit output, leading to inflated success claims. By replacing the heuristic with evidence-based checks for actual shell markers, the agent now reports only three real root shells and honestly acknowledges failures. I've been building an autonomous penetration-testing agent — an LLM driving real tools nmap, masscan, hydra, Metasploit, searchsploit around a loop: recon a target, pick an exploit, fire it, decide whether it worked, move on. Everything below runs against a deliberately vulnerable Metasploitable VM in an isolated lab. Nothing here is a technique for attacking systems you don't own; it's a story about making an AI agent tell the truth about what it did. Because the first hard lesson was this: an LLM agent will happily report success it never achieved. One of my early runs produced a beautiful report — This post is the arc from that lie to an engine that now pops three real root shells, reports them honestly, and refuses to claim anything it can't prove. The validator — the component that decides whether an exploit worked — was doing something that looks reasonable and is catastrophic: python the original "did it work?" check if "login:" in output or "shellcodes" in output.lower : return "confirmed" Two independent failure modes fed this: searchsploit output. When you search Exploit-DB, the tool prints a header: Exploits: ... / Shellcodes: .... Every single search — pure intel, no exploitation whatsoever — contained the word "Shellcodes". So every port the agent looked at got marked breached. Banner text. Any service that echoed login: counted as a shell. The result was a report that was 100% adjectives and 0% evidence. "Confirmed." "High confidence." "Breached." All string matches, none of them a shell. The fix: evidence, not vibes I replaced the heuristic with breach confirmed — a function that only returns true when the output contains actual proof of code execution: SHELL EVIDENCE = re.compile r"uid=\d+\ a-z +\ . gid=\d+" , id 1 output re.compile r"root@ \w.- +: ~/ " , a root prompt ...markers a real shell produces, not ones a banner can fake def breach confirmed output: str - bool: return any p.search output for p in SHELL EVIDENCE A curated exploit that pops vsftpd 2.3.4's backdoor and runs id produces uid=0 root gid=0 root . That is a breach. A searchsploit table is not. The same run that used to claim 23/23 now says: 3 confirmed, 20 correctly unconfirmed. The 3 are real. That honesty — being willing to say "I tried and it didn't work" — is the single most important property of the whole system. Why it couldn't pop anything: the boring plumbing bugs Once the agent stopped lying, it exposed how little was actually landing. The culprits weren't clever — they were the unglamorous, silent kind that never throw a stack trace where you're looking: 1. The tool the model could never call. The model kept calling searchsploit with {"query": "vsftpd"}. The tool's schema required {"keyword": "..."}. The MCP layer hard-rejected every call as malformed before it ran — 20 times a run, each a silent 0-second non-event. Fix: accept keyword | query | search and drop the rigid required. 2. ANSI codes poisoning module names. msfconsole highlights your search term with color escape codes: exploit/unix/\x1b 45mftp\x1b 0m/vsftpd 234. My parser dutifully captured those bytes as part of the module path, so every selected module failed to load. Gated Metasploit was at a 0% success rate for a reason that was invisible in plain-text logs. Fix: strip ansi before parsing. 3. Version strings masquerading as products. nmap reports RPC services version-first: 2 RPC 100000 . My fingerprint parser grabbed 2 as the product name and fed "2" to Metasploit as a search term. Fix: a leading-digit guard so a version can never be mistaken for a product. 4. A 5-minute hang from one timeout constant. call model reused the 300-second tool timeout, so a single wedged local-LLM request stalled the entire engagement for five minutes. Fix: a separate 90-second model timeout. 5. A sandbox that ate its own output. The code-exec sandbox raised TimeoutExpired without printing the EXIT contract the parser expected, so a slow exploit surfaced as an opaque "No EXIT marker" after burning the whole wall clock. Fix: catch the timeout, emit a clean EXIT 124 with partial output, drop the default wall to 60s. None of these are interesting. All of them were the difference between "works" and "silently does nothing." Collectively they were kneecapping the agent while the logs looked fine. Why it fired garbage: relevance vs. relevance With the plumbing fixed, the agent started reaching Metasploit — and immediately did something absurd. Watch what it picked: Port Service fingerprint Module it chose Rank 23 Linux telnetd exploit/linux/http/asuswrt lan rce excellent 513 rlogin login exploit/windows/misc/ais esel server rce excellent 5900 VNC exploit/linux/misc/igel command injection excellent A router RCE against telnet. A Windows exploit against a Linux rlogin service. Every one "excellent"-ranked, every one nonsense. The reason: msfconsole search