Security Boundaries¶
This page collects the boundaries Entrabot enforces between the agent, the human, external content, and the tools it exposes.
Identity boundary: Agent User vs. delegated human¶
Every action the agent takes against Graph is attributed to one of two principals, and the attribution is structural, not cosmetic:
agent_usermode — the three-hop Blueprint → Agent Identity → Agent User flow (tools/teams.py::acquire_agent_user_token) mints a token withidtyp=usercarrying the Agent User's own object ID. Graph sees the agent as a first-class user principal, distinct from both the human and an app-only service principal.delegatedmode —MsalDelegatedAuthuses the signed-in human's own token. There is no Agent User attribution in this mode; outbound messages are prefixed[EntraBot]so the human can tell which messages the agent sent, and the identity state machine recordsattribution_type = "delegated-human".
tools/audit.py::log_event() is what makes this durable: every audit event carries an attribution_type of "agent", "delegated-human", or "none", resolved from the active identity session, then config, then the credential store, in that order. See Identity and Token Flow for the full three-hop mechanics.
Certificate boundary (per OS)¶
Certificate handling differs by platform, and the two paths are not equivalent in strength:
- macOS and Linux —
_build_blueprint_assertion()retrieves the PEM private key from the OS keystore (Keychain viaMacCredentialStore, Secret Service/KWallet viaLinuxCredentialStore) into the process, then signs the JWT in-process withcryptography'sload_pem_private_keyand PyJWT. The key material is present in process memory for the duration of the signing call. - Windows — the private key is a non-exportable CNG key in
Cert:\CurrentUser\My, backed by the Microsoft Platform Crypto Provider (TPM) when available or the Microsoft Software Key Storage Provider otherwise (generate_windows_cert.py,auth/cncrypt_signer.py). Signing happens throughncrypt.dll; only the signature crosses back into the process, never the key.
Both paths produce the same JWT shape (x5t#S256 header, 10-minute assertion lifetime — auth/certificate.py), so the two are interchangeable at the protocol level. They are not interchangeable at the threat-model level: only the Windows CNG path is non-exportable, and only a TPM-backed CNG key is hardware-backed. The Mac/Linux path is neither hardware-backed nor non-exportable because anything that can read the OS keystore under the agent's account can retrieve the PEM.
Audit-first, fail-closed attribution¶
log_event() is called before a sensitive action proceeds, and again after it resolves, for security-sensitive call sites (Files Graph calls via _audit_graph_call(), Teams member adds, file shares): a pending event first, then success or failure. Other tools audit once, immediately before the side effect.
Attribution itself fails closed: if agent_id isn't supplied and none can be resolved from the active identity session, config, or the credential store, log_event() raises AuditAttributionError rather than logging "unknown" — unless the caller explicitly opts in with attribution_type="none" (reserved for bootstrap/preflight code that genuinely runs before any identity exists). This scope is specifically about attribution; it does not mean every possible agent action is audited today — it means an action that is meant to be agent-attributed cannot silently become an orphaned log line.
External-content / XPIA boundary¶
security/xpia.py::wrap_external() wraps external-source text — Teams messages, email bodies, file contents, Work IQ content — in a <external_content> envelope before it reaches the model:
The properties that make this a real boundary, not just formatting:
- The outer envelope is always authoritative.
wrap_external()unconditionally wraps every call, even when the body already looks like an envelope. External text cannot forge the trustedsource/sender/received_atmetadata by embedding what looks like the wrapper prefix — the caller-supplied attributes always win because they're the ones written into the outer tag. - Escape-on-collision. Any literal
</external_content>in the body — including case variants and whitespace-padded forms like< / External_Content >— is entity-escaped before wrapping, so embedded text can't break out of the envelope by closing it early. - Attribute-safe.
source,sender, andreceived_atvalues are escaped for&,<,>, and", so a hostile value likesource="teams:<script>"can't open a new tag inside the attribute region. - Round-trip support for tests and audit.
unwrap_external()reverses collision escaping and round-trips the body byte-for-byte while recovering thesource, optionalsender, and optionalreceived_atattributes. The envelope has nocontent_hashattribute or cryptographic binding. - Opt-out is an env flag, not a code path.
ENTRABOT_XPIA_WRAP_ENABLE=falseshort-circuitswrap_external()to the identity function for rollback, without touching call sites.
This is a text-escaping and provenance boundary, not a cryptographic signature. See Security (XPIA) API Reference for the full public surface, confirmed wrapped/unwrapped call sites, and limitations.
Sponsor authorization¶
"Sponsor" is Entrabot's authorization concept for who may direct the agent to take mutating action on a human's behalf.
- Source of sponsors.
identity/sponsors.py::fetch_agent_identity_sponsors()reads the Agent Identity's/sponsorsGraph relationship using an app-only, two-hop token (acquire_agent_identity_token()— stops after the FIC exchange, nouser_ficgrant). This has to be app-only: the Agent User's own delegated token cannot read its own/sponsorsrelationship. SponsorGate(identity/sponsors.py) decides whether an inbound message can satisfy an explicitwait_for_sponsor_dmor server-side auto-wait. It matches on sponsor user ID, UPN, or email, extended with chat-member and cross-tenant B2B lookups across watched chats.- Active-channel binding (
identity/active_channel.py::ActiveChannelBindings) is the separate, TTL-scoped gate that authorizes bothshare_fileandadd_teams_membermutations. A binding is minted only after a sponsor's inbound message has been successfully pushed to the model (via the channel-push notification path) — recording(sponsor_user_id, chat_id, graph_sent_at). The TTL (120s) is enforced against the message's Graph-authoredsent_at, not against when the server observed it, specifically so a stale message can't be replayed at bootstrap to mint a fresh authorization window. Bindings expire on read as well as on write, so a clock rewind can't resurrect one. share_fileandadd_teams_memberboth enforce all three checks.share_file()(tools/files.py) andadd_member()(tools/teams.py, exposed as theadd_teams_memberMCP tool) gate on: (1) the requester matching a sponsor identity, (2) an active-channel binding for that sponsor whosechat_idmatches the one supplied, and (3) — defense in depth — the sponsor actually being a Graph member of the target chat.NoActiveSponsorChannelErroris raised when the matched sponsor has no live binding at all;SponsorChannelMismatchErroris raised specifically to catch a confused-deputy pattern: an LLM in chat A being induced to act on chat B, where the sponsor is a genuine member of B but not actively engaged there right now. Dedicated tests for theadd_teams_memberbinding checks live intests/tools/test_add_member_channel_binding.py.- For both tools, only the requester is gated — not the recipient/invitee. A sponsor may direct the agent to share a file with anyone, or invite anyone into a chat;
share_file'srecipient_emailandadd_teams_member's inviteeemailare both unrestricted where the underlying source (Graph/invite, Graph chat membership) permits it. - Wait validation and mutation authorization are separate.
SponsorGate.accepts()decides whether an inbound message can satisfy a sponsor wait. The general background poll delivers human messages without usingSponsorGateas its filter; sponsor lookup is used after successful delivery only to decide whether to mint an active-channel binding. That binding, notSponsorGate, authorizes bothshare_fileandadd_teams_membermutations.
Server-side auto-wait, not a model-facing switch¶
send_teams_message's decision to block for a sponsor reply is made by inspecting the connected MCP client's clientInfo.name server-side (mcp_server.py::_current_host()), against a fixed set of known channel-push hosts. There is no tool parameter the model can set to skip or force this behavior — if there were, an LLM would be free to set it however the current turn's text happens to suggest, which defeats the point of a behavioral guarantee. If a new host needs different auto-wait behavior, that's a server-side allowlist change, not something exposed at the tool boundary.
The _COMMITMENT_PATTERNS outbound-discipline check (also in mcp_server.py) is a warning, not a block: it looks for commitment-sounding language in outbound Teams text without a matching recent add_promise call, and appends a _discipline_warning to the result JSON when it fires. It does not stop the message from sending.
Storage and cursor fail-closed behavior¶
Two storage-layer boundaries are part of the same fail-closed posture as the rest of this page:
- Half-configured Blob storage is a hard error.
storage/backend.py::get_backend()raisesBackendMisconfiguredErrorwhen exactly one ofENTRABOT_BLOB_ENDPOINT/ENTRABOT_BLOB_CONTAINERis set, rather than silently falling back to an empty local store. - Ambiguous cursor reads never authorize a push.
tools/chat_cursors.py::resolve_cursor()classifies every cursor read asABSENT,PRESENT, orUNRESOLVED; onlyABSENT(a clean, successful "nothing here yet" read) allows a poll to treat a chat as new. A read failure, a timeout, or a corrupt payload isUNRESOLVEDand is treated the same as "don't push," because an ambiguous read could be hiding a live cursor.
See Storage and Memory for the full mechanics of both.
Efferent-copy trust boundary¶
The efferent-copy middleware (src/entrabot/efferent_copy.py) is disabled by default — it only wraps tool functions when EFFERENT_COPY_ENABLE=1 is set, and EFFERENT_COPY_DISABLE=1 always wins over the enable flag. When enabled, any MCP peer in .mcp.json that exposes a schema-compatible observe(tool_name, args[, result]) tool is treated as part of the trusted observability boundary — discovery is schema-based, not name-based, so there's no allowlist of specific peer identities beyond EFFERENT_COPY_SINKS if an operator sets one.
Sensitive values are redacted before leaving the body by a case-insensitive, substring-based denylist (token, secret, password, client_secret, access_token, api_key, bearer, credential, private_key, and similar). This is name/key-substring redaction, not semantic content inspection — a sensitive value stored under a non-obvious key name (the module's own example: auth_blob) is not caught by the denylist and can reach a sink unredacted. Treat this as a real but bounded mitigation, not a guarantee that no sensitive data ever reaches an attached sink.
Current limitation: no shipped execution sandbox¶
Entrabot does not currently ship an execution sandbox. Process- and filesystem-level containment remains under evaluation.
See also¶
- Audit Layer — the event schema and attribution resolution in detail.
- Security (XPIA) API Reference — the full public surface of the external-content envelope.
- Identity and Token Flow — the three-hop flow and per-OS certificate signing.
- MCP Runtime — server boot, token refresh, and where these boundaries are wired in.
- Messaging and Delivery — how inbound messages reach the sponsor gate and the active-channel binding.
- Storage and Memory — backend fail-closed behavior and cursor concurrency.