Skip to content

Gmail message bodies reach the model unfenced — the one untrusted source not wrapped in the data-not-instructions fence, and the seeded email agents carry no prompt-level fallback either #725

Description

@serge-ivo

Rewritten 2026-08-23 by the backlog audit. The two Gmail connector tools shipped in 32f0f414 and are re-verified below in "Delivered — do not re-do". What remains is find_confirmation_link and the guard that would have prevented the omission. Original text in the edit history; the delivery assessment is in the audit comment.

find_confirmation_link returns attacker-authored text and an instruction to open an attacker-chosen URL, in one unfenced string

workers/api/src/lib/storage-tools.ts:635-638:

return ok(
    call.name,
    `Found email "${match.subject}" from ${match.from} (${match.date}).\nMost likely confirmation link: ${ranked[0]}\nOther links: ${ranked.slice(1, 4).join(", ") || "none"}\nOpen the confirmation link with a browser.open runner task to complete verification.`,
);

subject, from and every URL in ranked are written by whoever sent the message. The final sentence tells the model to open one of them. grep -n fenceUntrusted workers/api/src/lib/storage-tools.ts0.

The refusal one branch up carries the same two fields (:626-629):

`Found email "${match.subject}" from ${match.from} but it contained no links.`

This is a permission-gated tool (AgentState.permissions.email), so it is narrower than the connector tools were — but it is the one whose result is an instruction, which is why the original issue singled it out.

The sharp edge, stated because getting it wrong is silent

The instruction sentence must end up outside the fence. unfenceUntrusted's regex is anchored; a fence with trailing prose stops matching it, so a pipeline $ref off this result would break. workers/api/src/lib/tools.ts:448 already documents this exact hazard for the HTTP … prefix on fetch_url and is the shape to copy: platform prose outside, third-party text inside.

No guard stops the next connector repeating this

There is no cross-connector assertion that a tool returning third-party content fences it. Every existing fence test is per-connector:

$ grep -rln 'unfenced\|must fence' workers/api/src
workers/api/src/lib/untrusted-fence.ts
workers/api/src/lib/untrusted-fence.test.ts
workers/api/src/lib/tools.test.ts
workers/api/src/lib/connectors/web-search.test.ts
workers/api/src/lib/connectors/mcp.ts

The omission this issue found happened because nothing counted the denominator. A guard in the oauth-scope-drift.test.ts shape — a per-connector list of tools that return third-party prose, failing when a listed one does not fence — is what makes it countable. Per ADR 0002 G2 its success line should name the number of tools it examined.

Open question, stated rather than picked

gmail_download_attachment. workers/api/src/lib/connectors/gmail.ts:69-71 records a deliberate exclusion:

gmail_download_attachment's file id are OUR words about an outcome, and fencing those would teach the model that a fence means nothing in particular.

The file id is ours. :229-235 also returns name: meta.name ?? att.filename, and att.filename is the sender's; :198-199, :202 and :205 interpolate it into three refusal strings. Either fence the field, or correct the recorded reason to describe the payload that ships — a comment that under-describes what it excuses is how the next exclusion gets waved through. I would correct the comment and leave the field: a filename is a short string with little room for prose, and fencing a mostly-ours payload is exactly what the comment warns against. Owner's call.

Acceptance criteria

  1. find_confirmation_link's message-derived text (subject, sender, every URL) is inside one fence; the "Open the confirmation link with a browser.open runner task" sentence is outside it, and the result still matches unfenceUntrusted.
  2. The no-links refusal at storage-tools.ts:626-629 fences its subject and sender too, or a comment says why not.
  3. A guard test fails when a connector tool that returns third-party content does not fence it, and its success line states how many tools it examined.
  4. gmail_download_attachment is settled — the field is fenced, or gmail.ts:69-71 is corrected to describe the whole result.

Regression risk

The anchored-regex hazard in §1 is the only real one, and it is silent: a fenced result with trailing prose still looks right in a transcript while breaking every $ref off it. steps.ts:742, :816 and :904 are the call sites that must keep working. A test that $refs a field off a fenced find_confirmation_link result is what catches it. find_confirmation_link reaches no seeded pipeline today (it is chat/workflow-only), so the blast radius is small — but a user-defined pipeline could already call it.


Delivered by 32f0f414 — verified 2026-08-23, do not re-do

  • gmail_search and gmail_read_message are fenced at the connector, via okUntrusted (gmail.ts:73-76), called at :151 and :165. Fencing at the source covers chat, a pipeline step, POST …/tools/:name and MCP at once.
  • A closing marker smuggled into a Subject line is neutralisedgmail.test.ts:614, asserting exactly one closing marker survives, at the end, with [removed: present.
  • Our own words are deliberately NOT fencedgmail.test.ts:594 ("No messages matched") and :607 (a gmail_archive outcome). That is the right line and it is pinned.
  • unfenceUntrusted still unwraps for $ref — generic and separately tested in untrusted-fence.test.ts; no Gmail-specific assertion exists, which is a small gap and not worth its own ticket.
  • The prompt-level fallback question is settled by 0138's injection rule; this ticket is about the structural fence.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingconnectorsConnector + tool frameworksecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions