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.ts → 0.
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
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.
- The no-links refusal at
storage-tools.ts:626-629 fences its subject and sender too, or a comment says why not.
- 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.
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 neutralised —
gmail.test.ts:614, asserting exactly one closing marker survives, at the end, with [removed: present.
- Our own words are deliberately NOT fenced —
gmail.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.
find_confirmation_linkreturns attacker-authored text and an instruction to open an attacker-chosen URL, in one unfenced stringworkers/api/src/lib/storage-tools.ts:635-638:subject,fromand every URL inrankedare 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.ts→ 0.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$refoff this result would break.workers/api/src/lib/tools.ts:448already documents this exact hazard for theHTTP …prefix onfetch_urland 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:
The omission this issue found happened because nothing counted the denominator. A guard in the
oauth-scope-drift.test.tsshape — 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-71records a deliberate exclusion:The file id is ours.
:229-235also returnsname: meta.name ?? att.filename, andatt.filenameis the sender's;:198-199,:202and:205interpolate 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
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 matchesunfenceUntrusted.storage-tools.ts:626-629fences its subject and sender too, or a comment says why not.gmail_download_attachmentis settled — the field is fenced, orgmail.ts:69-71is 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
$refoff it.steps.ts:742,:816and:904are the call sites that must keep working. A test that$refs a field off a fencedfind_confirmation_linkresult is what catches it.find_confirmation_linkreaches 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-dogmail_searchandgmail_read_messageare fenced at the connector, viaokUntrusted(gmail.ts:73-76), called at:151and:165. Fencing at the source covers chat, a pipeline step,POST …/tools/:nameand MCP at once.gmail.test.ts:614, asserting exactly one closing marker survives, at the end, with[removed:present.gmail.test.ts:594("No messages matched") and:607(agmail_archiveoutcome). That is the right line and it is pinned.unfenceUntrustedstill unwraps for$ref— generic and separately tested inuntrusted-fence.test.ts; no Gmail-specific assertion exists, which is a small gap and not worth its own ticket.0138's injection rule; this ticket is about the structural fence.