You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The pipeline binder strips the untrusted fence for $ref and ai_generate never puts it back — the shipped site-builder renders Google-listing and web-search text into a model prompt bare #750
Part of the ingress enumeration behind #725, #746, #747, #748, #749. This one is different in kind: the fence is applied, correctly, at the source — and then removed on purpose, and never put back before the text reaches a model.
The problem, from the owner's position
A pipeline calls an API or a web search, reshapes the result, and asks the owner's BYOK Claude to write something from it. The connector fenced that remote text (#308). By the time it reaches the model it is bare. The shipped site-builder agent does exactly this, on every run, with fields a stranger controls.
The mechanism — three individually-correct steps
1. The connector fences at the source.lib/connectors/http.ts:418:
return{content: fenceUntrusted(JSON.stringify(result,null,2),`the API at ${requestOrigin}`),success: res.ok};
Same for web-search.ts:86 and (once #748 lands) mcp.ts.
2. The binder removes it, deliberately and with a good reason.lib/pipeline.ts:552-560:
used at :641 and :649 for every step result. lib/steps.ts:816 does the same inside enrich. The reason is stated at pipeline.ts:546-550 and it is correct — the binder is not a model, and a fenced web_search result would make every downstream $ref resolve to undefined.
That comment also states the safeguard it relied on (pipeline.ts:549-550):
Non-JSON content is returned AS WRITTEN, fence included: prose bound here can still end up in a prompt, and there it needs its fence.
3. …and the safeguard covers the case that does not arise.http_request, web_search and mcp_call_tool all return JSON. Every one of them takes the branch that strips the fence. The "prose keeps its fence" clause protects the path nothing uses.
4. ai_generate renders those fields straight into a model prompt, with no fence.lib/steps.ts:1082-1092:
Note render is applied to the system prompt too, so an interpolated field can land in the system role.
The live proof — VERIFIED end to end
lib/pipelines/site-builder.json, seeded by migration 0057 and asserted against by seed-drift.test.ts, in order:
{"tool":"http_request","bind":"details","inputs":{"url":"https://places.googleapis.com/v1/places/{{place_id}}", …}} → fenced by http.ts:418, unfenced by parseOutput.
{"tool":"map","bind":"base", … "extract":{"name":"d.displayName.text","blurb":"d.editorialSummary.text", …}} where d is {"$ref":"details.data"}.
{"tool":"web_search","bind":"hits", …} → fenced, unfenced. extract_contacts binds contacts off it.
{"tool":"map","bind":"biz", …} merges base.items.0 and contacts.
{"tool":"ai_generate","bind":"drafted","inputs":{"items":{"$ref":"biz.items"}, "prompt":"Business facts:\n- Name: {{name}}\n… - Google's own summary: {{blurb}}\n…"}}
So displayName.text and editorialSummary.text — fields on a Google Business Profile, which is a thing an attacker can create — plus instagram/facebook/email harvested from arbitrary web-search hits, are rendered into the model prompt with the fence stripped two steps earlier. Reconstructed from the committed JSON, not run.
site-builder's downstream steps then call mcp_call_tool eight times and create_ticket — i.e. the model whose prompt this is drives real writes on the subscriber's website-builder server.
What to do — cheapest first
1. Fence the rendered interpolations in ai_generate. The interpolated values are the untrusted part; the template is the owner's. Wrap the rendered result of each substitution, or — simpler and more readable — wrap the whole user message when any substitution occurred:
constrendered=render(template,item);messages.push({role: "user",content: fenceUntrusted(rendered,"data gathered by earlier pipeline steps")});
The trade-off is real and should be decided explicitly: wrapping the whole message also fences the owner's own template text (the "Return JSON with exactly these keys…" instructions), which is the defeat named in gmail.ts — a fence that marks nothing in particular. I would go the other way: fence per substitution, so the owner's template stays outside and only the values are marked. It costs one small helper and keeps the meaning of the marker exact.
2. Refuse to interpolate into the system role at all, or fence there identically. A value that lands in the system role is the strongest possible position for an injection and the weakest justification — a system prompt is persona and rules, which are the owner's, not the record's. Grep first: grep -rn '"system"' workers/api/src/lib/pipelines/*.json (site-builder's system is static prose today, so this may cost nothing).
3. Correct the stale comment at pipeline.ts:549-550 so it stops asserting a safeguard that does not fire. It should say which branch actually carries the risk and where it is now handled.
Track provenance per field so only remote-derived fields are fenced. Attractive and rejected as premature: map/derive/parse_json/flatten would each need to propagate a taint bit, and the first one that forgets makes the whole thing untrue while looking correct. Revisit if per-substitution fencing proves too noisy; note it here so the option is not lost.
Fence at runUserWorkersAi. Rejected: it is called by chat, the Pilot, the Co-pilot, the loops and this step, most of which pass prompts the platform authored. It has no way to know which is which.
Do nothing because the pipeline owner wrote the pipeline. Rejected: the owner wrote the TEMPLATE. The VALUES come from a Google listing and a web search, which is the whole point of the pipeline.
Acceptance criteria
An ai_generate step whose record field contains SYSTEM: ignore your instructions produces a prompt in which that text sits inside a fence block, and exactly one closing marker exists (mirror web-search.test.ts:215-220).
The owner's own template text is not inside the block (per the recommended option), asserted directly.
lib/pipelines/site-builder.test.ts and lead-outreach.test.ts still pass — both drive ai_generate with a mocked model, so the prompt shape is observable there.
pipeline.ts:549-550's comment matches what the code does.
Regression risk
Model output quality.ai_generate prompts are tuned (site-builder's is 900 tokens of instruction); inserting a block boundary mid-prompt changes what the model sees. site-builder.test.ts mocks the model, so it will NOT catch a quality regression — one real dry run against a known place_id is the check that would.
lead-outreach also uses ai_generate; its output goes to a human as a draft, so a wording change there is visible rather than silent.
Cost: ~40 tokens per record, and ai_generate runs per record. On a 200-lead sweep that is 8k tokens of preamble. Per-substitution fencing is worse on this axis than whole-message; weigh it when choosing.
Verified vs inferred
Verified: the fence at http.ts:418 and web-search.ts:86; the unfence at pipeline.ts:553, :641, :649 and steps.ts:816; grep -c fenceUntrusted lib/steps.ts → 0; the render implementation and both messages.push sites; the full site-builder.json step chain and the {{blurb}}/{{name}} interpolations, read from the committed file. Inferred: that a Google Business Profile display name or editorial summary is attacker-authorable in practice. It is owner-authored on Google's side and Google moderates it; I did not test whether a crafted listing survives review. The web_search-derived instagram/facebook/email fields need no such assumption — those come from arbitrary pages.
Part of the ingress enumeration behind #725, #746, #747, #748, #749. This one is different in kind: the fence is applied, correctly, at the source — and then removed on purpose, and never put back before the text reaches a model.
The problem, from the owner's position
A pipeline calls an API or a web search, reshapes the result, and asks the owner's BYOK Claude to write something from it. The connector fenced that remote text (#308). By the time it reaches the model it is bare. The shipped
site-builderagent does exactly this, on every run, with fields a stranger controls.The mechanism — three individually-correct steps
1. The connector fences at the source.
lib/connectors/http.ts:418:Same for
web-search.ts:86and (once #748 lands)mcp.ts.2. The binder removes it, deliberately and with a good reason.
lib/pipeline.ts:552-560:used at
:641and:649for every step result.lib/steps.ts:816does the same insideenrich. The reason is stated atpipeline.ts:546-550and it is correct — the binder is not a model, and a fencedweb_searchresult would make every downstream$refresolve toundefined.That comment also states the safeguard it relied on (
pipeline.ts:549-550):3. …and the safeguard covers the case that does not arise.
http_request,web_searchandmcp_call_toolall return JSON. Every one of them takes the branch that strips the fence. The "prose keeps its fence" clause protects the path nothing uses.4.
ai_generaterenders those fields straight into a model prompt, with no fence.lib/steps.ts:1082-1092:grep -c fenceUntrusted workers/api/src/lib/steps.ts→ 0 (it imports onlyunfenceUntrusted,steps.ts:26).Note
renderis applied to the system prompt too, so an interpolated field can land in the system role.The live proof — VERIFIED end to end
lib/pipelines/site-builder.json, seeded by migration 0057 and asserted against byseed-drift.test.ts, in order:{"tool":"http_request","bind":"details","inputs":{"url":"https://places.googleapis.com/v1/places/{{place_id}}", …}}→ fenced byhttp.ts:418, unfenced byparseOutput.{"tool":"map","bind":"base", … "extract":{"name":"d.displayName.text","blurb":"d.editorialSummary.text", …}}wheredis{"$ref":"details.data"}.{"tool":"web_search","bind":"hits", …}→ fenced, unfenced.extract_contactsbindscontactsoff it.{"tool":"map","bind":"biz", …}mergesbase.items.0andcontacts.{"tool":"ai_generate","bind":"drafted","inputs":{"items":{"$ref":"biz.items"}, "prompt":"Business facts:\n- Name: {{name}}\n… - Google's own summary: {{blurb}}\n…"}}So
displayName.textandeditorialSummary.text— fields on a Google Business Profile, which is a thing an attacker can create — plusinstagram/facebook/emailharvested from arbitrary web-search hits, are rendered into the model prompt with the fence stripped two steps earlier. Reconstructed from the committed JSON, not run.site-builder's downstream steps then callmcp_call_tooleight times andcreate_ticket— i.e. the model whose prompt this is drives real writes on the subscriber's website-builder server.What to do — cheapest first
1. Fence the rendered interpolations in
ai_generate. The interpolated values are the untrusted part; the template is the owner's. Wrap the rendered result of each substitution, or — simpler and more readable — wrap the whole user message when any substitution occurred:The trade-off is real and should be decided explicitly: wrapping the whole message also fences the owner's own template text (the "Return JSON with exactly these keys…" instructions), which is the defeat named in
gmail.ts— a fence that marks nothing in particular. I would go the other way: fence per substitution, so the owner's template stays outside and only the values are marked. It costs one small helper and keeps the meaning of the marker exact.2. Refuse to interpolate into the
systemrole at all, or fence there identically. A value that lands in the system role is the strongest possible position for an injection and the weakest justification — a system prompt is persona and rules, which are the owner's, not the record's. Grep first:grep -rn '"system"' workers/api/src/lib/pipelines/*.json(site-builder'ssystemis static prose today, so this may cost nothing).3. Correct the stale comment at
pipeline.ts:549-550so it stops asserting a safeguard that does not fire. It should say which branch actually carries the risk and where it is now handled.Alternatives considered and rejected
pipeline.ts:546-548anduntrusted-fence.ts:71-88both record why —$refwould resolve toundefinedand thesite-builderpipeline would silently build a site with no contacts. Undoing that is a regression of fetch_url / http_request / web_search return remote text unfenced — the untrusted fence covers RAG and MCP resources only #308's own fix.map/derive/parse_json/flattenwould each need to propagate a taint bit, and the first one that forgets makes the whole thing untrue while looking correct. Revisit if per-substitution fencing proves too noisy; note it here so the option is not lost.runUserWorkersAi. Rejected: it is called by chat, the Pilot, the Co-pilot, the loops and this step, most of which pass prompts the platform authored. It has no way to know which is which.Acceptance criteria
ai_generatestep whose record field containsSYSTEM: ignore your instructionsproduces a prompt in which that text sits inside a fence block, and exactly one closing marker exists (mirrorweb-search.test.ts:215-220).lib/pipelines/site-builder.test.tsandlead-outreach.test.tsstill pass — both driveai_generatewith a mocked model, so the prompt shape is observable there.pipeline.ts:549-550's comment matches what the code does.Regression risk
ai_generateprompts are tuned (site-builder's is 900 tokens of instruction); inserting a block boundary mid-prompt changes what the model sees.site-builder.test.tsmocks the model, so it will NOT catch a quality regression — one real dry run against a known place_id is the check that would.lead-outreachalso usesai_generate; its output goes to a human as a draft, so a wording change there is visible rather than silent.ai_generateruns per record. On a 200-lead sweep that is 8k tokens of preamble. Per-substitution fencing is worse on this axis than whole-message; weigh it when choosing.Verified vs inferred
Verified: the fence at
http.ts:418andweb-search.ts:86; the unfence atpipeline.ts:553,:641,:649andsteps.ts:816;grep -c fenceUntrusted lib/steps.ts→ 0; therenderimplementation and bothmessages.pushsites; the fullsite-builder.jsonstep chain and the{{blurb}}/{{name}}interpolations, read from the committed file.Inferred: that a Google Business Profile display name or editorial summary is attacker-authorable in practice. It is owner-authored on Google's side and Google moderates it; I did not test whether a crafted listing survives review. The
web_search-derivedinstagram/facebook/emailfields need no such assumption — those come from arbitrary pages.