# Prompt injection arrives as ordinary business mail

Everything Connect reads — an enquiry, a WhatsApp message, a transcribed sentence, an uploaded file — is data. Containment is not a filter that recognises attacks; it is that every action worth attacking is gated where the text cannot reach it: autonomy modes, one send boundary that reports nothing as sent without provider evidence, an assistant whose rights are narrower than its user's, and a compliance check no model output clears.

- **Status:** Available
- **Audience:** both, developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/prompt-injection-in-business-mail/

## What the attack actually looks like

It does not arrive labelled. A business agent reads text written by strangers all day, and the injection that matters is an ordinary enquiry with one extra sentence in it — in the signature block, inside a quoted reply, in an HTML comment, in the text layer of an attached quotation, or spoken aloud on a call where there is no formatting to hide in at all.

| Where the text arrives | What it would try to obtain | What stands in the way |
|---|---|---|
| Email body or quoted thread | An outbound message to a third party | `outbound.py` is the one send boundary; autonomy decides whether any outbound goes without a person |
| Signature or HTML comment | A changed standing instruction | `directives()` reads the tag list only, so prose in a message body is never a directive |
| An uploaded document | File reading turned into file writing | Reading and writing are separate tools; a change is a new version with provenance and audit |
| A spoken sentence on a call | A price, discount or promise | `safe_sales` refuses commercial terms Knowledge does not support and escalates |
| A message from a suppressed address | Its own suppression lifted | Suppression, unsubscribe and do-not-contact are checked in one place, and the Assistant cannot clear a do-not-contact entry at all |

## The boundary sits where the authority is, not where the text is

Filtering incoming text for attack phrases is a losing position: the attacker writes the next sentence, and the defender has to have anticipated it. The workable position is that reading a sentence and being permitted to act on it are different things, decided in different places, by code that never sees the sentence.

1. Decide what may happen without a person, per channel and per scope. `autonomy.py` holds four modes — `off`, `draft_only`, `ask_before_send`, `autonomous` — over four scopes, narrowest first: contact, endpoint, channel, workspace.
   - Result: A message that persuades the model to write something still meets a rule deciding whether anything leaves. Under `ask_before_send` the persuasive reply becomes a queue item with the message that prompted it sitting above it.
2. Send through one boundary, and report nothing as sent without the provider's own acknowledgement.
   - Result: There is no second path a tool call can take, and no state where something is believed to have gone out because the model said so.
3. Give the agent narrower rights than the person it works for. The Assistant has no pricing authority and cannot clear a do-not-contact entry.
   - Result: The most valuable outcomes for an attacker are not in the tool set at all, which is a stronger guarantee than a refusal the model has to remember to make.
4. Check the model's picture of the screen against the database before trusting it.
   - Result: Context handed to the model is treated as a claim about state, not as state. What the Assistant acts on is what the record says.

## Things received text is never allowed to do

- Become a standing instruction or a memory directive — those are tagged rows a person creates, and `directives()` reads only tags.
- Clear a suppression, an unsubscribe or a do-not-contact entry.
- Set a price, a discount, an SLA or a warranty that Knowledge does not support.
- Move an outbound message past the autonomy rule in force for its channel and scope.
- Reach another workspace: isolation is enforced three times, in the `customer_safe` allowlist, in the SQLAlchemy workspace kernel and again in PostgreSQL row-level security.
- Supply a person's name. `identity.PROTECTED_MARKERS`, `name_rejected` and `clean_name` guard every door a name passes through, and a form of address is never a name.
- Load remote content into a reader's browser. `mail_render.py` sanitises the HTML and remote images are proxied, so opening a message does not report the open back to its sender.
- Expand on being opened: DOCTYPE and ENTITY declarations and zip bombs are refused by the file service rather than parsed.

## Contained, merely detected, or unmeasured

Two of these are genuinely different guarantees and the distinction is worth keeping. An action outside the tool set cannot be taken. A rule that lives in a prompt can be broken and then noticed.

**Prevented** — Anything requiring authority the agent does not hold: pricing, clearing a do-not-contact entry, sending past an autonomy rule, reading another workspace.
**Detected after the fact** — Commercial claims on the realtime voice path. The rule can only live in the instructions there, so `price_spoken` counts the lines the voice priced, through the same expression the carrier path checks each sentence with.
**Unmeasured** — Resistance rate. There is no injection benchmark, no red-team score and no measured attempt count for this system: UNKNOWN. What is documented is the shape of the boundary, which is testable by reading the tool set rather than by trusting a number.

> **Careful** A document a person uploads and asks about is read by a model, and the instructions inside it are read too. The containment is the same one: the model may summarise it, and any change it proposes is a new version with provenance, not an edit in place.

## Questions

### Could a customer email make Connect send something to a third party?

Not by asking. Any outbound crosses `outbound.py` under the autonomy rule for that channel and scope; under `ask_before_send` it becomes an item in Needs You with the message that prompted it, and under `draft_only` it is written and nothing is even asked. Where a workspace has chosen `autonomous`, the compliance check on suppression and do-not-contact still runs first.

### Does Connect scan incoming messages for injection attempts?

The design does not rest on recognising them. Mail is sanitised for rendering and files are refused for structural attacks, but the sentence itself is treated as data and the actions it might want are gated elsewhere. A page claiming a detection rate for this would be claiming a measurement that does not exist.

### Is an attachment safer than an email body?

Neither is trusted. The file service refuses DOCTYPE and ENTITY declarations and zip bombs before reading anything, then reads the formats it handles with the standard library. Once the text is out, it has exactly the standing of a message body: something to read, not something to obey.

## Related

- [Security and isolation](https://connectbyjbrh.com/docs/security/)
- [Separating the agent's authority from the person's](https://connectbyjbrh.com/research/authority-separation/)
- [Human approval without blocking low-risk work](https://connectbyjbrh.com/research/approval-without-blocking/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Why a sales agent should refuse to answer](https://connectbyjbrh.com/research/safe-refusals-in-sales/)
- [Files and data in Connect](https://connectbyjbrh.com/docs/files-data/)

## What this page is based on

- `docs-source/sources/GENERAL.md` §5, §9 and §10 — autonomy, the file service, and credential handling
- `docs-source/sources/CHANNELS.md` §1 and §6 — the send boundary, compliance, and `safe_sales`
- `docs-source/sources/PHONE.md` §10 — the name guards
- Connect capability registry (docs-source/facts.py) — `assistant_tools`, `suppression_dnc`, `rls_isolation`
