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.
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.
| Design | Read repo secrets? | Take an action? | Worst case of an injection |
|---|---|---|---|
| Tool-armed agent in CI | Yes | Yes (label, close, push, comment) | Repo takeover, as in the January 2026 chain |
| "Confined" with tool deny-flags | Sometimes (process still has fs/env; subagents can bring tools) | Sometimes | A fence you cannot fully verify |
| Tool-less + human approval | No | No | A draft reply you delete |
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 Routines | Codex bug triage | This design | |
|---|---|---|---|
| Where it runs | Anthropic's cloud | OpenAI's cloud | Your machine |
| Agent has tools | Yes (shell, files, connectors) | Yes | None |
| Who posts | The agent | The agent (draft gate if prompted) | You, per reply |
| Injection exposure | Full tool surface | Full tool surface | A draft you delete |
| Provider | Claude only | Codex only | Claude, via claude -p |
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/.