Nothing an Agent Says Can Run a Command

I built a tmux companion that showed seven coding agents at a glance. An audit then showed that displaying an agent's text was enough to run it as a shell command.

By Ali AlQattan. I built a tmux companion that showed seven coding agents at a glance. An audit then showed that displaying an agent's text was enough to run it as a shell command.

TL;DR

room was a tmux companion I built between late August and mid September 2026. It painted what every coding agent was doing onto pane borders, window tabs and the status bar: working, idle, just finished, needs you. A week in, an audit showed the chrome itself was an execution path. tmux expands format strings, so an agent's session title could run a shell command just by being displayed. The fix was one filter, untrusted(), in front of every free-text field an agent supplies, plus whitelists for the two fields that pick a colour or become a command. The test file that pins it, tests/test-said.sh, holds 83 assertions at commit a51c969.

Seven agents, one pair of eyes

On a normal day I had Claude, Codex, Grok, pi, Antigravity, opencode and Muse running in different tmux panes, sometimes for hours. The machine knew which one was thinking, which one had finished and which one was stuck on a permission prompt. It showed me none of that. A pane could sit on "allow this command?" in another session for an hour and nothing on screen would say so.

I did not want a dashboard. I already had a terminal full of panes, so the information belonged on the panes. The name is the design rule: a room with agents in it. room shows who is there and what they are doing, and it never types into an agent's pane.

What a glance tells you

Each pane has exactly one state at a time, and only one state is loud.

State What you see What it means
working the tool's own accent and a turning ◐ ◓ ◑ ◒ the agent is busy
idle dim grey nothing is happening
just finished teal, for about 20 seconds it left working a moment ago
needs you filled red with the reason inline it is blocked on me

The teal is the part I used most. A pane that stops working stays teal for about 20 seconds before going grey, so a glance shows who just finished.

Needs you is the only saturated colour in the design and the only thing allowed to interrupt. Every window tab carries a one-character mark taken from its most urgent pane. Every other tmux session gets a tab on the bar with the same mark, because before session tabs a stuck agent in another session had no way to reach me. The bar also carries one phrase for the whole machine, like 3 working · 1 needs you, or house is calm.

An alert you have to hunt for is not an alert. So prefix u jumps to the pane that has been waiting longest and names the reason when you land. Press it again for the next one.

How the state gets there

The architecture has one rule: the UI never runs a process.

The scanner slept 0.6 seconds sampling churn, so a border that called it would stutter on every redraw. One writer per tmux server does the slow work and stores the results as tmux options like @room_state and @room_label. Borders, tabs and the bar only read those options. If the writer dies, an unset option expands to nothing, so the worst case is a blank label, not a broken bar.

The writer runs on two clocks. A refresh scans every 4 seconds while anything moves and every 10 seconds when the room is calm. A frame runs every half second, reads no screen, and only turns the spinner and ends the teal on time.

Later I gave the agents a faster way in. Most of these CLIs have hooks: Claude's hooks, Codex's notify, Grok's hooks.json, pi extensions, opencode plugins. Each hook runs room-writer report <tool>, which leaves one small file per pane with a title, a last line, a session id and a timestamp. For the tools that speak, needs you no longer waited up to ten seconds for the next scan.

Two rules kept that honest. The scanner still decides state. A hook can wake a refresh and hand it a title, but it never sets @room_state. And a pane whose screen could not be read keeps its last known state through two failed reads and only decays to idle on the third in a row, so one slow read does not flicker a busy agent to idle.

The chrome was an execution path

The hooks meant agents were now writing text that I put on my screen. Session titles, last lines, project names, model names, resume commands. In early September a gap audit showed what that meant.

tmux format strings are a small language. #(cmd) runs a shell command and prints its output. #[bg=red] changes the colour. The writer applies its whole batch with source-file, which also expands $ and ~. And display-message, which room used to tell me where a jump landed, expands formats too.

An agent's session title is whatever the agent decides it is, or whatever the text it just read told it to say. A title containing #(touch /tmp/room-pwned) would run the moment tmux drew the border. No click, no prompt. A title containing #[bg=#f38ba8] could paint a fake needs-you on a pane that needed nothing. An escape byte in a last line could recolour the terminal through the preview popup.

I had been treating agent output as data about my agents. It is not. It is input from whoever got text in front of the model, like a web page it fetched or a file it opened. That makes it attacker-controlled, and I was handing it to an interpreter.

Keep the meaning, lose the teeth

The commit is called "Nothing an agent says can run a command". Titles, last lines, resume commands, project, model, effort and context all go through one function before they reach a tmux option:

function untrusted(v) {
  gsub(/[\001-\037\177]/, " ", v)
  gsub(/[$~"\\]/, "", v)
  gsub(/#/, "##", v)
  return v
}

Control bytes become spaces. $ and ~ are dropped, so source-file has nothing to expand. Double quotes and backslashes go too, because the batch wraps every value in double quotes and a value must not step outside them. Every # is doubled, so #(cmd) prints as text instead of running. The writer's own styling still works because it is added after the filter. Real #[...] only ever comes from the writer, never from an agent.

Three fields got stricter treatment than a scrub:

  • Tool names are whitelisted. The scanner may only name the seven agents room knows, plus a plain shell. Anything else becomes other and is painted like a stranger. A pane cannot pick its own colour by claiming to be something.
  • Session ids are whitelisted, not scrubbed. A session id is the one field that becomes a command I might press Enter on, because prefix R stages a line like claude --resume <id>. It must match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ or it is not an id.
  • Every display-message doubles the hash before reporting anything a pane owns. Jump, resume and saved prompts all needed it.

The same audit caught a quieter version of the bug. Saved prompts put a trailing newline on the tmux buffer, so prefix P pasted the prompt and pressed Enter for me. The buffer now ends at the last character and the paste is bracketed with paste-buffer -p. The old test could not see this, because show-buffer inside a shell substitution eats the newline. The new test counts the bytes on the buffer and expects 22.

What the tests pin down

room's tests run against a throwaway tmux server per suite, with fake scanner, fzf and tmux binaries wherever a real one would touch my live session. The injection commit added seven assertions, for 966 across 21 suites. A later audit counted 1,061 across 21.

tests/test-said.sh is the one that matters here. It has 83 assertions covering report identity, state clocks, quiet panes, dead panes and hostile labels. The hostile ones read like the commit message:

ok   a hash in a title is doubled, so tmux prints it instead of running it
ok   a style in a title cannot paint a fake needs-you
ok   a dollar is dropped, so source-file cannot leak the environment
ok   an escape byte in a last line never survives to the preview
ok   a tool the scanner does not know is a stranger, not a colour

A separate suite, test-no-subshell.sh, guards the architectural rule. It greps every room tmux config, comments aside, for #( and fails on any hit. If anyone, including a future agent editing the config, puts a process back into the chrome, the suite goes red.

What I took from it

Watching agents and securing them turned out to be the same job. The point of room was that I could trust a glance, and a fake needs-you breaks that faster than any missing feature.

The rule I use now, everywhere I build agent tooling: an agent's text is attacker-controlled, even when the agent is mine. Its words come from whatever it read, and once they reach something that interprets them (a template, a shell, a format string, a paste buffer) they are input from a stranger. So I scrub what gets displayed and whitelist anything that can become a command. The hostile case gets its own named test.

On 18 September I committed the last of the work and took room off my machine. The rule stayed.