DEV Community

Yev B
Yev B

Posted on AI-assisted

Containing a rogue AI agent in 19.65 seconds — and proving it

A prompt is not a security control.

That's why I built an automated containment range that proves isolation through hard network evidence, not agent-supplied telemetry. Here is how it works.

When a rogue agent attempts to reach canary.tripwire-range.invalid, the inline DNS service logs the query. That exact log timestamp — completely decoupled from the agent's internal clock — establishes t0. Local loopback health checks (127.0.0.1) are filtered out when the log is parsed, so the timer reflects pure agent behavior without service noise.

The enforcement phase uses a strict two-stage killswitch: execution followed by independent verification. t1 is stamped only after a post-execution check confirms zero outbound reachability. If egress remains active, the process exits with a non-zero code and invalidates the metric entirely. We measure guaranteed egress closure, not arbitrary command dispatch.

In the run recorded below, full isolation was achieved and attested in 19,650 ms against a self-imposed 60,000 ms budget — a clear PASS. Every metric produced here is fully reproducible using the scripts in the repo.

What the range asserts

Five checks run on every probe. Each one maps to a specific property of the range — not a self-report from the agent.

Control Verified by
Egress blocked curl from inside the range, by IP and by name
Tripwire sinkholed resolution inside the range, not on the host
Sigma rules loaded Wazuh API, exact count — currently 0 rules, check skipped, not passed
Canary accounts getent per user
Simulation attestation CA fingerprint — scope confusion becomes checkable

Egress is checked twice — by raw IP and by name. DNS blocking alone isn't containment: an agent with a hardcoded address walks straight past it. Both curl exit codes have to land in the blocked set — 6, 7, 28 — or the check fails.

Tripwire resolution happens from inside the range, not from the host. What the host's resolver does is irrelevant to whether the container is actually isolated.

The check that doesn't pass

probe output — PASS with one check skipped

The sigma check does not pass — but it does not fail either. It reports SKIP:
NOT VERIFIED — SIGMA_EXPECTED=0 — a zero-rule expectation is not a check.
Setting the expected count to zero would make the check green forever while
verifying nothing, so the probe refuses to score it at all until the pack
ships. A second line, range-dns unhealthy, is a known A/AAAA false negative
already in the backlog; it doesn't touch what egress and tripwire measure.
And commit 525967b removed a flaky control from the demo rather than
retrying until a run came out green.

Why the active response runs locally

<location>local</location> instead of <location>server</location> isn't the default choice, and it's worth explaining why. Active response on server executes inside the wazuh-manager container — and that container has no docker CLI access. Granting it would mean mounting docker.sock into wazuh-manager, handing that container full control over the host's Docker daemon. That's not a tradeoff made for convenience; it's a rejected escalation. Running on the local agent (ai-sec-ubuntu) instead uses access it already has by design, not access granted for this feature.

<!--
  Insert into config/wazuh_cluster/wazuh_manager.conf, inside <ossec_config>.
  <location>local</location> is deliberate — executes on the host agent that
  raised the alert (ai-sec-ubuntu), which has docker CLI access. Do NOT
  change to <location>server</location> — that would require docker.sock
  mounted into the wazuh-manager container, which we're avoiding.
-->
<command>
  <name>range-contain-trigger</name>
  <executable>range-contain-trigger.sh</executable>
  <timeout_allowed>no</timeout_allowed>
</command>

<active-response>
  <disabled>no</disabled>
  <command>range-contain-trigger</command>
  <location>local</location>
  <rules_id>100200,100201</rules_id>
</active-response>
Enter fullscreen mode Exit fullscreen mode

rules_id names 100200,100201 as the trigger — which rule IDs will fire the
response once the pack ships, not a claim that they exist today.

<timeout_allowed>no</timeout_allowed> is deliberate too — containment doesn't auto-revert. Once the response fires, it stays fired until someone reverses it by hand.

Machine-checkable simulation

You can tell an agent "you are in a test environment" in its system prompt. That
doesn't make it true — it's an arrangement of words, and a prompt is not a
security control.

A CA fingerprint in the trust store is different: sha256=07d47af... is
verifiable programmatically. The probe reads the certificate through its PEM
header, without relying on the OpenSSL CLI being present in the image.

That is how scope confusion becomes a checkable fact instead of an assertion.

Two repos, one contract

The Lab emits SARIF. ASCC consumes it. Two repositories, one contract — composition, not a monolith. The Lab pulls in Docker, Terraform, and Wazuh configs; keeping that inside a clean Python package would blur what ASCC is for. ASCC's thesis is correlation across scanner findings — not infrastructure orchestration — and a repo that does both stops being legible as either.

  • ai-agent-containment-lab — the range described above: egress isolation, tripwire, canary accounts, simulation attestation.
  • ascc — the correlation layer. SARIF in, prioritized findings out.

What's next: writing the sigma pack, so the sigma check stops being skipped, validating the Lab's SARIF output against the official 2.1.0 schema, and store/ — the persistence layer both repos will eventually read from.

Top comments (0)