Skip to content

fix: fence the Date header, and tell the model what the fences mean - #24

Merged
jgalea merged 1 commit into
jgalea:mainfrom
Kaliratech:fix/fence-date-header
Sep 3, 2026
Merged

jgalea merged 1 commit into
jgalea:mainfrom
Kaliratech:fix/fence-date-header

Conversation

@Kaliratech

Copy link
Copy Markdown
Contributor

Fixes #22.

Two halves of one control.

1. Date: reached the model outside every fence

Date: was the only attacker-controlled field that never passed through fenceEmailContent/fenceEmailHeader, in all four read paths:

Path Line before
search_emails read.ts:27 — (${m.date})
read_email read.ts:51 — **Date:** ${msg.date}
read_thread read.ts:77 — **Date:** ${m.date}
inbox_summary read.ts:97 — (${m.date})

Gmail preserves a sender's Date: header as written, so a sender could put a literal [/UNTRUSTED_EMAIL_CONTENT] plus arbitrary text in it and have that text render in unmarked territory, adjacent to trusted-looking **Subject:** and **Date:** labels. The attack does not defeat escapeFenceTags; it uses the one field the escaper never saw.

IMAP and JMAP were never affected: imap.ts builds date from a parsed Date object, and JMAP uses the server-generated receivedAt.

fenceEmailHeader already accepts an arbitrary field name, so this needed no new helper.

2. The fences were never explained to the model

grep -rn UNTRUSTED src/ README.md outside sanitize.ts and its call sites returned nothing, and the Server was constructed without instructions. A model that has never been told what [UNTRUSTED_EMAIL_CONTENT] means has no reason to treat the contents as data, so the markers were a label rather than a control. The rule now ships in the initialize result, which is what that field is for.

Wording is deliberately about capability rather than politeness: it names the write verbs (send, forward, delete, trash, label, filter, re-authenticate) because those are the ones an injected instruction would reach for.

Tests

Two added to tests/tools/read.test.ts, in the file's existing style.

Both fail against the unpatched tree, which I checked rather than assumed:

× read tools > fences the date in every read path
  → expected '**msg-1** | [UNTRUSTED_FROM]\nsender@…' to contain '[UNTRUSTED_DATE]'
× read tools > a fence marker forged inside the Date header cannot escape its fence
  → expected undefined to be defined

The hostile-date test asserts against the extracted date block, not the whole output. The body's own closing fence is a legitimate [/UNTRUSTED_EMAIL_CONTENT] elsewhere in the same string, so a whole-output not.toContain would have passed for the wrong reason.

The default mock returns recent: [], so the inbox_summary date path needed a recent message added to be exercised at all.

Suite

256 passed across 22 files, excluding tests/signal-resilience.test.ts. That file passes 4/4 when run on its own but is timing-sensitive under parallel load (it spawns servers and waits on 500ms windows); it failed the same way on unmodified main on this machine before I touched anything, so it is unrelated to this change.

Verified against a real Gmail account as well: read_email renders

**Date:** [UNTRUSTED_DATE]
Fri, 14 Aug 2026 22:39:24 +0800
[/UNTRUSTED_DATE]

Happy to split this into two commits or two PRs if you would rather take the fence fix and the instructions text separately.

Two halves of one control, both from jgalea#22.

The Gmail `Date:` header was the only attacker-controlled field that never
passed through `fenceEmailContent`/`fenceEmailHeader`, so it reached the model
outside every fence in all four read paths (search_emails, read_email,
read_thread, inbox_summary). Gmail preserves a sender's `Date:` verbatim, so a
sender could emit a literal `[/UNTRUSTED_EMAIL_CONTENT]` followed by arbitrary
text that landed in unmarked territory next to trusted-looking `**Subject:**`
and `**Date:**` labels. `escapeFenceTags` never saw it. IMAP and JMAP are
unaffected: they build `date` from a parsed Date and from server-side
`receivedAt` respectively.

`fenceEmailHeader` already takes an arbitrary field name, so this needs no new
helper.

Second, the markers themselves were never explained to any model: the string
"UNTRUSTED" appeared nowhere outside sanitize.ts and its call sites, and the
Server was constructed without `instructions`. A fence only works if the reader
knows the rule, so the rule now ships in the initialize result.

Tests: two added to tests/tools/read.test.ts. Both fail against the unpatched
tree (`expected '**msg-1** | [UNTRUSTED_FROM]...' to contain '[UNTRUSTED_DATE]'`)
and pass with it. The hostile-date test asserts against the date block only,
since the body's own closing fence is a legitimate occurrence of that literal
elsewhere in the output and a whole-output check would pass for the wrong
reason.

Suite: 256 passed excluding tests/signal-resilience.test.ts, which passes 4/4
on its own but is timing-sensitive under parallel load on a busy machine.
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.

Gmail Date: header reaches the model outside the UNTRUSTED fences (all four read tools), and the fences are never explained to the model

2 participants