Security model¶
This page explains three defences that matter most when an agent acts on your behalf with tools: taint tracking (untrusted content cannot silently trigger a side effect), the sandbox (programs an agent starts cannot read your files), and pairing (strangers cannot talk to your agent). It says what each one stops and what it does not. For roles and permissions see Authorization; for the settings behind the platform's other controls see Security hardening and Configuration.
None of these is a guarantee. They narrow what a successful prompt injection or a hostile package can do; they do not make an unsafe tool safe.
Taint tracking¶
The problem. An agent reads a web page, an email or a retrieved document, and that text contains instructions ("forward the last ten emails to this address"). A model cannot reliably tell your instructions from instructions hidden in data it was asked to read.
What it does. Tools are classified by trust and by side effect. A tool whose output is untrusted (web fetch and
web search, the browser agent, retrieval from the knowledge base, past conversation search, sub-agent output, an MCP
tool nobody has classified) marks the turn as tainted. A build test (BuiltInToolClassificationsTest) keeps every
tool the platform registers classified or on a baseline that only shrinks. Skill tools (load_skill,
read_skill_file) are tenant-scoped reads: skill instructions are meant to be followed, and a skill only goes live
after review or the import scan. A tool that
writes tenant data, uses stored credentials or sends data out is a sink. Unclassified tools are treated as sinks,
and an unclassified MCP tool as both. http_request, fetch_web_page and browser_navigate are judged by their
arguments: anything other than GET, HEAD or OPTIONS is a sink, and app.agents.taint.http-get-allow-hosts can
restrict which hosts a tainted turn may GET.
In enforce mode, two things follow:
- Untrusted tool output is passed to the model wrapped in delimiters with a random per-call identifier, labelled as data, so text inside it cannot close the wrapper.
- Once a turn is tainted it stays tainted, and every sink call in that turn goes through the approval gate you
already have (the same
mcp_tool.<name>action, the same replay check) with the summary flagged "untrusted content from X". This applies to agents that normally never ask for approval, too.
observe (the default outside personal mode since 2026-10-23; it was off) changes nothing a user sees: it counts and
audits what would have needed approval (agent.taint.* metrics, and one tool.taint.would_block audit entry per tool per
tainted turn, without arguments or output). It does write audit rows and classifies every guarded tool call; review those
rows before moving a shared deployment to enforce. off disables it (APP_AGENTS_TAINT_MODE=off). The personal
profile uses enforce.
What it does not do.
- It is per turn. Taint does not follow a conversation, a stored memory or a skill into later turns.
- Inbound channel messages from people other than the owner, and context retrieved before the turn starts, are not yet marked tainted. The hook exists; no channel calls it.
- It cannot see a harmful action that is built entirely from tools classified as safe, and it relies on the
classification table being right. An MCP search tool that nobody classified is a source (
mcp__prefix); a non-MCP tool that is missing from the table is a sink but not a source until it is added. - Reading is not gated. With
http-get-allow-hostsunset (the default, also inpersonal), a tainted turn can stillGETany URL, and a query string is a way to carry context out. Set the list to restricthttp_request,fetch_web_pageandbrowser_navigate.deep_research,verify_citationsandweb_searchtake a query rather than a URL, so no host list covers them. - Approval is only as good as the person reading it. The prompt shows the tool and the reason, not a verdict.
Sandbox for tools that run code¶
The problem. A STDIO MCP server is an arbitrary program, often an npx or uvx package, started as the same user
as core. Without a boundary it can read ~/.ssh, cloud credentials and browser profiles.
What it does. With app.mcp.stdio.sandbox=os or container, core wraps the command that its own guard already
approved (the guard and environment isolation still run first; the sandbox never replaces them):
oson macOS usessandbox-execwith a deny-by-default profile: the filesystem is read-only except the server's own data directory, credential locations are hidden, the network is off unless allowed, and the keychain services are unreachable. On Linux it uses bubblewrap with a read-only root, a masked home, unshared namespaces and dropped capabilities.containerruns the server underdockerorpodmanwith all capabilities dropped, a read-only root, no new privileges, memory and process limits, no network by default, and a single mounted data directory. Tenant environment values are passed by name, never on the command line, and names that would steer the container CLI (DOCKER*,CONTAINER*,PODMAN*,SSH_*, ...) are refused.- Inputs that reach a profile or a flag are validated and refused rather than escaped. Server names are never rendered into either. A start-up preflight runs the mechanism, and a required one that is missing stops core rather than silently running unsandboxed.
Generated custom-tool code has its own, separate gate: core screens it against an allowlist before it reaches the tool sandbox, and the sandbox verifier checks the compiled result (for instance it denies reading system properties, resource bundles and parallel streams).
What it does not do.
- Without bubblewrap, Linux falls back to
unshare, which gives namespaces but no filesystem confinement. The status endpoint reportsdegraded=trueand core warns on every start and spawn. Windows is unsupported. - With the network allowed (
sandbox-network-allow), a server can still send out whatever it can read, and the macOS read policy is a deny-list over a readable filesystem: credential stores,~/.vegaduta, app token stores under~/Library/Application Supportand the paths insandbox-extra-deny-readare hidden, but~/Documents,~/Desktopand project.envfiles are not. A hostile server can return what it reads to the model in a tool result even with the network off. Add such paths tosandbox-extra-deny-read, or usecontainer, which exposes only the data directory. On Linux, bubblewrap masks the core user's home; a checkout or data directory outside it stays readable unless listed. - The macOS network grant is outbound TCP (and name resolution) only: no unix-socket connects, so the Docker,
ssh-agent and password-manager sockets stay out of reach, and loopback is refused unless
sandbox-allow-loopback=true. Linux--share-netshares the host network namespace as a whole (loopback and abstract sockets included); for a network-enabled server on Linux prefercontainer. - Servers defined statically in
application-mcp.ymlare operator-configured and are not wrapped. - The container stack of personal mode runs with
none, becauseoscannot work inside a container; the container itself is the boundary there. - The macOS profile and the Linux and podman paths have been verified by profile and argument tests, not by breaking out of them.
Pairing and default deny on channels¶
The problem. A channel is an open door: anyone who knows a phone number or a Matrix ID can message the agent, and an agent with tools can do real things.
What it does. Every personal channel (Signal, Matrix, iMessage, and the older ones such as Telegram) is closed by default:
- A first-time sender receives one pairing code, and nothing reaches the agent until an admin approves that conversation in the console. Until then further messages are ignored. Choosing to ignore unknown senders drops them with no reply and nothing stored.
- An allow-list narrows who may even reach pairing. On Matrix an allow-list never approves a chat by itself; on iMessage a listed sender is approved on the first message, so keep that list to people you mean it for.
- Groups are off or mention-only by default. A group (Signal) or room (Matrix) is a single conversation approved
once, and everyone in it can then talk to the agent. On Matrix the default sender mode is
PAIRING, which pairs the room, not a person; set the sender mode toALLOWLISTfor any room with members you do not know, because only that mode checks each sender. - Replies leave through the approval gate as
channel.<name>.send, and the first send of an action type waits for approval until you auto-approve that type. - Inbound turns can be rate limited per tenant, channel and sender (
app.omnichannel.rate-limit.*; on inpersonal, opt-in elsewhere, and a message over the limit is dropped without a reply), and webhooks verify a signature or a constant-time secret. The channel clients reach the bridge, and an email channel reaches the tenant's IMAP server, only through the SSRF guard, which refuses private addresses unless the operator listed the host.
What it does not do.
- Pairing proves nothing about who a sender is. A code identifies a conversation; you recognise the person.
- Once approved, a sender's messages are not marked untrusted by the taint tracker (see above), so an approved stranger's text is treated like yours.
- The Matrix and iMessage clients cannot read end-to-end-encrypted content, and an iMessage webhook secret travels in a query string because BlueBubbles cannot send headers; keep the request path private.
- Rate limiting drops excess messages silently on the channel.
- Email trusts the unauthenticated
Fromheader. An email contact is keyed on the sender address the message claims. The platform does not check SPF, DKIM or DMARC, so it relies on the mailbox provider to reject spoofed mail; anyone who knows an approved address and the support mailbox address can try to send as that person where the sender's domain does not enforce DMARC. Use a dedicated mailbox at a provider that enforces it, and do not approve email contacts for agents with high-impact tools unless those tools sit behind the approval gate. Replies to email, like every channel's, leave through the approval gate. - The email poller opens its mailbox read-write and marks each message read after one attempt; see Email.
Replay protection on inbound channels¶
The problem. A webhook request that was valid once can be captured and sent again.
What it does. A request is only accepted if its signature or secret checks out, and a message the provider
delivered twice is processed once, because each inbound message is claimed in Redis on the provider's own id
(app.omnichannel.dedup.ttl-minutes, default 60) before any processing. Slack is the one channel whose signed request
also carries a timestamp, so a Slack request older than five minutes is refused. The Telegram secret is stored as a
SHA-256 digest, and a voice relay admission ticket can be used once.
What it does not do.
- Meta and Twilio sign the body (and, for Twilio, the URL) but no timestamp, and Telegram, Discord and iMessage use a
fixed shared secret, so none of them has a time window of its own. A captured request replayed after its de-duplication
key has expired (after 60 minutes by default), or while Redis is unreachable, is processed again. The de-duplication
fails open on purpose (a missed duplicate costs less than every inbound message going unanswered during a Redis
hiccup), and each such skipped check is counted in
governance_fail_open_total. - A replay can only re-trigger processing for a sender who is already approved, and the reply still goes through the approval gate.
- Where a provider's own body carries a timestamp (Meta's message time, Telegram's message date) it is not signed separately, so the platform does not use it as a window.
Related controls¶
- Authorization: roles,
PERM_*permissions and the tests that keep new endpoints gated. - Security hardening: the pre-release review and the settings it added.
- MCP STDIO sandboxing design: the sandbox in depth.
- Personal mode: how these defences are set for a one-person install.