Found while enumerating every ingress of text the platform did not author, after #725 turned up a miss that #308's fix had walked past. #308 named three tools and fixed three tools; nothing enumerated the rest. This is one of the rest.
The problem, from the owner's position
An agent is asked "what's issue #412 about?". It calls github_read_issue, and the issue body — which any GitHub account on the internet can author on a public repo — arrives in the model's transcript as plain text, indistinguishable from the platform's own instructions. The owner sees a normal answer. What they cannot see is that the same turn's context now contains whatever a stranger wrote in that issue, on the instruction path, next to a model that on 22 of this account's 42 live instances also holds github_comment_issue and github_update_issue with consent already granted.
agent-think.ts:296 states the threat model this platform accepted:
Retrieved RAG content is UNTRUSTED — documents, ingested URLs, repo files and public webhook payloads, any of which an attacker can author. Fence it so the model treats it as data, not instructions: the front line against prompt-injection that would otherwise chain read-tools + fetch_url into an exfiltration of the owner's private data.
A GitHub issue body is more obviously attacker-authored than any item on that list. Filing an issue on a public repo needs no relationship with the owner at all.
Where it is — VERIFIED
grep -c fenceUntrusted workers/api/src/lib/connectors/github.ts → 0. Same for lib/github-issues.ts and lib/github-prs.ts, the two modules that build the payloads.
All five read handlers return remote text bare:
lib/connectors/github.ts:110 — github_list_issues
return { content: JSON.stringify(issues, null, 2), success: true };
lib/connectors/github.ts:120 — github_read_issue
return issue ? { content: JSON.stringify(issue, null, 2), success: true } : { content: `Issue #${num} not found in ${repo}.`, success: false };
lib/connectors/github.ts:129 — github_list_pulls · :139 — github_read_pull · :101 — github_workflow_runs (branch names and commit messages), all the same shape.
The payload really does carry free text. lib/github-issues.ts:191-192:
const body = (raw.body ?? "").slice(0, BODY_CAP);
return { ...toSummary(raw), body };
and toSummary carries title (:86).
Nothing downstream re-fences. runRegistryTool returns the handler's string untouched — lib/tool-registry.ts:765:
return { name, content: r.content, success: r.success, ...(r.transfer ? { transfer: r.transfer } : {}) };
and agent-think.ts:994-997 (record, "the one seam where a result re-enters the prompt") only caps length:
const capped = capToolResult(content);
toolResults.push(`[${tc.name}]: ${capped}`);
Reachability — MEASURED on production, 2026-08-23
Read-only, via GET /v1/instances/my/instances and GET /v1/instances/:id/tools with the CLI session token:
- 22 of 42 live instances declare
github_read_issue (and github_list_issues, github_read_pull, github_list_pulls; 19 declare github_workflow_runs).
- 22 of 22 of those also declare at least one GitHub write tool.
- On
25501ef7-306b-4a02-ae35-683424344423 ("AIPA tmux Coder") the tools route reports, in the same response:
github_read_issue read allowed=True ok
tmux_run_command write allowed=True ok (writeConsent required, granted)
tmux_send_keys write allowed=True ok
github_comment_issue write allowed=True ok
So the chain read an issue body authored by a stranger → run a shell command on the owner's machine is enabled end to end, today, on a live instance. No exploit was attempted (read-only sweep).
Mechanism — two correct decisions composing badly
- The fence is applied per connector, by hand.
lib/untrusted-fence.ts is explicit that this is deliberate — "FENCED AT THE SOURCE, NOT AT THE SURFACE… the connector wraps its own output" — and it is the right call, because one handler answers chat, a pipeline step, POST /v1/instances/:id/tools/:name and MCP.
- Nothing enumerates which connectors need it. The guard that was supposed to hold the line,
lib/security-invariants.test.ts:400, is a hardcoded four-entry map:
const FENCES_REMOTE_TEXT: Record<string, string> = {
"lib/tools.ts": …, "lib/connectors/http.ts": …,
"lib/connectors/web-search.ts": …, "lib/connectors/mcp.ts": …,
};
It asserts those four still call fenceUntrusted. A connector absent from the map is not a failure — it is invisible. github.ts has never been in it.
Neither decision is wrong alone. Together they mean a connector is fenced iff someone remembered it while writing it.
What to do — cheapest first
1. The one-line fix (ship this alone if nothing else). Fence the four read payloads at the handler, keeping the platform's own framing outside the block, as mcp.ts:1268 does:
return { content: fenceUntrusted(JSON.stringify(issue, null, 2), `GitHub issue ${repo}#${num}`), success: true };
Origins worth distinguishing: GitHub issue …, GitHub issues in …, GitHub pull request …, GitHub Actions runs for …. github_workflow_runs is the weakest case (branch/commit strings) and can be included or deferred — say which, don't leave it silently out.
2. Check the pipeline binder still resolves. These handlers return JSON, so parseOutput (lib/pipeline.ts:553, unfenceUntrusted(content).trim()) unwraps it and $ref keeps working — the same property http_request relies on. Add a test that asserts it, next to the existing http.test.ts:699 case.
3. The structural fix belongs in its own issue — see the enumeration/ADR issue filed alongside this one. Do not block this fix on it.
Alternatives considered and rejected
Acceptance criteria
github_read_issue, github_list_issues, github_read_pull, github_list_pulls return content that starts with <untrusted_reference_material origin=…> and ends with the matching close tag.
- A test asserts a body containing
</untrusted_reference_material> cannot close the block early (exactly one closing marker in the output) — mirroring web-search.test.ts:215-220.
- A test asserts
JSON.parse(unfenceUntrusted(result.content)) still yields the issue object, so pipeline $ref is intact.
lib/connectors/github.ts is added to whatever list security-invariants.test.ts uses, so deleting the fence fails CI.
Regression risk
Verified vs inferred
Verified: the greps (0 hits in three modules), all five file:line returns, the absence of any re-fence in runRegistryTool and record, the body/title fields on the payload, and the live tool declarations + consent verdicts on production.
Inferred: that a model would actually act on an injected instruction in an issue body. Not reproduced — doing so means writing a poisoned issue on a public repo and driving a live agent at it, which the read-only remit of this sweep excludes. The fence exists precisely because that property is a model's, not ours, to guarantee.
Found while enumerating every ingress of text the platform did not author, after #725 turned up a miss that #308's fix had walked past. #308 named three tools and fixed three tools; nothing enumerated the rest. This is one of the rest.
The problem, from the owner's position
An agent is asked "what's issue #412 about?". It calls
github_read_issue, and the issue body — which any GitHub account on the internet can author on a public repo — arrives in the model's transcript as plain text, indistinguishable from the platform's own instructions. The owner sees a normal answer. What they cannot see is that the same turn's context now contains whatever a stranger wrote in that issue, on the instruction path, next to a model that on 22 of this account's 42 live instances also holdsgithub_comment_issueandgithub_update_issuewith consent already granted.agent-think.ts:296states the threat model this platform accepted:A GitHub issue body is more obviously attacker-authored than any item on that list. Filing an issue on a public repo needs no relationship with the owner at all.
Where it is — VERIFIED
grep -c fenceUntrusted workers/api/src/lib/connectors/github.ts→ 0. Same forlib/github-issues.tsandlib/github-prs.ts, the two modules that build the payloads.All five read handlers return remote text bare:
lib/connectors/github.ts:110—github_list_issueslib/connectors/github.ts:120—github_read_issuelib/connectors/github.ts:129—github_list_pulls·:139—github_read_pull·:101—github_workflow_runs(branch names and commit messages), all the same shape.The payload really does carry free text.
lib/github-issues.ts:191-192:and
toSummarycarriestitle(:86).Nothing downstream re-fences.
runRegistryToolreturns the handler's string untouched —lib/tool-registry.ts:765:and
agent-think.ts:994-997(record, "the one seam where a result re-enters the prompt") only caps length:Reachability — MEASURED on production, 2026-08-23
Read-only, via
GET /v1/instances/my/instancesandGET /v1/instances/:id/toolswith the CLI session token:github_read_issue(andgithub_list_issues,github_read_pull,github_list_pulls; 19 declaregithub_workflow_runs).25501ef7-306b-4a02-ae35-683424344423("AIPA tmux Coder") the tools route reports, in the same response:Mechanism — two correct decisions composing badly
lib/untrusted-fence.tsis explicit that this is deliberate — "FENCED AT THE SOURCE, NOT AT THE SURFACE… the connector wraps its own output" — and it is the right call, because one handler answers chat, a pipeline step,POST /v1/instances/:id/tools/:nameand MCP.lib/security-invariants.test.ts:400, is a hardcoded four-entry map:fenceUntrusted. A connector absent from the map is not a failure — it is invisible.github.tshas never been in it.Neither decision is wrong alone. Together they mean a connector is fenced iff someone remembered it while writing it.
What to do — cheapest first
1. The one-line fix (ship this alone if nothing else). Fence the four read payloads at the handler, keeping the platform's own framing outside the block, as
mcp.ts:1268does:Origins worth distinguishing:
GitHub issue …,GitHub issues in …,GitHub pull request …,GitHub Actions runs for ….github_workflow_runsis the weakest case (branch/commit strings) and can be included or deferred — say which, don't leave it silently out.2. Check the pipeline binder still resolves. These handlers return JSON, so
parseOutput(lib/pipeline.ts:553,unfenceUntrusted(content).trim()) unwraps it and$refkeeps working — the same propertyhttp_requestrelies on. Add a test that asserts it, next to the existinghttp.test.ts:699case.3. The structural fix belongs in its own issue — see the enumeration/ADR issue filed alongside this one. Do not block this fix on it.
Alternatives considered and rejected
agent-think.tsat therecordseam instead. Rejected: it covers exactly one of the four surfaces the handler answers, which is the reasoninglib/untrusted-fence.tswas written down for. It would also fence the platform's own refusal strings, which are not untrusted and which the model needs to obey.listIssues/readIssue(lib/github-issues.ts). Rejected: those functions also feed the console and the admin issue views, which are not models. Fencing there would put markup in a UI.Acceptance criteria
github_read_issue,github_list_issues,github_read_pull,github_list_pullsreturn content that starts with<untrusted_reference_material origin=…>and ends with the matching close tag.</untrusted_reference_material>cannot close the block early (exactly one closing marker in the output) — mirroringweb-search.test.ts:215-220.JSON.parse(unfenceUntrusted(result.content))still yields the issue object, so pipeline$refis intact.lib/connectors/github.tsis added to whatever listsecurity-invariants.test.tsuses, so deleting the fence fails CI.Regression risk
$refbreakage if a caller parses the content withoutunfenceUntrusted. Caught by criterion 3. Grep first:grep -rn "github_read_issue\|github_list_issues" workers/api/src --include='*.ts'and the same inworkers/mcp/src.github_list_issues(30 issues) that is noise next to the payload.store/consolerenders it as text and not as HTML.Verified vs inferred
Verified: the greps (0 hits in three modules), all five
file:linereturns, the absence of any re-fence inrunRegistryToolandrecord, the body/title fields on the payload, and the live tool declarations + consent verdicts on production.Inferred: that a model would actually act on an injected instruction in an issue body. Not reproduced — doing so means writing a poisoned issue on a public repo and driving a live agent at it, which the read-only remit of this sweep excludes. The fence exists precisely because that property is a model's, not ours, to guarantee.