Part of the ingress enumeration that #725 and #746 come from. This one is the sharpest of the set, because the fenced path and the unfenced path are 40 lines apart in the same module, over the identical corpus.
The problem, from the owner's position
Repo Chat indexes a GitHub repo into the instance's vector store. The owner asks a question. Two things can happen:
- The chat turn's automatic RAG block retrieves the same chunks → they arrive fenced, and the model is told never to obey what is inside them.
- The model calls
search_knowledge or read_knowledge for the same chunks → they arrive bare.
Same bytes, same source, same turn, opposite treatment. Which one the owner gets depends on whether the model decided to call a tool.
Where it is — VERIFIED
grep -c fenceUntrusted workers/api/src/lib/storage-tools.ts → 0.
lib/retrieval.ts holds both halves:
- Fenced —
lib/retrieval.ts:136 (ragContextOrNotice, the automatic block):
return context ? fenceUntrusted(context, "documents/URLs/repos/webhooks") : "";
- Not fenced —
lib/retrieval.ts:99-113 (searchKnowledgeFor, the tool path) returns { ok: true, results } raw. Its one caller, lib/storage-tools.ts:273-279, stringifies them straight into the tool result:
`${JSON.stringify(found.results, null, 2)}\n\nTo read around a file match and quote it exactly: read_file with id=sourceId and offset ≈ …`
VectorSearchResult.text (agent-storage-types.ts:43) is the chunk text itself.
The other doc readers are bare too:
lib/storage-tools.ts:293 — read_knowledge
return ok(call.name, JSON.stringify({ id: doc.id, title: doc.title, content: doc.content }, null, 2));
lib/storage-tools.ts:282-287 — list_knowledge (titles).
lib/storage-tools.ts:349-382 — read_file (extracted document text, windowed).
The corpus is untrusted by the platform's own account. agent-think.ts:296 names it — "documents, ingested URLs, repo files and public webhook payloads, any of which an attacker can author" — and routes/public.ts:260-262 says of the unauthenticated ingest route:
it ingests third-party content (Zapier/Make/n8n payloads) straight into an instance's vector store, which is the untrusted-content path (#263).
Nothing re-fences downstream: agent-think.ts:994-997 (record) only caps length.
Reachability — MEASURED on production, 2026-08-23
GET /v1/instances/267286d5-6877-4f2a-8b5c-3c40b3e3be85/tools (Repo Chat), read-only:
search_knowledge read allowed=True ok
read_knowledge read allowed=True ok
list_knowledge read allowed=True ok
fetch_url read allowed=True ok
That is the exact chain agent-think.ts:296 says the fence exists to stop — "chain read-tools + fetch_url into an exfiltration of the owner's private data" — with the read-tool half unfenced. search_knowledge/read_knowledge are declared on 2 live instances; read_file on 1; fetch_url is a BASE tool and is on essentially all of them.
Mechanism
searchKnowledgeFor and ragContextOrNotice were split so the tool path could report a retrieval outage honestly (#628). The split is right. What went with it, silently, is that the fence lived on only one branch: buildRAGContext returns a prose block that ragContextOrNotice wraps, while searchKnowledgeFor returns a typed array that its caller stringifies — so there was no single "the retrieved text" string for the fence to sit on when the second path was written.
Compounding it: storage-tools.ts is not a connector, so it is outside the vocabulary of both #263 and #308, and outside security-invariants.test.ts:400's four-entry FENCES_REMOTE_TEXT map. It was never a candidate for the guard, because the guard's list is of connector modules.
What to do — cheapest first
1. Fence the three doc readers at the tool result. Origin strings should say what the source actually is, since the corpus is mixed:
// search_knowledge
return ok(call.name, fenceUntrusted(JSON.stringify(found.results, null, 2), "documents/URLs/repos/webhooks indexed in this agent's knowledge base") + "\n\nTo read around a file match…");
Keep the platform's own "To read around a file match…" hint outside the block — it is our instruction and the model must obey it. Same treatment for read_knowledge (:293) and read_file (:349-382); list_knowledge carries titles only and is the weakest case — include it or say why not.
2. Do NOT fence add_knowledge/update_knowledge results. Those are the platform reporting an outcome; fencing a confirmation teaches the model a fence marks nothing in particular, which is the failure lib/connectors/gmail.ts:60-70 warns about.
3. Add lib/storage-tools.ts to the guard. See the enumeration/ADR issue filed alongside; do not block this fix on it.
Alternatives considered and rejected
Acceptance criteria
search_knowledge, read_knowledge and read_file results contain exactly one <untrusted_reference_material …> block, around the retrieved text only.
- A doc whose content contains
</untrusted_reference_material> does not close the block early (assert exactly one closing marker) — mirroring untrusted-fence.test.ts:23-27.
- The platform's own trailing hint on
search_knowledge is outside the block.
RETRIEVAL_EMPTY_MESSAGE and the fail(...) paths are NOT fenced (they are platform statements).
Regression risk
- The MCP surface exposes
search_instance_knowledge / read_knowledge-equivalents to an external client (Claude Desktop). Fencing at the source means those clients now see the marker. That is correct — the same remote text is untrusted there too — but it is a visible change to workers/mcp output; check platform-docs/mcp.md does not promise a bare payload.
- Prompt size: ~40 tokens per call.
- Test to catch a regression: a case in
lib/storage-tools.test.ts asserting the fence on all three readers, plus the marker-neutralisation case.
Verified vs inferred
Verified: the grep (0 in storage-tools.ts), every file:line above, that VectorSearchResult.text is the chunk body, that the same vectorSearch backs both paths (agent-storage/context.ts:77 for the fenced one, retrieval.ts:105 for the bare one), the codebase's own labelling of the ingest route as untrusted, and the live tool verdicts on Repo Chat.
Inferred: nothing load-bearing. No injected document was planted and no agent was driven — read-only sweep.
Part of the ingress enumeration that #725 and #746 come from. This one is the sharpest of the set, because the fenced path and the unfenced path are 40 lines apart in the same module, over the identical corpus.
The problem, from the owner's position
Repo Chat indexes a GitHub repo into the instance's vector store. The owner asks a question. Two things can happen:
search_knowledgeorread_knowledgefor the same chunks → they arrive bare.Same bytes, same source, same turn, opposite treatment. Which one the owner gets depends on whether the model decided to call a tool.
Where it is — VERIFIED
grep -c fenceUntrusted workers/api/src/lib/storage-tools.ts→ 0.lib/retrieval.tsholds both halves:lib/retrieval.ts:136(ragContextOrNotice, the automatic block):lib/retrieval.ts:99-113(searchKnowledgeFor, the tool path) returns{ ok: true, results }raw. Its one caller,lib/storage-tools.ts:273-279, stringifies them straight into the tool result:`${JSON.stringify(found.results, null, 2)}\n\nTo read around a file match and quote it exactly: read_file with id=sourceId and offset ≈ …`VectorSearchResult.text(agent-storage-types.ts:43) is the chunk text itself.The other doc readers are bare too:
lib/storage-tools.ts:293—read_knowledgelib/storage-tools.ts:282-287—list_knowledge(titles).lib/storage-tools.ts:349-382—read_file(extracted document text, windowed).The corpus is untrusted by the platform's own account.
agent-think.ts:296names it — "documents, ingested URLs, repo files and public webhook payloads, any of which an attacker can author" — androutes/public.ts:260-262says of the unauthenticated ingest route:Nothing re-fences downstream:
agent-think.ts:994-997(record) only caps length.Reachability — MEASURED on production, 2026-08-23
GET /v1/instances/267286d5-6877-4f2a-8b5c-3c40b3e3be85/tools(Repo Chat), read-only:That is the exact chain
agent-think.ts:296says the fence exists to stop — "chain read-tools + fetch_url into an exfiltration of the owner's private data" — with the read-tool half unfenced.search_knowledge/read_knowledgeare declared on 2 live instances;read_fileon 1;fetch_urlis a BASE tool and is on essentially all of them.Mechanism
searchKnowledgeForandragContextOrNoticewere split so the tool path could report a retrieval outage honestly (#628). The split is right. What went with it, silently, is that the fence lived on only one branch:buildRAGContextreturns a prose block thatragContextOrNoticewraps, whilesearchKnowledgeForreturns a typed array that its caller stringifies — so there was no single "the retrieved text" string for the fence to sit on when the second path was written.Compounding it:
storage-tools.tsis not a connector, so it is outside the vocabulary of both #263 and #308, and outsidesecurity-invariants.test.ts:400's four-entryFENCES_REMOTE_TEXTmap. It was never a candidate for the guard, because the guard's list is of connector modules.What to do — cheapest first
1. Fence the three doc readers at the tool result. Origin strings should say what the source actually is, since the corpus is mixed:
Keep the platform's own "To read around a file match…" hint outside the block — it is our instruction and the model must obey it. Same treatment for
read_knowledge(:293) andread_file(:349-382);list_knowledgecarries titles only and is the weakest case — include it or say why not.2. Do NOT fence
add_knowledge/update_knowledgeresults. Those are the platform reporting an outcome; fencing a confirmation teaches the model a fence marks nothing in particular, which is the failurelib/connectors/gmail.ts:60-70warns about.3. Add
lib/storage-tools.tsto the guard. See the enumeration/ADR issue filed alongside; do not block this fix on it.Alternatives considered and rejected
searchKnowledgeForby returning a pre-fenced string. Rejected: it returns a typedVectorSearchResult[], whichVectorsSection-style console callers and future non-model readers want structured. Fence at the tool-result seam, where the reader is known to be a model.search_knowledge. Rejected: it is Repo Chat's declared toolset (migration 0050) and the whole point of that agent being "built the creator way". Removing it would regress feat(agents): declarative tool catalog + per-agent tool allowlist (#51) #59/Epic: agents live outside the monorepo — enforce the platform-only boundary #50.read_knowledge, on the grounds that search returns short chunks. Rejected: chunk size is ~512 chars andtop_kgoes to 20 — 10KB of attacker prose is not a short chunk, and a single chunk is more than enough for one instruction.agent-think.ts:994for all tool results uniformly. Rejected here for the same reason as in GitHub issue and PR bodies reach the model unfenced — text any stranger can author on a public repo, on 22 of 42 live instances, one of which also holds a consented shell #746: it covers one of the four surfaces a tool answers, and it would fence the platform's own refusal strings. Belongs in the structural issue, not this one.Acceptance criteria
search_knowledge,read_knowledgeandread_fileresults contain exactly one<untrusted_reference_material …>block, around the retrieved text only.</untrusted_reference_material>does not close the block early (assert exactly one closing marker) — mirroringuntrusted-fence.test.ts:23-27.search_knowledgeis outside the block.RETRIEVAL_EMPTY_MESSAGEand thefail(...)paths are NOT fenced (they are platform statements).Regression risk
search_instance_knowledge/read_knowledge-equivalents to an external client (Claude Desktop). Fencing at the source means those clients now see the marker. That is correct — the same remote text is untrusted there too — but it is a visible change toworkers/mcpoutput; checkplatform-docs/mcp.mddoes not promise a bare payload.lib/storage-tools.test.tsasserting the fence on all three readers, plus the marker-neutralisation case.Verified vs inferred
Verified: the grep (0 in
storage-tools.ts), everyfile:lineabove, thatVectorSearchResult.textis the chunk body, that the samevectorSearchbacks both paths (agent-storage/context.ts:77for the fenced one,retrieval.ts:105for the bare one), the codebase's own labelling of the ingest route as untrusted, and the live tool verdicts on Repo Chat.Inferred: nothing load-bearing. No injected document was planted and no agent was driven — read-only sweep.