feat(security): XPIA content wrapping at the tool boundary - #100
Merged
Conversation
Brandon Werner (brandwe)
force-pushed
the
feat/xpia-content-wrapping
branch
from
July 10, 2026 22:59
6cf2f6d to
b56b95c
Compare
There was a problem hiding this comment.
Pull request overview
Implements boundary-enforced “XPIA” wrapping for attacker-controllable tool returns by enclosing external bodies (Teams messages, email bodies, file text, A365 Word/ODSP text, and inbound interaction-log summaries) in a machine-checkable <external_content ...>...</external_content> envelope, and updates the body prompt/docs to instruct the model to treat the envelope contents as data (not instructions). This aims to close passive instruction-injection via tool returns.
Changes:
- Add
entrabot.security.xpiawithwrap_external/unwrap_externalimplementing idempotent wrapping, attribute escaping, and escape-on-collision for embedded</external_content>variants. - Wire wrapping into read paths (
read_teams_messages,read_email,read_file, A365 Word/ODSP reads) and add an inbound-onlycontent_wrappedfield forread_interactions. - Add deny-list “write-shaped tool name” recognizer (
entrabot.tools.dispatch) plus extensive unit/regression tests and supporting doc/status updates.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| TODOS.md | Notes status update for read-tool content wrapping work item. |
| tests/tools/test_watch.py | Adjusts watch replies assertions for wrapped Teams content. |
| tests/tools/test_teams.py | Updates Teams read tests and adds dedicated XPIA wrapping tests. |
| tests/tools/test_read_interactions.py | Adds tests for new content_wrapped on inbound interaction entries. |
| tests/tools/test_read_email.py | Updates email tests to assert wrapped bodies + adds hostile-tag/env-flag cases. |
| tests/tools/test_files.py | Updates file-read tests to assert wrapped text + unwrap-based truncation checks. |
| tests/tools/test_dispatch.py | New tests pinning write-shaped tool-name recognizer behavior. |
| tests/test_config.py | Adds tests for ENTRABOT_XPIA_WRAP_ENABLE parsing in config. |
| tests/security/test_xpia_wrap.py | New unit tests for envelope correctness, escaping, idempotency, round-trip, env flag. |
| tests/security/init.py | Initializes tests.security package. |
| tests/a365/test_word.py | Updates Word content tests for wrapped content_html + hostile-tag/env-flag cases. |
| tests/a365/test_odsp.py | Updates ODSP small-text tests for wrapped content + hostile-tag/env-flag cases. |
| src/entrabot/tools/teams.py | Wraps Teams message bodies at the tool boundary; keeps metadata outside envelope. |
| src/entrabot/tools/read_interactions.py | Adds inbound-only content_wrapped via _annotate_inbound_with_xpia_wrap. |
| src/entrabot/tools/files.py | Wraps extracted file text after truncation; sets envelope source from webUrl or drive/item. |
| src/entrabot/tools/email.py | Wraps email body content in envelope while preserving other metadata. |
| src/entrabot/tools/dispatch.py | New write-shaped tool-name recognizer + debug registration helper. |
| src/entrabot/security/xpia.py | New core XPIA envelope implementation (wrap/unwrap + collision escaping). |
| src/entrabot/security/init.py | New security package marker + rationale docstring. |
| src/entrabot/config.py | Adds xpia_wrap_enable env parsing to config. |
| src/entrabot/a365/word.py | Wraps Word document HTML content returned from Work IQ boundary. |
| src/entrabot/a365/odsp.py | Wraps ODSP small text file content returned from Work IQ boundary. |
| prompts/anatomy/security.md | Adds body-prompt rule: treat <external_content> contents as data, refuse directives inside. |
| docs/runbooks/hard-won-learnings.md | Adds Learning #70 documenting the XPIA wrapping defense and rationale. |
| docs/engineering-status.md | Updates overall status and recent-changes list to include XPIA wrapping work. |
Wraps every read-tool return in a machine-checkable <external_content>
envelope so the LLM treats external content as data, not instructions.
Closes the passive-injection gap for Teams messages, email bodies,
files, and A365 documents.
* New module `src/entrabot/security/xpia.py`:
- `wrap_external(body, *, source, sender, received_at)` — idempotent,
escape-on-collision on literal `</external_content>` (case- and
whitespace-tolerant), escapes `<`, `>`, `&` in attribute values.
- `unwrap_external()` — byte-for-byte roundtrip for tests and audit.
- `ENTRABOT_XPIA_WRAP_ENABLE=false` disables (default `true`). Read
at call time so operators can toggle without reboot.
* Wired into every read-tool return site:
- `tools/teams.py::read` — `source=teams:<chat_id>`, sender from
UPN/mail/oid. `content` and `content_text` wrapped per entry.
Metadata (message_id, sender_id, sender, sent_at, reply_to_ids,
attachments) stays outside the envelope.
- `tools/email.py::read_email` — `source=email:<message_id>`.
- `tools/files.py::read_file` — `source=file:<web_url>`. Truncation
applied to inner text pre-wrap.
- `a365/word.py::get_document_content` — `source=file:<url>#word`.
- `a365/odsp.py::read_small_text_file` — `source=a365:<file_id>`.
Binary reader left unwrapped.
- `tools/read_interactions.py` — new `content_wrapped` field on
inbound entries (not mutating `summary`, which is a stable
120-char preview relied on by ~20 existing tests).
* New module `src/entrabot/tools/dispatch.py`:
- Write-shaped tool-name recognizer using deny-list regex
`^(send|reply|create|delete|upload|share|add_|resolve_)`.
Broadened from the plan's narrow `add_(?:member|comment)` to
plain `add_` to catch the real shipped names
(add_teams_member, add_file_comment, add_word_comment, add_promise).
- `log_registration_if_write_shaped` — debug log at registration
so new write-shaped tools surface in startup logs. No behavior
change; observational only.
* Body prompt: new "Mechanical envelope for external content" bullet
under Instruction-injection defense in `prompts/anatomy/security.md`.
Restart entrabot MCP to pick it up.
* Learning #70 added to `hard-won-learnings.md`: "Instruction-injection
defense is boundary-enforced, not model-enforced." Sibling to #67
(attacker-controllable tool args) and #69 (identity by UPN).
Full suite: 1567 -> 1652 (+85 tests). Ruff clean.
- `tests/security/test_xpia_wrap.py` — 28 tests: envelope shape,
optional-attribute omission, case-insensitive escape,
ampersand/angle-bracket attribute escaping, idempotency, roundtrip
(adversarial + Unicode corpus, 13 inputs), env-flag disables wrap.
- `tests/tools/test_dispatch.py` — 37 tests pinning the deny-list
pattern against every shipped tool name and hypothetical variants.
- Extensions to existing tool tests confirming wrapped bodies +
unwrapped metadata across Teams, email, files, Word, A365 text,
interaction log.
- `hypothesis` fuzz replaced with a parametrized adversarial +
Unicode corpus. Avoids adding a new dev-dep; same regression
surface.
- `read_interactions` adds a new `content_wrapped` field rather than
mutating `summary`. Preserves the append-only schema promise.
- Deny-list regex broadened from `add_(?:member|comment)` to `add_`
as noted above.
- Case-preserving escape so `unwrap` roundtrips byte-for-byte on
adversarial variants like `</EXTERNAL_CONTENT>`.
Design doc: `docs/architecture/PLAN-xpia-content-wrapping.md` (lands
in PR #99).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brandon Werner (brandwe)
force-pushed
the
feat/xpia-content-wrapping
branch
from
July 11, 2026 00:16
7d4ee57 to
607f12b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wraps every read-tool return in a machine-checkable
<external_content>envelope so the LLM treats external content as data, not instructions. Closes the passive-injection gap for Teams messages, email bodies, files, and A365 documents.Best Short Term Feature #1 from the Entrabot New Features master plan (see PR #99 for the design docs). Sibling to Learning #67 (attacker-controllable tool arguments) and Learning #69 (identity by UPN) — same principle applied to tool returns.
What's in the envelope
<,>,&.</external_content>(case- and whitespace-tolerant).Where it wires in
read_teams_messagesteams:<chat_id>read_emailemail:<message_id>read_filefile:<web_url>read_word_documentfile:<url>#wordread_a365_text_filea365:<file_id>read_interactionsinbound entriesteams:<chat_id>Binary A365 reads are left unwrapped.
Deviations from spec
hypothesisfuzz replaced with a parametrized adversarial + Unicode corpus (13 inputs). Same regression surface without a new dev-dep.read_interactionsadds a newcontent_wrappedfield rather than mutatingsummary(which is a stable 120-char preview relied on by existing tests).add_(?:member|comment)toadd_so the shippedadd_teams_member/add_file_comment/add_word_comment/add_promisetools actually match.unwraproundtrips byte-for-byte on adversarial variants like</EXTERNAL_CONTENT>.Rollback
ENTRABOT_XPIA_WRAP_ENABLE=falseand restart. Wrappers become identity functions.git revert <sha>.Test plan
tests/security/test_xpia_wrap.py— 28 tests: envelope shape, escape-on-collision (case-insensitive), attribute escaping, idempotency, byte-for-byte roundtrip on adversarial + Unicode corpus, env-flag disables wrap.tests/tools/test_dispatch.py— 37 tests pinning the deny-list against every shipped tool name.Notes
docs/architecture/PLAN-xpia-content-wrapping.mdlands in docs: master plan for new features + XPIA wrapping and skills layer designs #99 (Entrabot New Features master plan + XPIA + Skills designs). This PR references that file by path; the path will exist after docs: master plan for new features + XPIA wrapping and skills layer designs #99 merges.🤖 Generated with Claude Code