Skip to content

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

Description

@serge-ivo

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:

function parseOutput(content: string): unknown {
    const t = unfenceUntrusted(content).trim();

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:

const render = (t: string, item: Record<string, unknown>): string =>
    t.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, k: string) => {
        const v = getPath(item, k);
        return v == null ? "" : String(v);
    });

if (system) messages.push({ role: "system", content: render(system, item) });
messages.push({ role: "user", content: render(template, item) });

grep -c fenceUntrusted workers/api/src/lib/steps.ts0 (it imports only unfenceUntrusted, steps.ts:26).

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:

  1. {"tool":"http_request","bind":"details","inputs":{"url":"https://places.googleapis.com/v1/places/{{place_id}}", …}} → fenced by http.ts:418, unfenced by parseOutput.
  2. {"tool":"map","bind":"base", … "extract":{"name":"d.displayName.text","blurb":"d.editorialSummary.text", …}} where d is {"$ref":"details.data"}.
  3. {"tool":"web_search","bind":"hits", …} → fenced, unfenced. extract_contacts binds contacts off it.
  4. {"tool":"map","bind":"biz", …} merges base.items.0 and contacts.
  5. {"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:

const rendered = 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.

Alternatives considered and rejected

  • Stop unfencing in the binder. Rejected outright: pipeline.ts:546-548 and untrusted-fence.ts:71-88 both record why — $ref would resolve to undefined and the site-builder pipeline 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.
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingpipelinesPipelines, steps, triggers and the event pump — what CALLS a connectorsecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions