Skip to content

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

Description

@serge-ivo

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.ts0. 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:110github_list_issues
    return { content: JSON.stringify(issues, null, 2), success: true };
  • lib/connectors/github.ts:120github_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:129github_list_pulls · :139github_read_pull · :101github_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

  1. 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.
  2. 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.

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 workingconnectorsConnector + tool frameworksecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions