Skip to content
All insights

An offensive agent will not respect a scope that isn't in the network

29 min readAI SecurityHAVOCAgents
Cover: an agent's scope can live in the prompt, in harness policy, in identity or in the network, and only the last two layers are binding

In two weeks we have twice read that the AI escaped. First OpenAI, on 21 July. Then Anthropic, on the 30th. The headlines were the predictable ones: models breaking out of their cage, Skynet, a bill with the words kill switch in the title introduced two days after the first story broke.

The original reports are worth reading, because they say something else. And they say two different things from each other, which is what almost no coverage has separated.

Comparison of the two incidents: OpenAI got out through the only permitted network hop by exploiting a zero-day in the package proxy, while at Anthropic the sandbox was never sealed and the model simply had internet

Anthropic: nothing had to be escaped

Anthropic reviewed 141,006 evaluation runs and found three incidents, spread across six runs, in which a model reached the real systems of three organisations. The cause sits in one sentence of their own report: the prompt told Claude it was in a simulation with no internet access, and "due to a misunderstanding between us and our evaluation partner, this was not the case, and internet access was available."

That is the whole thing. The environment was operated by Irregular, a third party that specialises in building offensive evaluation ranges, and the machines had internet egress without either side knowing. The model did what it was asked, inside the perimeter it was given, which happened to be the entire world.

A second sentence is worth quoting in full, because it defuses half the headlines: Claude compromised that infrastructure "using basic techniques, such as exploiting weak passwords and unauthenticated endpoints. It did not find or exploit any complex vulnerabilities." And, explicitly, "in none of these situations did Claude exfiltrate itself or deliberately attempt to escape its test environment." Anthropic itself classifies it as "closer to a harness and operational failure than a model alignment failure."

Two days later, a mainstream headline read "Claude AI has gone dangerously rogue, Anthropic says." Anthropic had said the exact opposite, in writing.

OpenAI: out through the one permitted hop

The OpenAI case does not fit in the same box. Here the control was in place, and it still gave way.

The ExploitGym environment had no direct internet access. It had a single egress path: a self-hosted package registry cache proxy, so dependencies could be installed. That is precisely the architecture any serious containment guide recommends: no default route, one permitted intermediary.

The models attacked the intermediary. In OpenAI's words, they "identified and exploited a zero-day vulnerability (which we've now responsibly disclosed to the vendor) in the package registry cache proxy." They later named the product: JFrog Artifactory. And this does not rest on the affected party's word alone, because JFrog confirmed it publicly on 27 July and shipped the fix in Artifactory 7.161.

From there, Hugging Face's forensic reconstruction is the best public document that exists today on how a loose offensive agent behaves: around 17,600 recovered actions between 9 and 13 July, with "self-migrating" command and control staged on public services and no bespoke implant at all. Initial access came through two application bugs in its dataset pipeline, and from there a service account token, node credentials through the metadata service, 136 keys from a secrets object and GitHub tokens with write permissions.

And the detail that explains everything: OpenAI acknowledges that "these deployment safeguards were intentionally not enabled during this evaluation because it was aimed at testing cyber vulnerabilities." They ran the models with cyber refusals reduced and without human monitoring for days.

So this is not the same incident twice. At Anthropic the control was never in place. At OpenAI the control was in place and turned out to be software with a zero-day inside it. Only the second requires real offensive capability, and even so the operational conclusion is the one Dan Guido of Trail of Bits summed up: "a containment failure with the safeties turned off."

The domestic version, which is also ours

Something similar happened to us not long ago, at a different scale. While working on automating offensive agents we set up an internal competition between our own agents, with a platform that kept the ranking. The platform sat on the same network as the challenges. One of the agents reached the scoreboard and got to see how its competitors had solved the tests.

The agent did nothing brilliant. It did exactly what we asked, inside the perimeter we gave it. The segmentation failure was ours, just as Irregular's was Irregular's. If those three have anything in common, it is that scope was written somewhere it does not get enforced.

Scope lives in four places and only two are binding

When somebody says "the agent has its scope defined," it is worth asking where.

Four layers where scope can live: system prompt and harness policy are declarative, identity and credentials and network and kernel are binding

The first two layers are declarative. A prompt is a request: the model can reason against it, and in Anthropic's first incident it reasoned that the real company "must be part of the exercise" and carried on. Harness policy is better, because it is a per-action control, but OpenAI marks its limit: long-running models can "learn the blind spots of an approval system and work around it." Anthropic's documentation puts it another way about its classifier: it is a per-action control, not an isolation boundary.

The last two are binding. A credential with a minute-scale TTL bounds what any traffic that does leave can do. A sandbox with no network namespace means the request never leaves the machine in the first place.

The practical rule: if scope only exists somewhere the model can read it, the model can argue with it.

Three ways out without breaking anything

Anthropic's three incidents are not the same failure repeated. They are three distinct archetypes, and only one is caught by a well-built network allowlist.

Three archetypes of scope escape: name collision, outbound artifact and unbounded volume, each with its corresponding control

The second one deserves a pause. Claude found setup instructions referencing a PyPI package that did not exist, so it created and published it. It was available for roughly one hour, and in that hour it was downloaded and executed on 15 real systems, including the scanner of a security company that treated PyPI packages as safe by default.

Think about it from the control side: there was no outbound connection to any victim. The agent published an artifact on a legitimate service and the victims came to fetch it. No CIDR egress filter, no DNS policy and no TLS-terminating proxy stops that. Egress control governs where the agent connects, not what the agent leaves published out there.

How you contain an agentic environment today

This is the state of the art, ordered by what is still standing when the model decides the rule does not apply to it.

Seven-layer containment stack: kernel boundary, no network namespace, deny-by-default egress, credentials outside, quotas and stop, evidence and classifiers

The kernel boundary is a microVM or gVisor for untrusted work. Bubblewrap on Linux and Seatbelt on macOS are a real and cheap uplift, but they share the kernel, and Anthropic's documentation admits it: a kernel vulnerability could theoretically enable escape. They put it well: contain at the environment layer first, then steer behaviour at the model layer, because hypervisors and syscall filters have survived far more adversarial attention than anything you are going to build.

Removing the network namespace is what makes the proxy mandatory rather than conventional. In Anthropic's sandbox runtime, on Linux the process's namespace is removed entirely and traffic can only leave through Unix sockets to proxies living outside. It matters because HTTP_PROXY and HTTPS_PROXY are a gentlemen's agreement: a binary that ignores them gets out on its own, and Node's fetch() does not honour them unless you ask it to.

Deny-by-default egress has four known ways of falling over, and all four actually happened. Parser differentials: in Claude Code, a SOCKS5 request to attacker.example\x00.allowed-domain.com passed the JavaScript check because .endsWith() saw the permitted suffix, while getaddrinfo() truncated at the null byte. Empty-list inversion: CVE-2025-66479, where the documentation said an empty array meant "no network" and the code checked allowedDomains.length > 0. Legitimate domain as a channel: Anthropic documents a case of its own where workspace files were exfiltrated through api.anthropic.com, which was allowed. And domain fronting, if you decide on the hostname the client declares rather than on decrypted traffic. The conclusion is that an FQDN allowlist without TLS termination is a control against misconfiguration, not against an adversary.

And the one most people skip: DNS. Unit 42 published in April the bypass of AWS Bedrock AgentCore's sandbox mode, where TCP and UDP were blocked but DNS resolution to arbitrary domains was not. With your own authoritative server, every query is exfiltration and every response is inbound: a full C2. The cleanest public reference configuration is GKE Agent Sandbox, with runtimeClassName: gvisor mandatory and explicit egress blocks to RFC 1918, to the cluster's internal DNS and to 169.254.0.0/16, which is the metadata endpoint and the standard pivot.

The three remaining layers compress quickly. The agent does not hold credentials: either the proxy injects them into the outbound request, or the sandbox sees a sentinel the proxy swaps only towards declared destinations, with short-TTL per-task identity underneath. Quotas and the stop switch live outside the agent's control plane, because if the agent can suppress its own reporting, the circuit breaker that depends on that reporting does not exist. And evidence uses OpenTelemetry's GenAI conventions, with a trap: content capture is off by default, so the factory configuration produces audit trails without the payloads you would need in a forensic investigation.

Classifiers come last, and the datapoint that sets the priority order is from an internal Anthropic test: when the malicious instructions were pasted by the user themselves, credential exfiltration succeeded 24 times out of 25. There is no model-layer defence for that. And if your plan B is human approval, Anthropic's other number disarms it: users approved roughly 93% of permission prompts.

What you actually build each layer with

That stack gets described far more often than it gets configured. These are the pieces that exist today for each layer, and what is worth knowing before you pick one.

LayerPiecesWhat to know before you choose
Kernel boundarygVisor (runsc), Kata Containers, Firecracker, Cloud HypervisorgVisor filters syscalls in user space and is operated like any other container runtime. A microVM isolates through virtualisation and boots a little slower. Both are real boundaries.
Lightweight isolationBubblewrap, Landlock, seccomp-bpf, sandbox-exec on macOSThey share the kernel: they reduce the damage, they do not bound it. Good enough for the harness, not for hostile workloads. Landlock is the only one an unprivileged process can apply to itself.
Network namespaceunshare -n, veth between namespaces, --network=none in Podman and Docker, Anthropic's sandbox runtimeThis is what turns the proxy into a requirement. Without it, HTTP_PROXY is a suggestion.
Egressnftables with interval sets, Cilium with toFQDNs, an Envoy egress gateway, Squid with SSL bump, mitmproxyWith no TLS termination you are deciding on the hostname the client declares. Stock Kubernetes NetworkPolicy knows nothing about FQDNs, only CIDRs and pod selectors.
DNSCilium's DNS proxy, CoreDNS with an allowlist, your own resolver inside the namespaceIf resolution leaves unfiltered, the rest of the egress layer is decorative. This is exactly the Bedrock AgentCore failure.
IdentitySPIFFE/SPIRE, Vault dynamic secrets, cloud workload identity, RFC 8693 token exchangeThe transport supplies the credential, not the agent. And block 169.254.169.254 explicitly, because the metadata endpoint is the credential nobody remembers exists.
Per-action policyOPA with Rego, Cedar, harness hooks, MCP gatewaysAn engine outside the reasoning process, which is what Google and MITRE are asking for. It is still a per-action control.
Quotas and stopcgroup v2 through systemd-run, counters in BPF maps, a token budget in the LLM gatewayThe counter has to live somewhere the agent cannot write.
EvidenceOpenTelemetry's GenAI conventions, Falco, Tetragon, auditdApplication traces and kernel traces tell different stories, and a defensible report needs both.
ClassifiersPrompt Guard, Llama Guard, NeMo Guardrails, Prompt ShieldsLast step, with that 24 out of 25 in mind.

The decision that orders every other one is whether you have an orchestrator. On Kubernetes the reference configuration is GKE Agent Sandbox's, and it fits in two objects: one sets the kernel boundary, the other sets scope.

# the workload: kernel boundary, no service account token, no free capabilities
apiVersion: v1
kind: Pod
metadata:
  name: eng-4417
  labels: { app: agent, engagement: "4417" }
spec:
  runtimeClassName: gvisor
  automountServiceAccountToken: false
  containers:
    - name: agent
      image: registry.internal/kaix/agent@sha256:...
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }
      resources:
        limits: { cpu: "2", memory: 4Gi, ephemeral-storage: 2Gi }
---
# scope: in Cilium, one egress rule existing is enough for everything else to be denied
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: scope-4417
spec:
  endpointSelector:
    matchLabels: { engagement: "4417" }
  egress:
    - toEndpoints:
        - matchLabels:
            "k8s:io.kubernetes.pod.namespace": kube-system
            "k8s:k8s-app": kube-dns
      toPorts:
        - ports: [{ port: "53", protocol: ANY }]
          rules:
            dns:
              - matchName: target.client.example
    - toFQDNs:
        - matchName: target.client.example
    - toCIDRSet:
        - cidr: 203.0.113.0/24

The dns rules in the first block are the part most people skip: without them the pod resolves whatever it likes, and toFQDNs only works because Cilium's DNS proxy sees the answers and populates the permitted IPs from them. Both matchName entries have to say the same thing, and if either one becomes a matchPattern you have opened the door discussed above.

With no orchestrator the logic is identical, in three commands and no YAML.

# 1. its own network namespace, with a veth as the only way out
ip netns add eng-4417
ip link add veth-eng type veth peer name veth-host
ip link set veth-eng netns eng-4417

# 2. deny by default on the host side, where the agent has no say
nft add table inet agents
nft add chain inet agents fwd '{ type filter hook forward priority 0; policy drop; }'
nft add set inet agents scope-4417 '{ type ipv4_addr; flags interval; }'
nft add element inet agents scope-4417 '{ 203.0.113.0/24 }'
nft add rule inet agents fwd iifname "veth-host" ip daddr @scope-4417 accept

# 3. ceilings that do not depend on the process honouring them
systemd-run --scope --collect \
  -p MemoryMax=4G -p CPUQuota=200% -p TasksMax=512 \
  ip netns exec eng-4417 /usr/local/bin/agent

Three details decide whether this holds. The filter lives on the host side of the veth, so the agent can hold CAP_NET_ADMIN inside its own namespace and still not reach it. The forward chain is global, so on a machine that forwards anything else you scope by iifname instead of leaving a bare drop policy. And the systemd-run ceilings are cgroup ceilings, not scheduler checks: they count whether or not the model feels like counting.

For a coding agent, that is the finished job. For ours it is not, and the reason fits in one YAML line: drop: ["ALL"] takes away the CAP_NET_RAW a SYN scan needs. The moment you hand that capability back, socket-level control stops being enough, and that is precisely the conversation in the next section.

What none of these pieces gives you is the link between the engagement and the list you just loaded. They all accept a set of CIDRs and names; none of them knows where it came from, who authorised it, or whether it will still be valid an hour from now. That part you write, and it is what turns a filter into an auditable control.

Where eBPF fits, and where it doesn't

The stack above has a problem if you apply it as-is to an offensive agent: layer 2 does not work for us. Removing the network namespace and handing over a Unix socket to a proxy works for a coding agent, which only needs to speak HTTP to a package registry. Ours needs real L3 and L4 against the target: SYN scans, ICMP, hand-crafted packets. You cannot hand nmap a proxy. So enforcement has to drop to somewhere that decides per destination while permitting arbitrary protocols, and that is where eBPF earns its place.

That said, the attach point you would reach for first is the wrong one.

eBPF attach point matrix: what cgroup/connect4, cgroup_skb egress and tc/tcx on the veth each see for TCP with connect, UDP with sendto, raw sockets and AF_PACKET

BPF_CGROUP_INET4_CONNECT looks perfect: it decides at connect(), before a packet exists, and it is per-cgroup. The problem is that it is not a generic connect() hook. It is dispatched only through the pre_connect callback in struct proto, and in net/ipv4/af_inet.c both inet_dgram_connect() and __inet_stream_connect() gate on that pointer existing. Only tcp_prot, udp_prot and ping_prot define it. raw_prot does not, so a raw socket never fires the hook, not even calling connect().

And there is a second step down. AF_PACKET has no cgroup hooks and transmits via dev_queue_xmit(), which does not pass through ip_finish_output(), where BPF_CGROUP_RUN_PROG_INET_EGRESS lives. Raw ethernet is invisible to connect4 and to cgroup_skb; only tc sees it. Raw IP sockets are caught by cgroup_skb, because raw_send_hdrinc() ends up going through ip_output. And unconnected UDP fires BPF_CGROUP_UDP4_SENDMSG under a && !connected guard, so covering it means attaching both. Add an operational trap: cgroup_skb's scope is the socket's cgroup, fixed in sk_cgrp_data at creation, so moving a process between cgroups does not move the sockets it already had open.

Here is where it closes. Both SOCK_RAW and AF_PACKET require CAP_NET_RAW, so for a normal workload the connect4 gap is only reachable if you over-privileged the sandbox, and the right answer is not to grant the capability. But a pentesting agent needs CAP_NET_RAW: it is what a SYN scan requires. Nmap's manual says it plainly, -sS never calls connect(), and on top of that it picks raw IP or raw ethernet at runtime. The capability that would make socket-level control sufficient is precisely the one your product cannot take away. The only enforcement point that holds is tc/tcx on the veth of the agent's namespace.

In exchange, eBPF gives you three things there that nftables does not do as well. The scope list lives in a BPF_MAP_TYPE_LPM_TRIE, built literally for prefixes, where bpf_map_update_elem() replaces entries atomically and an ARRAY_OF_MAPS lets you swap the whole trie at once without an in-flight program seeing a torn state. The verdict comes out tagged with the cgroup, and from there the task and the authorisation record. And cutting connectivity is a write to a map the host owns, not the agent.

For DNS, the reference implementation is Cilium, which runs its own proxy and uses observed responses to populate the allowed IPs of toFQDNs, with matchName for exact names and matchPattern for wildcards. A caveat almost nobody mentions: matchPattern: "*.example.com" restricts which names resolve, not what is encoded inside them, so it still permits exfiltration through subdomain labels. The real anti-tunnel control is dropping to matchName for known hosts.

The limits of eBPF

eBPF is not an isolation boundary. It runs in the same kernel you are trying to contain the agent inside. It does not replace layer 1, it complements it.

Taking CAP_BPF away from the agent is not enough, and this catches people out. The capability governs load time: CAP_BPF since Linux 5.8, plus CAP_NET_ADMIN for the cgroup types we care about. But since that same 5.8, attaching and detaching cgroup programs checks no capability at all: all you need is to open the target cgroup2 directory. So the real protection is twofold: attach from a cgroup above the agent's with NONE flags, which block any program in the whole subtree, and do not give the container the host cgroupfs or its cgroup namespace.

The verifier has a history, and it is not history. CVE-2020-8835, CVE-2021-3490 and CVE-2023-2163 are the three reference local privilege escalations, and of the seventy CVEs matching "bpf verifier" in the NVD, around forty-seven are from 2024 onwards. The switch is kernel.unprivileged_bpf_disabled, where writing 1 cannot be undone without a reboot and 2 can; since Linux 5.16 the upstream default is 2.

Inside gVisor there is no eBPF, and it fails in the worst possible way: bpf() is listed as partial support, the Sentry accepts BPF_PROG_LOAD and BPF_PROG_ATTACH, everything else returns EINVAL, and the documentation says loaded programs have no effect. You can load your policy, get no error, and be filtering nothing.

Before the first request: verify ownership

There is one control that appears in no generic stack and is mandatory in offensive security: checking the target belongs to whoever the client says, before touching it. Anthropic's first incident is the perfect example, because a fictional company name matched a real, live domain. If scope had been expressed as CIDRs and domains with proof of ownership, there would have been no incident.

The mechanisms are cheap and standardised: RFC 9116 security.txt at /.well-known/security.txt, machine-parsable, which doubles as a pre-flight check and as the notification channel if you do touch something you shouldn't have; a DNS TXT record carrying a platform token, the same pattern as ACME domain validation; and RDAP to match ranges and domains against the client's legal entity. Plus a rule that gets forgotten: re-resolve DNS continuously, because a host that starts pointing at a CDN or shared hosting mid-engagement has to drop out of scope on its own.

The other half of the environment: the CLI and the tools

Containing the machine is half the job. The other half is the tool catalogue you put in front of the model, and mistakes there are expensive because they do not look like security mistakes, they look like API design.

A bash tool is a tool with no limit. The day you expose a generic shell, your catalogue stops being the one you wrote and becomes everything installed in the image, plus everything the agent can install. The alternative is wrapping each binary in a tool with a schema: typed arguments, enumerations instead of free strings, and execution through execve with an argument list, never by concatenating a command line. No shell means no metacharacters, and no metacharacters removes half a class of escapes.

And the target is not a free-form argument. This is the design decision that changes the most:

class PortScan(BaseModel):
    """Port scan against a target in the engagement."""
    target: int = Field(description="Index of the target in the authorisation object")
    profile: Literal["top-100", "top-1000", "full"]
    timing: Literal["T2", "T3"] = "T3"

def port_scan(args: PortScan, eng: Engagement) -> Result:
    host = eng.targets[args.target].ip          # the model never writes an IP
    return run(["nmap", "-sS", f"-{args.timing}", *PROFILES[args.profile], host])

The agent asks for target number three, it does not write 203.0.113.7. The difference is where scope gets checked: in the first version it is checked once, when the engagement is built, and an out-of-range index is a program error; in the second you have to validate a string on every call, which is exactly where the \x00 and .endsWith() parser differentials showed up.

The catalogue changes per phase. Recon, exploitation and reporting are not three steps of the same session: they are three sessions with different catalogues, different credentials and different traces. Reporting needs no network path to the target, and recon needs no state-changing tools. That is the operational reading of the rule of two, and you implement it by trimming the catalogue, not by asking the model to restrain itself.

Tool descriptions are input, not configuration. They go inside the model's context, so an MCP server that changes a description changes the agent's behaviour without touching a line of your code. What you need is to pin every definition by hash when it is approved, and to require approval again when the hash changes.

On frameworks it pays to be precise, because each one covers a different slice and none of them covers the one people assume.

FrameworkWhat it does solveWhere it stops
LangGraphinterrupt() over a checkpointed graph: it halts before a sensitive action and resumes with the human decision stored in the state, not in process memoryThe state is written by the same process that runs the tools
Claude Agent SDKallowedTools, permission modes, PreToolUse hooks that can deny, and a canUseTool callback where OPA or Cedar plugs inIt is harness policy, with the limit OpenAI puts on it: a long-running agent learns its blind spots
OpenAI Agents SDKInput and output guardrails, per-tool approval and traces of the full sessionSame thing: a per-action control, inside the same process
Pydantic AIArguments validated against a schema before they reach your function, with a model retry when validation failsIt validates the shape of the argument, not the legitimacy of the destination

None of the four is an isolation boundary, and none of them claims to be. They are layer 2 from the diagram at the top: better than a prompt, which is why they deserve to be configured properly, but declarative. The question that ranks them is always the same: if the agent's process could run arbitrary code, would this control still be standing? With an SDK hook, no. With the network namespace, yes.

That leaves the catalogue you did not write. A third-party MCP server is code running in your environment that decides what text enters the model's context, so treat it as a hostile dependency: version pinned by digest, its own sandbox, no engagement credentials, and a gateway in front logging every call. In an audit, a remote MCP server you do not control is a third party's infrastructure sitting inside an engagement someone else signed.

The layer that is not in the stack: the protocols

Everything above contains the machine. It does not contain what the agent talks to, and that is the other half of the surface.

MCP, the protocol an agent uses to talk to its tools, enforces nothing by design and says so: it cannot impose its security principles at the protocol level, so implementors "SHOULD". The one thing that is a MUST and is genuinely a boundary is audience binding: the client includes RFC 8707's resource parameter in authorization and token requests, the server validates the token was issued for it, and "MCP servers MUST NOT accept or transit any other tokens." That is the control that stops one engagement's token reaching another engagement's tooling.

What does not exist is tool definition integrity. The Tool object carries name, description, schemas and annotations, and not a single hash, version or signature field. The spec acknowledges the tool set "MAY change over time" but does not require re-approval when an already-approved description changes, and the July 2026 revision adds ttlMs and cacheScope so shared intermediaries can cache those definitions. An unsigned, cached, shared definition is a rug pull with a TTL. MITRE catalogues it as AML.T0110, AI agent tool poisoning, with three sub-techniques separating the definition, the implementation and the runtime response.

In A2A, the agent-to-agent protocol, the Agent Card may be signed with JWS, but signing is a MAY and verification a SHOULD: nothing requires rejecting an unsigned card. And across the specification's 3,600-plus lines there is not one warning that Agent Card content is untrusted input, even though skill descriptions and examples end up inside a model's reasoning context. MCP at least warns that annotations must be considered untrusted; A2A has no equivalent.

That this is not theoretical comes from MITRE ATT&CK's own catalogue, which records campaign C0062 as the first AI-orchestrated espionage operation, leaning on "MCP-accessible penetration testing infrastructure" for persistent command execution. That is exactly our product category, in the other side's hands.

The academic design patterns are not built for this

The reference work on securing agents against prompt injection is "Design Patterns for Securing LLM Agents against Prompt Injections," June 2025, with fourteen authors across ETH Zurich, Google, Microsoft, IBM and EPFL. Its central principle: once an agent has ingested untrusted input, it must be constrained so that it is impossible for that input to trigger consequential actions. Six patterns follow, and all six share one assumption: that you can separate planning from untrusted data. That assumption breaks precisely in our use case, because what you exploit depends on what you found. CaMeL, Google DeepMind's capability-based design, names the limitation and owns it: it calls it "data requires action."

Meta's rule is stricter still.

Meta's Rule of Two properties applied to an offensive agent: processes untrusted input, reaches sensitive systems and changes state or reaches out, plus the split into recon, exploitation and reporting sessions

Its "Agents Rule of Two," October 2025, says an agent should satisfy no more than two of these three properties within a session: processing untrustworthy inputs, reaching sensitive systems or private data, and being able to change state or communicate externally. A pentesting agent is all three at once. Meta itself says what to do then: the agent may not operate autonomously and requires, at minimum, human approval or another reliable means of validation.

You can read that as a specification. What the patterns tell you is where to put the human and where to cut the session. It runs in the same direction as the term OWASP coined this year, least-agency: avoid unnecessary autonomy, because deploying agentic behaviour where it is not needed expands the surface without adding value.

And there is a convergence that repeats across all the recent literature. Google formulates it as policy engines operating "outside the AI model's reasoning process." MITRE formulates it as mitigation AML.M0033: "validation should be performed external to the AI agent." Four independent sources, one sentence: the model cannot be the thing that decides whether something is in scope.

What the frameworks say, and what they don't

The frameworks have moved a lot in the last eight weeks, and knowing what to cite matters.

Timeline from June to August 2026: CCN-CERT BP/36, the Digital Omnibus, the Hugging Face intrusion, the OpenAI and Anthropic disclosures, the CREST accreditation and MITRE ATLAS mitigation AML.M0035

OWASP published the Top 10 for Agentic Applications in December 2025, with codes ASI01 through ASI10. One detail: the example scenario for ASI02 is a security automation agent chaining PowerShell, cURL and internal APIs such that endpoint monitoring sees neither malware nor exploit. OWASP already models our product category as a threat. Fairly. That vocabulary, together with ATLAS, is also the format your evidence is expected to arrive in; we use it to label verdicts in DELIA.

MITRE ATLAS is where the most useful development sits. On 31 July 2026, one day after Anthropic's disclosure, ATLAS added mitigation AML.M0035, "AI Red Team," which requires rules of engagement covering authorised systems, accounts, data, techniques, test windows, resource limits, escalation, evidence handling and stop conditions, plus the subsequent removal of test accounts, modified data and installed software. That is not compliance prose: it is a machine-checkable specification.

Rules-of-engagement elementHow it actually gets enforced
Authorised systemsCIDR and domain allowlist with proof of ownership, enforced at egress
Accounts and dataPer-engagement credentials, short TTL, outside the sandbox
TechniquesPer-phase tool allowlist, destructive ones behind approval
Test windowsTime window enforced in the infrastructure, not in the planner
Resource limitsTarget, action and spend ceilings per run
Escalation and stopSwitch outside the agent's control plane
Evidence handlingPer-action trace carrying the policy verdict next to the call
Artifact removalInventory of what was created during the engagement, plus verified cleanup

That last row is, once again, the PyPI package case. An agent that creates things needs to know what it created.

On the regulatory side, CREST launched its AI-enabled penetration testing accreditation on 28 July 2026, three days before Anthropic's disclosure, and what it examines is exactly this: how AI is incorporated into the methodology and how it is kept within an engagement's agreed scope and rules. Its prior research carries a line that sums up where the industry is: "The AI guard rail is me." In Spain, the CCN-CERT published BP/36 on offensive AI in June, covering supervision, traceability and access control in agent-based environments.

On the EU AI Act a note of currency is due, because on 8 July 2026 Regulation (EU) 2026/1744, the Digital Omnibus, was adopted, delaying high-risk obligations from 2 August 2026 to December 2027 and August 2028. What remains in force and is directly relevant here is Article 55(1)(a), applicable since August 2025, the only point in the regulation requiring "conducting and documenting adversarial testing."

Then the legal plane, which people underestimate. Article 197 bis of the Spanish Criminal Code punishes with six months to two years access to a system "circumventing the security measures in place and without due authorisation." Authorisation is the element that negates the offence, which is why proof that every request went to an authorised target is not a nice feature, it is the defence. Add that if the accidental victim is an essential or important entity you have manufactured a NIS2 incident for them, and that 42% of companies already carry AI-related exclusions in their cyber insurance.

How KAIX HAVOC does it

KAIX HAVOC is our AI-assisted offensive security platform. Scope is not a criterion the agent interprets: it is an object that gets compiled and enforced underneath it.

How scope gets down to the network in KAIX HAVOC: signed authorisation, proof of ownership, compilation to an LPM trie, enforcement at tc/tcx, ceilings in BPF maps, evidence with verdict, and teardown with inventory

An engagement starts with an authorisation object signed by the client: CIDR ranges, domains, time window and permitted technique classes. Nothing enters that object without first clearing the ownership pre-flight, which resolves a DNS TXT challenge or verified security.txt and checks RDAP against the legal entity. A domain that fails never gets compiled, which makes the name collision from Anthropic's first incident structurally impossible: the scenario could never have named a target that was not the client's.

That object is compiled to an LPM_TRIE loaded into a tc/tcx program attached to the veth of the agent's namespace, deny by default. It is the point that sees everything leaving, regardless of whether the agent used a TCP socket, a raw one or AF_PACKET, which is exactly what you need when the tooling holds CAP_NET_RAW because it has to SYN scan. A resolver re-resolves in-scope names throughout the engagement and updates the trie live; when a host starts pointing at shared infrastructure, the entry disappears with nobody intervening. Whole-scope changes swap the trie through ARRAY_OF_MAPS, so an in-flight program sees the old one or the new one, never an intermediate state.

Ceilings are counters in BPF maps, not checks in the planner: targets touched, actions emitted, time window. The 9,000-target scan from Anthropic's third incident would have died in the first hundred, and it would have died in the kernel, not in an if the model could reason about. The stop is written to that same map from the control plane, which lives outside the sandbox: the agent cannot suppress its own signal because it has nothing to do it with. It holds no CAP_BPF, has no host cgroupfs and shares no cgroup namespace, so it can neither load its own programs nor detach ours.

Target credentials never enter the sandbox. Connectivity runs over a VPN and an SSH jump host, and secrets are injected by the transport layer, with per-engagement identity and short TTL. Above that, every plan passes a semantic judge before running and there are human approval checkpoints at the sensitive stages, with the session split into recon, exploitation and reporting, which is the direct answer to the Rule of Two.

Evidence leaves with the verdict attached to the action: every tool call is recorded next to the policy decision that permitted it and the authorisation-object entry that covered it, labelled with OWASP and ATLAS. That is what turns a finding into a reproducible recipe and, if it ever comes to it, what demonstrates to a court that every request went to an authorised target. And at teardown, an artifact inventory collects everything the agent created outside its own environment, from test accounts to published packages, and cleanup is verified before the audit is called done.

What we take from it

An offensive agent is, by definition, a system you have asked to find the path nobody anticipated. That works just as well towards the inside of the scope as towards the outside. The model is not what makes the difference. Where you wrote the boundary is.

If scope lives in the prompt, the model can reason against it. If it lives in harness policy, it can learn to route around it. If it lives in the network and the kernel, the request never leaves the machine, and whatever the model reasoned is irrelevant.

It is the same thing we have been saying about client-side validation for twenty years. The only difference is that now the client argues back.

If you run agents with the ability to act on real systems, your own or your clients', at KAIX LABS we design and review guardrails, sandboxing and real-time monitoring for LLM ecosystems and agentic workflows. Tell us what you have running and we'll look at it in 30 minutes.