Skip to content

feat(security): XPIA content wrapping at the tool boundary - #100

Merged
Brandon Werner (brandwe) merged 3 commits into
mainfrom
feat/xpia-content-wrapping
Jul 11, 2026
Merged

feat(security): XPIA content wrapping at the tool boundary#100
Brandon Werner (brandwe) merged 3 commits into
mainfrom
feat/xpia-content-wrapping

Conversation

@brandwe

Copy link
Copy Markdown
Member

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 source="teams:19:..." sender="alice@example.com" received_at="2026-07-09T18:05:36Z">
body text
</external_content>
  • Attributes escape <, >, &.
  • Body escape-on-collision on literal </external_content> (case- and whitespace-tolerant).
  • Idempotent (double-wrap is a no-op).
  • Application-generated metadata (message ids, sender IDs, timestamps, counts) stays outside the envelope so downstream tools and the model can still filter and count without ingesting body content.

Where it wires in

Tool Envelope source
read_teams_messages teams:<chat_id>
read_email email:<message_id>
read_file file:<web_url>
read_word_document file:<url>#word
read_a365_text_file a365:<file_id>
read_interactions inbound entries teams:<chat_id>

Binary A365 reads are left unwrapped.

Deviations from spec

  • hypothesis fuzz replaced with a parametrized adversarial + Unicode corpus (13 inputs). Same regression surface without a new dev-dep.
  • read_interactions adds a new content_wrapped field rather than mutating summary (which is a stable 120-char preview relied on by existing tests).
  • Deny-list regex broadened from add_(?:member|comment) to add_ so the shipped add_teams_member / add_file_comment / add_word_comment / add_promise tools actually match.
  • Case-preserving escape so unwrap roundtrips byte-for-byte on adversarial variants like </EXTERNAL_CONTENT>.

Rollback

  • Set ENTRABOT_XPIA_WRAP_ENABLE=false and restart. Wrappers become identity functions.
  • Full revert: git revert <sha>.

Test plan

  • Full suite: 1567 → 1652 (+85). Ruff clean.
  • 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.
  • Extensions to Teams, email, files, Word, A365 text, and interaction-log tool tests confirming wrapped bodies + unwrapped metadata.
  • Restart entrabot MCP after landing (body-prompt change is loaded at boot).

Notes

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.xpia with wrap_external / unwrap_external implementing 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-only content_wrapped field for read_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.

Comment thread src/entrabot/security/xpia.py Outdated
Comment thread src/entrabot/config.py
Comment thread tests/security/test_xpia_wrap.py Outdated
Brandon Werner (brandwe) and others added 3 commits July 10, 2026 17:15
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>
@brandwe
Brandon Werner (brandwe) merged commit 1d76a08 into main Jul 11, 2026
5 checks passed
@brandwe
Brandon Werner (brandwe) deleted the feat/xpia-content-wrapping branch July 11, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants