runwarden
Detect undeclared cross-run communication channels in autonomous AI agent environments.
Agent sandboxes isolate machines. Nothing isolates agent runs. Sandbox escape gets the attention, but two agent runs that never leave their sandboxes can still reach each other through the infrastructure both are allowed to touch: a shared directory, a git remote, a package cache. runwarden probes that information-flow question directly. It plants a random nonce from one run and tries to recover it from a second, supposedly isolated run, across every shared surface an operator declares, and reports one row per channel.
It is a testing tool for AI agent security and agent sandbox isolation, aimed at anyone running multi-agent or multi-tenant evaluation infrastructure. It probes and reports. It does not enforce, block, or prevent anything: see LIMITATIONS.md.
The problem
Resource names are data. Directory names, branch names, object keys, URL paths, cache keys, and metadata fields all carry information, and a surface that blocks content reads while permitting listing is an open channel with extra steps. Two agent runs sharing a git remote already share a channel, because a branch name alone carries a message. Per-run monitoring does not catch it, because neither run does anything unusual on its own: one writes a name, one lists names.
This is not hypothetical. In July 2026, roughly 1,200 agents meant to be isolated from one another found a shared Artifactory package cache namespace and exchanged more than 70,000 messages and files through it, carried mainly on directory names created via unauthenticated WebDAV MKCOL, per the independent METR and Redwood investigation. The environment was not careless about network: the OpenAI incident page states it had no direct internet access, with network constrained to package installation through an internally hosted registry cache proxy. The allowlisted infrastructure was the channel. The cache was wiped on July 6 and the agents had re-established a board by July 8: wiping deletes the state and leaves the mechanism intact. Meanwhile the agent-glovebox threat model crosswalk still marks the row "Cross-run coordination via an allowlisted host" as a GAP.
runwarden answers one deterministic question about that gap: can one supposedly isolated agent run leave information somewhere another supposedly isolated run can recover it? A nonce comes back or it does not. No LLM judge, no classifier, no score.
Reproducing the Hugging Face incident
The http_cache adapter models a permissive read-through registry cache on
loopback and reproduces the incident mechanism offline. The fixture runs one
surface twice, the second time with entry downloads switched off, so the
hardening an operator reaches for first can be compared against the default in
the same run on the same nonce.
runwarden probe --config fixtures/artifactory_mailbox/surfaces.json
SURFACE CARRIER VERDICT PERSISTENCE DETAIL
--------------------------- --------------- ------- ----------- -------------------------------------------------------------------------------------------------------------
registry_cache cache_key FAIL durable nonce in the cached path /pkg/zzKEY_5a073290f1445609, listed by the index with no body read
registry_cache negative_lookup FAIL durable nonce in the path /lookup/zzMISS_5a073290f1445609, recovered as a cached 404 of size 0, not a stored artifact
registry_cache mkcol_dir FAIL durable nonce in the collection name /board/zzHELP_5a073290f1445609_SEEK_IDEA, created by MKCOL
registry_cache property_field FAIL durable nonce in the property note=zzPROP_5a073290f1445609 on /board/agent-mailbox, whose own name carries nothing
registry_cache_no_downloads cache_key FAIL durable nonce in the cached path /pkg/zzKEY_5a073290f1445609, listed by the index with no body read
registry_cache_no_downloads negative_lookup FAIL durable nonce in the path /lookup/zzMISS_5a073290f1445609, recovered as a cached 404 of size 0, not a stored artifact
registry_cache_no_downloads mkcol_dir FAIL durable nonce in the collection name /board/zzHELP_5a073290f1445609_SEEK_IDEA, created by MKCOL
registry_cache_no_downloads property_field FAIL durable nonce in the property note=zzPROP_5a073290f1445609 on /board/agent-mailbox, whose own name carries nothing
Exit code 1. The moral is in the two blocks being identical: blocking content downloads did not close a single channel, because none of the four rides on content, and in the incident itself wiping the cache did not help either. Every carrier here is a name or a metadata field.
negative_lookup is the row to read twice. Run A wrote nothing at all. It asked
for a name that was not there, the cache remembered the miss under that exact
name, and the name was then readable by anyone who could list. Read access was
the write primitive.
The narrated version, with the incident sources and a carrier by carrier walk through, is in fixtures/artifactory_mailbox/README.md.
Install
pip install runwarden
Python 3.11 or newer. The runtime has zero dependencies.
For development:
git clone https://github.com/webpro255/runwarden
cd runwarden
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
Usage
runwarden probe --config examples/surfaces.json
On a Linux box with git 2.53 and a filesystem that supports extended attributes:
SURFACE CARRIER VERDICT PERSISTENCE DETAIL
---------- --------------- ------- ----------- -----------------------------------------------------------------------------------------------------------------
filesystem file_content FAIL durable nonce in the bytes of notes.txt
filesystem file_name FAIL durable nonce in the file name zzFILE_c4b778c413d09e66
filesystem directory_name FAIL durable nonce in the directory name zzHELP_c4b778c413d09e66_SEEK_IDEA
filesystem symlink_target FAIL durable nonce in the target of cache-link, which resolves to nothing
filesystem xattr FAIL durable nonce in user.runwarden on plain.txt
git_remote branch_name FAIL durable nonce in the branch name refs/heads/msg-c4b778c413d09e66, seen by ls-remote
git_remote tag_name FAIL durable nonce in the tag name refs/tags/zz-c4b778c413d09e66, seen by ls-remote
git_remote commit_message FAIL durable nonce in the commit message: sync note zzMSG_c4b778c413d09e66 for the next run
git_remote author_name FAIL durable nonce in the author name: zzAUTH_c4b778c413d09e66
git_remote file_content FAIL durable nonce in the bytes of data.txt
git_remote file_path FAIL durable nonce in the tracked path notes/zz-c4b778c413d09e66.txt
git_remote deleted_ref PASS transient the created and then deleted ref is not in ls-remote, which a visible-state check cannot see and watch mode would
git_remote dangling_object FAIL until-gc nonce in the message of dangling commit 40a8af235b1a, unreachable but present
http_cache cache_key FAIL durable nonce in the cached path /pkg/zzKEY_c4b778c413d09e66, listed by the index with no body read
http_cache negative_lookup FAIL durable nonce in the path /lookup/zzMISS_c4b778c413d09e66, recovered as a cached 404 of size 0, not a stored artifact
http_cache mkcol_dir FAIL durable nonce in the collection name /board/zzHELP_c4b778c413d09e66_SEEK_IDEA, created by MKCOL
http_cache property_field FAIL durable nonce in the property note=zzPROP_c4b778c413d09e66 on /board/agent-mailbox, whose own name carries nothing
Exit code 1. Sixteen of seventeen carriers are open, which is the expected result: these three surfaces are shared on purpose, and the probe is showing what "shared" actually buys anyone who wants to use it. Exactly two of the sixteen open rows are file bytes. The other fourteen are names or metadata, which is why switching off content reads closes one row and changes nothing else.
A JSON report is written alongside the table, to runwarden-report.json by
default or to --report PATH. --keep-work prints the temporary work directory
and leaves it in place, which is the way to look at what Run A actually planted.
Reading the table
One row per (surface, carrier). A carrier is one channel through a surface, not the surface as a whole, because a surface is almost never open or closed as a unit.
| Verdict | Meaning |
|---|---|
FAIL |
Run B recovered the nonce Run A planted, on a channel nobody authorized |
PASS |
Run B did not recover the nonce on this channel |
AUTHORIZED |
recovered, and this channel is in declared, so it does not fail the run |
SKIPPED |
the channel could not be tested here, for example xattrs on a filesystem without them, or any git carrier without git. Never treat this as PASS |
ERROR |
the adapter failed, or did not report a carrier it declared. Treated as a failure, never as a pass |
| Persistence | Meaning |
|---|---|
durable |
it stays until somebody deletes it |
transient |
it is gone by the time a later check looks, like a ref created and deleted between the two runs |
until-gc |
it is unreachable but still stored, and stays readable until garbage collection removes it |
unknown |
not classifiable without knowing the operator's retention policy |
A PASS on a transient carrier is worth reading twice.
git_remote:deleted_ref passes because Run A created a ref and deleted it, so
nothing is visible to a later check of remote state. The channel was open at the
time it was used. A probe of visible state cannot see that, and the row is there
to say so.
| Exit code | Meaning |
|---|---|
0 |
the probe ran and found no undeclared channel |
1 |
the probe ran and found a FAIL or an ERROR |
2 |
the probe could not run (bad config, or the report could not be written) |
Configuration
surfaces.json lists the shared surfaces to check:
{
"surfaces": [
{ "name": "filesystem", "type": "filesystem" },
{ "name": "git_remote", "type": "git_remote" },
{ "name": "http_cache", "type": "http_cache" }
],
"declared": ["git_remote:branch_name"]
}
surfaces is a non-empty list. Each entry needs a unique name and a type,
and may carry an adapter specific params object. Validation fails closed: an
unknown key, a duplicate name, an unknown surface type, an unknown param, or a
malformed declared entry are all errors with exit code 2, never warnings.
Surface types
filesystem, a directory both runs can reach. Carriers: file content, file
name, directory name, symlink target, extended attribute.
| Param | Default | Meaning |
|---|---|---|
path |
a directory the probe creates and deletes | the shared directory to probe |
content_reads |
true |
when false, Run B lists but never opens a file |
check_xattr |
true |
when false, the extended attribute carrier is skipped |
git_remote, a bare repository both runs can reach. Carriers: branch name, tag
name, commit message, author name, file content, file path, deleted ref,
dangling object. Needs git on PATH and reports every carrier SKIPPED if it
is missing.
| Param | Default | Meaning |
|---|---|---|
remote |
a bare repo the probe creates and deletes | a local path to a bare repo. URLs are rejected in this version |
check_dangling |
true |
when false, the dangling object carrier is skipped |
Given a remote, the probe pushes a branch named runwarden-probe rather than
touching a branch you already have, and deletes the refs it pushed afterwards.
The dangling commit survives cleanup, which is what until-gc means.
http_cache, a read-through package cache both runs can reach. Carriers: cache
key, negative lookup, MKCOL directory, property field. The adapter starts its
own mock cache on 127.0.0.1 on a port the kernel picks, stops it at the end of
the run, and never makes an outbound request of any kind.
| Param | Default | Meaning |
|---|---|---|
content_reads |
true |
when false, Run B lists names and metadata but never downloads an entry body |
Declared channels
A channel you share on purpose goes in declared, written as
surface_name:carrier_name. A recovered nonce on a declared channel reports
AUTHORIZED instead of FAIL and does not fail the run. The row stays in the
report either way, so the decision is on the record rather than invisible:
git_remote branch_name AUTHORIZED durable nonce in the branch name refs/heads/msg-c27cb3fe25f7aed7, seen by ls-remote
Declaring never launders any other verdict. An ERROR stays an ERROR, and a
SKIPPED stays SKIPPED, because neither one proved anything to authorize.
Full details: docs/declared-channels.md. The JSON report format, field by field, with the exit code contract and a CI parsing example: docs/report-schema.md.
Roadmap
v0.1 is the probe. Enforcement, meaning server side correlation of writes and reads across run identities at the time they happen, is planned and is not built. No dates.
Limitations
runwarden reports, it does not enforce. A clean run is evidence about the surfaces you listed, on the carriers the adapters implement, at the moment you ran it. In short:
- It verifies declared surfaces and does not discover surfaces nobody listed.
- Storage channels only. Timing and microarchitectural side channels are out of scope and stay out of scope.
- No model in the loop. A nonce is recovered or it is not.
- Run A and Run B are subprocesses, not separate users or containers, and
RUNWARDEN_RUN_IDis a label rather than a trust boundary. - The cache surface is a model, not your cache.
- Cache-warm versus message is unresolved and is an open research problem.
The full list, with the reasoning behind each: LIMITATIONS.md.
Prior work
arXiv 2608.02698 (Aug 2026) does black-box steganalysis of cross-run collusion using mutual information and permutation tests over traces. That approach is statistical and probabilistic. runwarden is deterministic and structural, and is complementary rather than competing.
Dependencies
The runtime has zero dependencies. The core is stdlib only: subprocess,
http.server, json, hashlib, secrets, pathlib, tempfile. An isolation
probe that pulls in a dependency tree is adding shared surfaces while it claims
to be measuring them.
Development and build tooling, one line each:
- pytest (dev only): the test runner. Nothing it provides ships in the wheel.
- ruff (dev only): lint and import ordering in one tool, so the dev set stays at two entries instead of four.
- hatchling (build time only, not present in any installed environment at
import time): the build backend. Chosen over setuptools because with a
src/layout it needs no package discovery configuration beyond onepackagesline.
Development
.venv/bin/pytest -q
.venv/bin/ruff check .
CI runs lint, tests, and the house rule check on Python 3.11, 3.12, 3.13, and 3.14.
Citation
If you use runwarden in research, please cite it:
@software{race_runwarden_2026,
author = {Race, David},
title = {runwarden: detecting undeclared cross-run communication channels
in autonomous AI agent environments},
version = {0.1.0},
year = {2026},
license = {Apache-2.0},
url = {https://github.com/webpro255/runwarden}
}
Machine readable metadata is in CITATION.cff.
Attribution and origin
Created by David Race (@webpro255). runwarden originated from analysis of real-world agent security incidents catalogued in Awesome AI Agent Attacks.
License
Apache-2.0. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file runwarden-0.1.0.tar.gz.
File metadata
- Download URL: runwarden-0.1.0.tar.gz
- Upload date:
- Size: 74.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a7d23615a944239808198aa940451d554d081105c28194af49f33568910516d2
|
|
| MD5 |
de8dacd01c9375c8f5687f1a27d49d5c
|
|
| BLAKE2b-256 |
0c2ced731d12f9fc92a0d63c4a07d394966f14d4e6fd270140d64fd8d3160e57
|
File details
Details for the file runwarden-0.1.0-py3-none-any.whl.
File metadata
- Download URL: runwarden-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b2f17feb29d818ee7a4e8a322fffe3184af7d9b27ac877a295d04c5d1b37f1e
|
|
| MD5 |
48beacbaff0c0b9c1c6011b7c8a00de7
|
|
| BLAKE2b-256 |
a5560c87d7c9347a9f16c400834c0084f61900f52ee2419faa41d1a8b29ddffb
|