Repository navigation
fix: fence the Date header, and tell the model what the fences mean - #24
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #22.
Two halves of one control.
1.
Date:reached the model outside every fenceDate:was the only attacker-controlled field that never passed throughfenceEmailContent/fenceEmailHeader, in all four read paths:search_emailsread.ts:27—(${m.date})read_emailread.ts:51—**Date:** ${msg.date}read_threadread.ts:77—**Date:** ${m.date}inbox_summaryread.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 defeatescapeFenceTags; it uses the one field the escaper never saw.IMAP and JMAP were never affected:
imap.tsbuildsdatefrom a parsedDateobject, and JMAP uses the server-generatedreceivedAt.fenceEmailHeaderalready 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.mdoutsidesanitize.tsand its call sites returned nothing, and theServerwas constructed withoutinstructions. 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 theinitializeresult, 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:
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-outputnot.toContainwould have passed for the wrong reason.The default mock returns
recent: [], so theinbox_summarydate path needed a recent message added to be exercised at all.Suite
256 passedacross 22 files, excludingtests/signal-resilience.test.ts. That file passes4/4when 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 unmodifiedmainon this machine before I touched anything, so it is unrelated to this change.Verified against a real Gmail account as well:
read_emailrendersHappy to split this into two commits or two PRs if you would rather take the fence fix and the
instructionstext separately.