Security and Risk Model
An agent with an email address and access to your app is a real attack surface. Protege is explicit about where the line sits: some protections the engine enforces for you, and some are yours to own. This page draws that line, then details the one guardrail the engine gives you the most control over — inbound access control.
What the engine enforces
- Domain-scoped routing. The engine claims only inbound mail for domains you've registered as an
EmailDomain. It never intercepts the host app's other mail, and mail to an unknown address is bounced rather than processed. - Inbound access control. Every sender is checked against a layered guardrail before a message reaches inference (detailed below).
- A recursion guard. Every agent-sent email is stamped with an
X-Protege-Recursionhop count —1on fresh mail, the inbound message's count plus one on a reply — and inbound mail whose count has reached the limit (config.recursion_limit, default 50) is silently dropped. Human mail clients never echo the header back, so a person replying anywhere in the chain resets it; only an unbroken Protege-to-Protege reply chain accumulates. Console and operator mail is never stamped. - Reply identity. The
send_emailtool forcesFromto the sending agent's own address — an agent can't impersonate another sender — and takes a reply's subject and threading from the inbound message rather than letting the model invent them. - Bounded loops. A single run is capped at
max_tool_turnstool-calling rounds, so a confused agent can't spin forever. - Attachment limits. Oversized or too-numerous attachments are bounced at the edge, before storage.
What the host owns
- Dashboard authentication. The engine ships none. The
/protegemount exposes agents, threads, and traces to anyone who can reach it — you must wrap the mount in your app's auth (e.g.authenticate :user do ... end). The install generator leaves a loudTODOto this effect. - Mail authentication. Whether a sender's
Fromcan be trusted at all is a transport concern — SPF/DKIM/DMARC. A provider handles it for you; if you self-host, the bundled MTA enforces it, but you own the DNS. Without it, the access guardrail is only as trustworthy as theFromheader. - Tool blast radius. A tool is ordinary Ruby with your app's full power. What a tool can do — refund any order, or only the sender's; read one record, or the table — is your design. Scope aggressively and treat tool input as untrusted.
- Prompt injection. A customer's email is untrusted input that the model reads. Assume it may try to talk the agent into misusing a tool, and defend at the tool boundary (authorization checks in the tool), not by trusting the prompt.
- Attachment reach (known limitation). The blob ids the built-in
send_emailandread_attachmenttools accept currently resolve against all Active Storage blobs in the app — there is no agent- or message-level scoping yet. An agent talked into guessing an id could read or attach a file it was never sent, so until scoping lands, treat an attachment-capable agent as able to reach anything in your app's Active Storage, and gate such agents to senders you trust.
Inbound access control
This is the guardrail you tune most, so it's worth understanding in full. It decides which senders may reach an agent, and it's built from two layers that only ever narrow:
- The global ceiling —
config.inbound_access, a committed, org-wide policy. This is the outer boundary; nothing can widen past it. - Per-agent rules —
AccessRulerecords, editable in the dashboard. These can restrict an individual agent further, but never admit a sender the ceiling blocks.
The effective decision is the intersection: a sender must pass both layers. A agent with no rules of its own imposes no extra constraint and inherits the ceiling as-is.
How The Scoop uses it
Sundae (customer-facing) should hear from anyone, while Scout (internal ops) should only be reachable from inside the company. Set the ceiling wide and restrict Scout at the agent layer:
# config/initializers/protege.rb — the global ceiling: no outright bans
config.inbound_access = Protege::Gateway.build_access_policy # permit-all ceilingThen, on the Operations agent's record in the dashboard, add an allow rule for *@thescoop.com. That rule flips Scout into allow-list mode, so only company senders get through — while Sundae, with no rules, stays open to customers. Neither can be reached from a domain the ceiling would have blocked, had you set one.
Pattern matching
Patterns are an exact address or a single * wildcard (*@thescoop.com). They're matched against the sender's routing key — tag-stripped and lowercased — so Ceo+finance@Thescoop.com matches a ceo@thescoop.com rule. Precedence within a layer is fixed: an explicit deny match always wins, then an allow match admits, then the layer's default settles everyone else. Supplying any allow rule makes the layer default-deny ("only these may enter"); a layer with only deny rules is a block-list.
A sender that fails the guardrail is bounced with an access-denied notice and never reaches inference.
Tool-level gating
Message admission is only the first gate. Each toolkit attached to an agent carries its own allow/deny sender gate (no rules admits every sender the message guardrail lets through) plus an off-by-default Allow on scheduled runs flag, enforced at dispatch — not just by hiding tools from the catalog. So one agent can expose different tools to different senders, even mid-thread: Sundae can offer process_refund only to *@thescoop.com while lookup_order stays open to every customer, and a staffer replying inside a customer's thread unlocks tools the customer never saw.
Related
- Gateway — where these checks run in the inbound flow.
- Configuration —
inbound_accessandattachment_policy. - Going to production — the pre-launch security checklist.