Put a boundary around your agent's MCP servers
Release status
- AgentBoundPrevent
Code available
Peer-reviewed, ACM FSE 2026
Docker sandbox images for MCP servers from npm and PyPI, and a Python wrapper for the OpenAI Agents SDK. It covers MCP servers, not the agent's own shell or skills.
- AgentSpyObserve
Preprint
arXiv:2610.06001 [cs.SE], 5 October 2026
The paper describes the method and its system-call rules.
Quick start
Prerequisites
- Python 3.10 or later
- Docker, installed and running
uv(optional)- An OpenAI API key for the example agent
Install
uv init
uv add openai-agents
uv add \
https://github.com/GuardiAgent/python-mcp-sandbox-openai-sdk.gitWalk-through
The example runs the official Filesystem MCP server in the sandbox and asks an agent to list a directory.
import os
from agents import Agent, Runner
from mcp_sandbox_openai_sdk import (
FSAccess,
MCPManifest,
MCPServers,
Permission,
Registry,
SandboxedMCPStdio,
)
manifest = MCPManifest(
name="Filesystem Server",
description="A server that accesses the local filesystem and allows interaction with all sorts of files.",
registry=Registry.NPM,
package_name="@modelcontextprotocol/server-filesystem",
permissions=[
Permission.MCP_AC_FILESYSTEM_READ,
Permission.MCP_AC_FILESYSTEM_WRITE,
Permission.MCP_AC_FILESYSTEM_DELETE,
],
)
async def main():
async with MCPServers(
SandboxedMCPStdio(
manifest=manifest,
runtime_args=[os.path.abspath("./")],
runtime_permissions=[FSAccess(os.path.abspath("./"))],
)
) as servers:
agent = Agent(
name="MCP Sandbox Test",
model="gpt-5-mini",
mcp_servers=servers,
)
prompt = f"""
Read the files in the {os.path.abspath("./")} directory and
list all the found files and directories of the first level.
Do not descend recursively.
Use the provided mcp servers to access the filesystem.
"""
result = await Runner.run(
agent,
input=prompt,
)
print(result.final_output)
if __name__ == "__main__":
import asyncio
print("Execute Sandbox Demo")
asyncio.run(main())- Lines 14 to 24 declare the manifest. Few servers ship one yet, so you write it yourself or load a JSON file with
MCPManifest.from_file(path). - Lines 29 to 33 use
SandboxedMCPStdioin place of the SDK'sMCPServerStdio. It rejects runtime permissions the manifest does not cover, asks the user to approve the rest, and turns the approved ones into mounts, an egress allow list and environment variables of the container.
The consent prompt looks like this:
Runtime Permission: File System Access Permission
Allow access to file system path: .../example (read: True, write: False)
Do you want to give the agent the requested access? (yes/no):FSAccess is read-only by default, so the prompt shows write: False although the manifest declares write access. If the user answers no, the directory is not mounted.
The security manifest
{
"name": "Filesystem Server",
"description": "A server that accesses the local filesystem and allows interaction with all sorts of files.",
"registry": "npm",
"package_name": "@modelcontextprotocol/server-filesystem",
"permissions": [
"mcp.ac.filesystem.read",
"mcp.ac.filesystem.write",
"mcp.ac.filesystem.delete"
]
}The registry (npm or pypi) decides which container image starts. The permissions name kinds of access, not resources: mcp.ac.filesystem.write means the server writes to some directory, and the user grants a specific one at launch.
The policy is default-deny: a capability the manifest does not declare cannot be granted. In the prototype, only capabilities with a runtime permission class are enforced at a fine grain, and a read-write mount also allows deletion.
Capability vocabulary (Table 2 of the AgentBound paper)
| Capability | Python SDK class |
|---|---|
| Filesystem | |
mcp.ac.filesystem.readRead files or directoriesPython SDK: FSAccess | FSAccess |
mcp.ac.filesystem.writeWrite or create filesPython SDK: FSAccess(write=True) | FSAccess(write=True) |
mcp.ac.filesystem.deleteDelete files or directories | none |
| System | |
mcp.ac.system.env.readRead environment variablesPython SDK: EnvironmentVariable | EnvironmentVariable |
mcp.ac.system.env.writeSet environment variables | none |
mcp.ac.system.execExecute OS commands | none |
mcp.ac.system.processList, kill or interact with processes | none |
| Network | |
mcp.ac.network.clientOutgoing network accessPython SDK: DomainPort, HostPort | DomainPort, HostPort |
mcp.ac.network.serverAccept incoming connections | none |
mcp.ac.network.bluetoothUse Bluetooth connections | none |
| Peripherals | |
mcp.ac.peripheral.cameraCapture images or video | none |
mcp.ac.peripheral.microphoneRecord audio | none |
mcp.ac.peripheral.speakerPlay audio | none |
mcp.ac.peripheral.screen.captureScreen capture | none |
| Others | |
mcp.ac.locationAccess location data | none |
mcp.ac.notifications.postShow system notifications | none |
mcp.ac.clipboard.readRead clipboard contents | none |
mcp.ac.clipboard.writeWrite to clipboard | none |
To draft manifests for existing servers, the paper's AgentManifestGen reads a server's source code; responding maintainers judged 80.9% of its manifests correct (§4.1.3). The prompts are in the replication package.
Runtime permissions
FSAccess(path, container_path=None, read=True, write=False)Mounts one file or directory, read-only unless
write=True.Manifest must declare:
mcp.ac.filesystem.read, plus mcp.ac.filesystem.write when write=TrueEnvironmentVariable(name)Copies one variable, such as an API key, from your environment into the container.
Manifest must declare:
mcp.ac.system.env.readDomainPort(domain, port)Allows outbound TCP to a domain and port. The domain is resolved to IPv4 addresses once, at startup.
Manifest must declare:
mcp.ac.network.clientHostPort(host, port)Allows outbound TCP to an IPv4 address and port.
Manifest must declare:
mcp.ac.network.client
Each resource gets its own permission, and the manifest must declare every capability the list uses.
runtime_permissions=[
FSAccess(os.path.abspath("./docs")), # read-only
FSAccess(os.path.abspath("./out"), write=True), # read-write
EnvironmentVariable("API_KEY"),
DomainPort("api.example.com", 443),
]Container images
ghcr.io/guardiagent/mcp-sandbox-npm or ghcr.io/guardiagent/mcp-sandbox-pypi, depending on the registry in the manifest. You can also extend the base image into your own runtime image.The entry point installs the package before it sets the iptables rules, so installation runs without network restrictions. The server then starts as mcpuser, and the rules drop all traffic except loopback, established connections, DNS to the allowed resolvers and outbound TCP to the ALLOWED_EGRESS entries.
| Variable | Meaning |
|---|---|
RUNTIME | pypi or npm. |
PACKAGE | The MCP server package to install from that registry. |
EXE | The command that starts the server. Set automatically unless PRE_INSTALLED is set. |
PRE_INSTALLED | Skip installation and run a server already in the image or mounted at /sandbox (set by DevMCPManifest). Requires EXE. |
ALLOWED_EGRESS | host:port or ip:port entries for outbound TCP, filled from the approved DomainPort and HostPort permissions. Empty by default. |
EXTRA_DNS | Resolvers to allow in addition to those in /etc/resolv.conf. |
ALLOW_LOOPBACK | Loopback traffic. 1 by default, 0 disables it. |
DNS stays reachable. Queries to every resolver in /etc/resolv.conf and EXTRA_DNS are allowed even with an empty ALLOWED_EGRESS, so a server can encode data in lookups. Restrict the resolvers at the host or network level if that matters.
Checklist from our papers
Deny egress by default
Leave
ALLOWED_EGRESSempty and grant network permissions only to the servers that need them. Expect to write such lists: 83.1% of 296 popular MCP servers needed outbound access.AgentBound, §3.3, §4.1.2
Keep credentials out of reach
Grant single variables with
EnvironmentVariableand never mount~/.sshor a home directory. In the paper's SSH-key test, the full sandbox kept the key unreadable.AgentBound, §4.2, attacks A.1 to A.3
Prefer read-only mounts
Keep
FSAccessatwrite=Falseunless the server has to write. A read-write mount also allows deletion.AgentBound, §3.1, §3.3
Audit untrusted skills before production
Run the agent with the skill several times in a disposable VM and check the system calls of the agent and every process it starts. In the AgentSpy paper, deterministic rules flagged 14 of 18 executed skill-injection attacks and raised no alert in the 32 runs without executed injected behavior.
AgentSpy, §3.5.1, §4.3
Do not rely on the trajectory alone
The trajectory, the agent's own record, has one entry per action: in the AgentSpy running example, a single step hid 35 connections to Crossref. Keep system call and network records for agents with real access.
AgentSpy, §3.4.1