The Rollout

A prompt-injection-safe GitHub triage agent: give the model no tools

In January 2026, a security researcher took over a GitHub repository by opening one issue. The repo used Anthropic’s Claude Code GitHub Action to triage issues. RyotaK of GMO Flatt Security wrote an issue that read like an error message and refined it until the model “recovered” by running the commands buried inside. That walked the action into reading /proc/self/environ, where the credential for minting an OIDC token was sitting; Anthropic’s backend traded that token for a GitHub App installation token with write access to code, issues, and workflows. One issue produced full write access. Anthropic rated it 7.8, paid a bounty, and shipped the fix in claude-code-action v1.0.94.

The specific bug was an authorization check that trusted any actor whose name ended in [bot]. The general problem is older and unsolved: point a tool-armed LLM at text an attacker controls, and one well-written paragraph can make it do what the attacker wrote, not what you asked. This is indirect prompt injection, and there is no known way to sanitize it away. The action’s own security notes admit as much: they strip HTML comments and hidden characters, then warn that new bypasses will emerge. Filtering raises the bar. It does not close the door.

I wanted the same job without that door: a small task that runs over my two repositories every morning, reads the new issues, PRs, and discussions, drafts a digest and any replies worth sending, and lets me approve each one before it posts. That is exactly the workload that just got someone owned. Here is how to run it so an injection has nothing to hold onto.

Agent with tools, in CI Attacker's issue text LLM shell · files · network reads /proc/self/environ mints an OIDC → write token pushes to your repo injection reaches actions Tool-less agent Attacker's issue text + canaries LLM no tools · text in, text out text: digest + draft replies a human approves post one reply
Same input, two blast radii. A tool-armed agent that "recovers" from a malicious issue can reach secrets and write access. A tool-less one has nothing to call; the worst an injection produces is a draft a human throws away.

The wrong fix first: fencing a tool-using agent

My first attempt kept the agent and fenced it with flags. Claude Code accepts --allowedTools and --disallowedTools, but a deny-list is a pre-approval list, not a sandbox. The process still runs with your filesystem, environment, and network reachable; the flags govern which named tools the model may call, not what the process can touch. If Task, Agent, Skill, or Workflow survive, an injection can spawn a subagent that arrives with its own tools. You bar the front door and leave a door that builds new doors. I could not convince myself the fence held, so I removed the tools instead.

The design: tool-less by construction

The agent that reads untrusted text does one thing: turn text into text. No shell, no files, no network, no subagents. Everything it needs arrives in the prompt; everything it produces leaves on stdout. Three plain scripts sit around it.

gather.sh runs first and does all the reading: it calls gh and writes a snapshot of open issues, PRs, discussions, and recent releases to a file. The model fetches nothing.

run-agent.sh feeds that snapshot to the model as data and takes back text:

claude -p --output-format text --strict-mcp-config \
  --disallowedTools Bash WebFetch WebSearch Task Agent Workflow Skill \
                    NotebookEdit Edit Write Glob Grep TodoWrite \
  < prompt.txt

The prompt goes in on stdin, the process runs in an empty temporary directory, and the disallow list names every tool that reads, writes, fetches, or spawns. What is left is a language model with nothing to invoke.

reply.sh runs afterward, and only when I say so. It posts one approved reply by id with gh. The model never holds the token and never triggers the write.

Because the model cannot write files, it hands everything back as structured text. The contract is three markers:

<<<DIGEST>>>
...markdown digest...
<<<REPLIES>>>
[{"id":"R1","repo":"me/app","number":123,"kind":"issue","body":"draft reply"}]
<<<END>>>

An awk pass extracts the first DIGEST and REPLIES blocks; jq validates the array. If the model wanders off the format, the run exits non-zero and I get a plain fallback digest. Drafts land in a file for me to read, never in a queue that posts itself.

Why this holds even though a flag is not a sandbox

--disallowedTools is still a flag, not a sandbox. What changed is how much is left to protect. A tool-using agent permits some actions and blocks others, so the blocked set has to be airtight. A tool-less agent permits none. There is no allowed tool for an injection to route through, no subagent to spawn, and nothing valuable in the process to reach: the working directory is empty and the posting credential lives in a different script that runs later, under my hand. The best a successful injection can manage is a convincing draft reply. I read every draft; the cost is thirty wasted seconds of my morning.

DesignRead repo secrets?Take an action?Worst case of an injection
Tool-armed agent in CIYesYes (label, close, push, comment)Repo takeover, as in the January 2026 chain
"Confined" with tool deny-flagsSometimes (process still has fs/env; subagents can bring tools)SometimesA fence you cannot fully verify
Tool-less + human approvalNoNoA draft reply you delete
The difference isn't how well each design filters the input. It's what remains reachable after the model has been talked into cooperating with the attacker.

Why not the built-in cloud triage?

Both vendors ship this workload as a feature. Claude Code Routines, a research preview, runs scheduled sessions in Anthropic’s cloud with full tool access (shell, files, connectors) and no approval prompts mid-run. Its GitHub triggers cover only pull-request and release events, so issue triage runs on the clock, with daily caps by plan (Pro 5, Max 15, Team/Enterprise 25). Codex’s “automate bug triage” is the same shape in OpenAI’s cloud; it will draft for approval if you ask, but that gate is a sentence in your prompt, not a platform boundary. The convenience is real: zero install, and someone else maintains and runs it. This design trades that for local execution, a model with nothing to invoke, and a gate that lives in the architecture.

Claude Code RoutinesCodex bug triageThis design
Where it runsAnthropic's cloudOpenAI's cloudYour machine
Agent has toolsYes (shell, files, connectors)YesNone
Who postsThe agentThe agent (draft gate if prompted)You, per reply
Injection exposureFull tool surfaceFull tool surfaceA draft you delete
ProviderClaude onlyCodex onlyClaude, via claude -p
Same job, three trust models; only the local one makes the approval gate structural.

Prove it: the adversarial confinement gate

I do not trust arguments about model behavior, so the install tests the real CLI before scheduling anything. tests/test_confinement.sh feeds the live model a snapshot rigged with injection: an issue body ordering it to ignore its instructions and run a command, a smuggled REPLIES block trying to forge an approved post, and two canary strings, __BASH_CANARY__ and __SUBAGENT_CANARY__, that only surface if the model shells out or spawns a subagent. The test asserts the run touched nothing, tripped no canary, and produced only the text contract. Nine of nine must pass before the launchd job installs. The design is the claim; the gate is the evidence, checked against the model that will actually run.

Grounding, so it doesn’t lie to people

One rule is about honesty, not safety. A triage agent’s most tempting mistake is telling a reporter their bug is fixed when it guessed. The gather step pulls the last few releases, and the prompt forbids claiming a fix shipped unless a release postdates the report; otherwise the draft says a fix is in progress. The model checks fetched facts instead of its own optimism.

Where this fits, and where it doesn’t

The design is narrow on purpose. It fits a read-and-draft job with a human at the end, not an agent that labels, closes, or pushes on its own; the moment the model drives a write, you are back to securing a tool-using agent. The model can still be talked into a bad draft; the guarantee is not that it behaves but that when it misbehaves, the blast radius is a suggestion I can delete.

There is a quieter benefit. Each run is a headless claude -p session and writes the same transcript any Claude Code session does. When a digest looks off, I don’t guess; I open the run in Agent Sessions, the macOS app I build for reading agent transcripts, and see exactly what went in and what came back. Scheduled agents are becoming ordinary, and almost none have a “what did this actually do last night” view. The transcripts are already on disk. They just need reading.

Agent Sessions is free, local-only, has no telemetry, and opens those files read-only. Download it or read the source on GitHub. More posts like this one live at /blog/.

← The Rollout