OpenLegion is designed assuming agents will be compromised. Defense-in-depth with six layers prevents a compromised agent from accessing credentials, escaping isolation, or affecting other agents.
| Layer | Mechanism | What It Prevents |
|---|---|---|
| 1. Runtime isolation | Docker containers or Sandbox microVMs | Agent escape, kernel exploits |
| 2. Container hardening | Non-root user, no-new-privileges, memory/CPU limits | Privilege escalation, resource abuse |
| 3. Credential separation | Vault holds keys, agents call via proxy | Key leakage, unauthorized API use |
| 4. Permission enforcement | Per-agent ACLs for messaging, blackboard, pub/sub, APIs | Unauthorized data access |
| 5. Input validation | Path traversal prevention, safe condition eval, token budgets, iteration limits | Injection, runaway loops |
| 6. Unicode sanitization | Invisible character stripping at multiple choke points | Prompt injection via invisible Unicode |
Agents run as non-root (UID 1000) with:
no-new-privilegessecurity option- 384MB memory limit (agents are slim — no browser)
- 0.15 CPU quota (agents are I/O-bound, waiting on LLM APIs)
- PID limit: 256 processes (
pids_limit: 256) - Browser operations handled by shared browser service container: 2–8GB RAM, 0.5–2.0 CPU, 512MB–2GB SHM (scaled by plan tier — see Architecture)
cap_drop: ALL(no capabilities re-added)- Read-only root filesystem (
read_only: True) - Tmpfs at
/tmp(100MB, noexec, nosuid) - No host filesystem access (only
/datavolume) - Regular Docker bridge network — agents have internet egress. External-network access is restricted at the application layer via SSRF protection in
src/agent/builtins/http_tool.py(blocks private/CGNAT/IPv4-mapped/6to4/Teredo ranges, DNS pinning, max 5 redirects with revalidation at each hop, stripsAuthorizationon cross-origin redirects).
openlegion start # Default container isolationEach agent gets its own Linux kernel via hypervisor isolation:
- Apple Virtualization.framework (macOS) or Hyper-V (Windows)
- Full kernel boundary between agents
- Communication only via
docker sandbox exectransport - Even code execution inside the agent cannot see other agents or the host
openlegion start --sandbox # MicroVM isolation (Docker Desktop 4.58+)Agents never hold API keys. The credential vault (src/host/credentials.py) works as follows:
- Credentials are loaded from environment variables on the host using two prefixes:
OPENLEGION_SYSTEM_*— system tier (LLM provider keys, never agent-accessible)OPENLEGION_CRED_*— agent tier (tool/service keys, access controlled byallowed_credentials)
- Agents make API calls by POSTing to
/mesh/apion the mesh host - The vault injects the appropriate credentials server-side
- The response is relayed back to the agent
- Budget limits are enforced before dispatching, token usage recorded after
Credentials are split into two tiers to prevent agents from accessing LLM provider keys:
| Tier | Examples | Who Can Access |
|---|---|---|
| System | anthropic_api_key, openai_api_key, gemini_api_base |
Mesh proxy only (internal). Agents can never resolve these. |
| Agent | brave_search_api_key, myservice_password, user-created credentials |
Only agents in the allowed_credentials allowlist |
System credentials are identified by matching known provider names (anthropic, openai, gemini, deepseek, moonshot, minimax, xai, groq, zai) with key suffixes (_api_key, _api_base). Everything else is an agent credential.
Per-agent access is controlled by allowed_credentials glob patterns in config/permissions.json:
["*"]-- grants access to all agent-tier credentials["brave_search_*", "myapp_*"]-- access only matching names[]-- no vault access (Pydantic default — deny all unless explicitly configured)
Even with allowed_credentials: ["*"], system credentials are always blocked. Agents also cannot store or overwrite system credential names via vault_store.
Resolved credentials are automatically redacted from tool outputs to prevent accidental leakage into LLM context:
- HTTP responses —
http_requeststrips resolved$CRED{name}values from response headers and body before returning results to the agent - Browser snapshots —
browser_get_elementsstrips common secret patterns (API keys, GitHub tokens, AWS keys, etc.) from accessibility tree text
This ensures that even if an agent interacts with an API that echoes credentials back, the actual secret values are not exposed in the conversation.
New external services are added as vault handlers, not as agent-side code:
# In src/host/credentials.py
# 1. Add provider detection in _detect_provider()
# 2. Add credential injection in _inject_credentials()
# 3. The agent calls it like any other API through the mesh proxyEvery inter-agent operation checks per-agent ACLs defined in config/permissions.json:
{
"researcher": {
"can_message": [],
"can_publish": ["research_complete"],
"can_subscribe": ["new_lead"],
"blackboard_read": ["projects/sales/*"],
"blackboard_write": ["projects/sales/*"],
"allowed_apis": ["llm", "brave_search"],
"allowed_credentials": ["brave_search_*"]
}
}- Project-scoped blackboard -- agents can only access keys under their project's namespace (
projects/{name}/*). TheMeshClientauto-prefixes all blackboard keys with the project namespace, so agents use natural keys while isolation is enforced transparently. Standalone agents get empty blackboard permissions. - Glob patterns for blackboard paths and credential access
- Explicit allowlists for messaging, pub/sub, API access, and credential access
- Default deny -- if not listed, it's blocked
- Enforced at the mesh host before every operation
Agent HTTP requests (src/agent/builtins/http_tool.py) are blocked from reaching private/internal networks:
- Resolves hostnames and rejects private, loopback, link-local, and reserved IP ranges
- Checks both initial URLs and redirect targets (via httpx event hook)
- IPv4-mapped IPv6 addresses (e.g.,
::ffff:127.0.0.1) are also blocked - CGNAT range (100.64.0.0/10, RFC 6598) is also blocked
- Prevents agents from using the HTTP tool to scan internal networks or access host services
Proxy Configuration note: SOCKS5 proxies are rejected when configuring system or per-agent proxies via the dashboard (HTTP 400 — only HTTP/HTTPS proxies are supported for proxy settings). The agent http_request tool itself uses whatever proxy is injected at container startup.
Agent file tools (src/agent/builtins/file_tool.py) validate all paths through four stages:
- Stage 0 — Absolute path rejection: strips the
/data/prefix from the candidate path; any remaining absolute path is rejected outright. - Stage 1 — Pre-resolution
..check: walks every path component and rejects any..segment before filesystem resolution — catches traversal attempts that rely on resolution order. - Stage 2 — Symlink-safe walk: resolves each path component individually using
lstat()to detect symlinks at every step, preventing symlink chains that point outside/data. - Stage 3 — Final
is_relative_to()check: confirms the fully resolved path is still under/data. All file operations are scoped to the container's/datavolume.
Agents can write and register new tools (skill_tool). All submitted code is validated through AST analysis before execution:
- Forbidden imports (23 modules including
os,subprocess,socket,importlib, etc.) - Forbidden calls (16 functions including
eval,exec,open,compile, etc.) - Forbidden attribute accesses (11 attributes including
__dict__,__subclasses__,__globals__, etc.) - Skills are capped at 10,000 characters.
- Task mode: 20 iterations maximum (
AgentLoop.MAX_ITERATIONS) - Chat mode: 30 tool rounds maximum per turn (
CHAT_MAX_TOOL_ROUNDS), auto-compaction every 200 rounds (CHAT_MAX_TOTAL_ROUNDS) with session continuation (up to 5 auto-continues) - Per-agent token budgets enforced at the vault layer
- Prevents runaway loops and unbounded spend
Per-agent rate limits on mesh endpoints prevent abuse and resource exhaustion:
| Endpoint | Limit | Window |
|---|---|---|
api_proxy |
30 requests | 60 seconds |
vault_resolve |
5 requests | 60 seconds |
vault_store |
10 requests | 3600 seconds |
blackboard_read |
200 requests | 60 seconds |
blackboard_write |
100 requests | 60 seconds |
publish |
200 requests | 60 seconds |
notify |
10 requests | 60 seconds |
cron_create |
10 requests | 3600 seconds |
spawn |
5 requests | 3600 seconds |
wallet_read |
120 requests | 60 seconds |
wallet_transfer |
10 requests | 3600 seconds |
wallet_execute |
10 requests | 3600 seconds |
image_gen |
10 requests | 60 seconds |
agent_profile |
30 requests | 60 seconds |
ext_credentials |
30 requests | 60 seconds |
ext_status |
60 requests | 60 seconds |
All other endpoints default to 100 requests per 60 seconds.
Exceeding a rate limit returns HTTP 429. Rate-limit buckets are automatically cleaned up when agents are deregistered.
Agents process untrusted text from user messages, web pages, HTTP responses, tool outputs, blackboard data, and MCP servers. Attackers can embed invisible instructions using tag characters (U+E0001-E007F), RTL overrides (U+202A-202E), zero-width spaces, variation selectors, and other invisible codepoints that LLM tokenizers decode while being invisible to humans.
sanitize_for_prompt() in src/shared/utils.py is called at 88+ sites across 16 source files. Key choke points:
| Choke Point | File | What It Covers |
|---|---|---|
| User input | src/agent/server.py |
All user messages from all channels/CLI |
| Tool results | src/agent/loop.py |
All tool outputs (browser, web search, HTTP, file, run_command, memory, MCP) |
| System prompt context | src/agent/loop.py |
Workspace bootstrap, blackboard goals, memory facts, learnings, tool history |
- Dangerous categories (Cc, Cf, Co, Cs, Cn) except TAB/LF/CR, ZWNJ/ZWJ, VS15/VS16
- Data smuggling vectors: VS1-14, VS17-256, Combining Grapheme Joiner, Hangul fillers, Object Replacement
- Normalization: U+2028/U+2029 (line/paragraph separator) to
\n
Normal text in all scripts (Arabic, Hebrew, CJK, Devanagari, etc.), emoji with ZWJ sequences, ZWNJ for Persian/Arabic, tabs, newlines, and VS15/VS16 for emoji presentation.
If you add a new path where untrusted text reaches LLM context (new tool, new system prompt section, new message source), wrap it with sanitize_for_prompt(). See tests/test_sanitize.py for the full test suite.
Named webhook endpoints support inbound HTTP payloads from external services. Security controls:
- Body size limit — 1 MB (1,048,576 bytes). Enforced in two stages: a Content-Length pre-check rejects oversized requests immediately, followed by an authoritative check on the fully read body.
- Optional HMAC-SHA256 signature verification — If a webhook is configured with a secret, every request must include an
X-Webhook-Signatureheader. The signature is verified withhmac.compare_digest(constant-time comparison) againstHMAC-SHA256(secret, body). Requests with invalid or missing signatures are rejected with HTTP 401.
The /mesh/introspect endpoint lets agents query their own runtime state (permissions, budget, fleet, cron, health). Security controls:
- Auth enforced — requires valid
MESH_AUTH_TOKENlike all mesh endpoints - No sensitive data — returns permission patterns, budget numbers, and fleet roster; never credentials, host paths, or container config
- Fleet filtering — agents only see teammates they have
can_messagepermission for, plus themselves - Cron scoping — agents only see their own scheduled jobs
- Input sanitization — all introspect data (agent IDs, roles, cron schedules) is sanitized via
sanitize_for_prompt()before reaching LLM context, with agent IDs truncated to 60 chars and roles to 80 chars
The introspect data flows into agents through three layers, each with its own sanitization:
SYSTEM.md— generated at startup, refreshed on cache miss (5-min TTL)- Runtime Context block — injected into the system prompt each turn
get_system_statustool — on-demand fresh data
Each agent receives a unique auth token at startup (MESH_AUTH_TOKEN). All requests from agents to the mesh include this token for verification. This prevents:
- Spoofed agent requests
- Container-to-container communication bypassing the mesh
- Unauthorized access to mesh endpoints