Fencing untrusted text: captured listing titles, company names and calendar locations reach the model unfenced #9

Closed
opened 2026-10-01 10:48:02 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, comparing UNTRUSTED_FIELDS with the API's response schemas and with where each field's text comes from.

Related to #3.

fenced() wraps a string only when its JSON key is in UNTRUSTED_FIELDS, and the list leaves out names and titles on purpose ("Names and dates are left alone"). Several of those short fields are nonetheless written by third parties and can hold a full sentence aimed at the model, so they reach it outside any fence, in the same reply as the write tools.

  • Where: src/postulo_mcp/server.py:47-66 (UNTRUSTED_FIELDS), src/postulo_mcp/server.py:380 (if isinstance(payload, str) and key in UNTRUSTED_FIELDS), src/postulo_mcp/server.py:87-92 (INSTRUCTIONS), tests/test_server.py:256 ("a title is not prose"), README.md:86-102.
  • What happens: these strings are third-party text and are not fenced:
    • title on ListingRef/ListingOut and on search hits: a captured page's og:title or <title> (core plugins/builtin/__init__.py:539), up to 250 characters (core jobs/models.py:873);
    • name on CompanyRef, taken from the same capture;
    • location on InterviewOut, which postulo-dav copies from a shared calendar event, up to 500 characters (postulo-dav src/postulo_dav/sync.py:199-200);
    • location on ListingOut, also from the capture.
      A captured page whose title is an instruction to change statuses or add notes arrives unmarked in list_applications, search_postings, search and get_application. INSTRUCTIONS tell the model what fenced text is, which invites it to read unfenced text as the person's own.
  • Who can trigger it: whoever writes a page the person captures, or an event on a calendar the person syncs. It matters only when the server runs with --write and the model follows text it reads; the README rightly calls the fence "a seatbelt, not a cage".
  • Why it matters: the fence exists to mark text from outside the person's records, and the fields an outside party controls most directly (a page title, a meeting location) are the ones it skips.
  • Fix: fence, or at least neutralise, the third-party short fields while keeping names readable: for title, name and location on listings, companies, interviews and search hits, collapse newlines and strip anything resembling a fence marker, and fence the value when it is longer than a short threshold (say 80 characters) or contains a newline. Add a sentence to INSTRUCTIONS saying that names, titles and locations can also come from captured pages and calendars and are never instructions either. Change the assertion at tests/test_server.py:256, which currently pins the gap in place.

Done when

  • A test in tests/test_server.py gives a listing a title and an interview a location that each carry an instruction with a newline, calls search_postings and list_interviews, and finds neither text unmarked and neither newline intact.
Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, comparing `UNTRUSTED_FIELDS` with the API's response schemas and with where each field's text comes from. Related to #3. `fenced()` wraps a string only when its JSON key is in `UNTRUSTED_FIELDS`, and the list leaves out names and titles on purpose ("Names and dates are left alone"). Several of those short fields are nonetheless written by third parties and can hold a full sentence aimed at the model, so they reach it outside any fence, in the same reply as the write tools. - **Where:** `src/postulo_mcp/server.py:47-66` (`UNTRUSTED_FIELDS`), `src/postulo_mcp/server.py:380` (`if isinstance(payload, str) and key in UNTRUSTED_FIELDS`), `src/postulo_mcp/server.py:87-92` (`INSTRUCTIONS`), `tests/test_server.py:256` (`"a title is not prose"`), `README.md:86-102`. - **What happens:** these strings are third-party text and are not fenced: - `title` on `ListingRef`/`ListingOut` and on search hits: a captured page's `og:title` or `<title>` (core `plugins/builtin/__init__.py:539`), up to 250 characters (core `jobs/models.py:873`); - `name` on `CompanyRef`, taken from the same capture; - `location` on `InterviewOut`, which postulo-dav copies from a shared calendar event, up to 500 characters (postulo-dav `src/postulo_dav/sync.py:199-200`); - `location` on `ListingOut`, also from the capture. A captured page whose title is an instruction to change statuses or add notes arrives unmarked in `list_applications`, `search_postings`, `search` and `get_application`. `INSTRUCTIONS` tell the model what fenced text is, which invites it to read unfenced text as the person's own. - **Who can trigger it:** whoever writes a page the person captures, or an event on a calendar the person syncs. It matters only when the server runs with `--write` and the model follows text it reads; the README rightly calls the fence "a seatbelt, not a cage". - **Why it matters:** the fence exists to mark text from outside the person's records, and the fields an outside party controls most directly (a page title, a meeting location) are the ones it skips. - **Fix:** fence, or at least neutralise, the third-party short fields while keeping names readable: for `title`, `name` and `location` on listings, companies, interviews and search hits, collapse newlines and strip anything resembling a fence marker, and fence the value when it is longer than a short threshold (say 80 characters) or contains a newline. Add a sentence to `INSTRUCTIONS` saying that names, titles and locations can also come from captured pages and calendars and are never instructions either. Change the assertion at `tests/test_server.py:256`, which currently pins the gap in place. ## Done when - A test in `tests/test_server.py` gives a listing a title and an interview a location that each carry an instruction with a newline, calls `search_postings` and `list_interviews`, and finds neither text unmarked and neither newline intact.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:48:02 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-mcp#9
No description provided.