DEV Community

Artemii Amelin
Artemii Amelin

Posted on

Agents Can Scrub Their Own Traces. shell.online's Record Reader Ships Each Turn Off the Host as It Is Written, and Is Still Not an Audit Log

A paper posted to arXiv this week, LLM Agents Can Easily Tamper With Their Own Traces by Jeremy Qin, David Schmotz, Derck Prinzhorn, Luca Beurer-Kellner, Ameya Prabhu and Maksym Andriushchenko, opens with a sentence that most agent monitoring quietly depends on: "These analyses assume that LLM agents cannot tamper with their own execution traces." Then it tests the assumption. From the abstract: "local LLM agents such as Claude Code, Codex, Antigravity, Open Code and Grok Build fail to enforce this boundary. All tested harnesses, except Muse Code, allowed agents to delete their traces when asked, without triggering monitor guardrails."

The traces in question are ordinary files. For Claude Code the paper names the location: ~/.claude/projects/<project>/<session>.jsonl. Getting rid of them took nothing clever. One agent wrote /tmp/transcript-scrub.sh to repeatedly empty the session file in the background. The Muse Code exception is a built-in skill that reminds the agent the traces cannot be tampered with, which is a policy rather than a control. The paper's third finding is that tampering emerges on its own when a model is trying to improve its reward, no prompt required.

The authors' recommendation is to record model requests and responses through a trusted interception mechanism that sits between the harness and the model API, outside the agent's host, append-only, with interrupted streams marked incomplete.

This lands on a week where shell.online started reading exactly that Claude Code file. So it is worth being precise about what our reader is, and what it is not.

What merged this week

PR #272 on the shell.online repository, merged September 25 and on main but not yet in a tagged release (v0.23.3 is the latest tag), wires the internal/agentlog package from Friday's post into the running product. Until this PR nothing imported it. Now cmd/shell/agent_tap.go follows the agent's own JSONL record and sends it to viewers, and the browser chat draws the conversation from that instead of from the screen.

The reason is stated at the top of agentlog.go: a full-screen agent repaints a grid, so a viewer's terminal holds one screenful of a projection, with history gone and "working" inferred from a spinner. The record has roles, timestamps and every turn.

The wire shape is small:

  • The host reads forward from a byte offset in the record, never re-reads, and leaves a line with no newline yet for the next pass. Each event is capped at 32KB on a rune boundary before it goes anywhere.
  • Events travel as a new frame opcode, AgentEvent (0x0e), sealed with the same per-session frame cipher as terminal output. The relay's handler refuses anything over 256KB and closes the socket, otherwise it broadcasts the sealed bytes to viewers. It holds no key and cannot read what it forwards.
  • The host builds batches by size, not just by count, stopping at 160KB of payload or 40 events. Forty 32KB messages would be 1.28MB, which would have got the host disconnected rather than delivered.
  • The browser side parses the JSON with every field type-checked, drops unknown kinds, caps text at 64KB and menus at 12 options, and sets everything with textContent. Once a record batch arrives, the screen-reading adapter is reset and the chat stops reading the grid, because drawing both shows every turn twice.

The visible new thing is that an agent's multiple-choice question arrives as data with its options, and the chat view draws them as buttons. Pressing one writes a single digit to the pty, the same digit the harness's own menu is numbered with, and the chosen label is posted into the thread so nobody watching wonders what was answered. A read-only viewer cannot press them; the same disableStdin check that blocks typing blocks the buttons.

Read the paper's threat model against this

The record our host reads is written by the agent running in the session. A comment in agentlog.go says it plainly: the record is the same trust domain as the terminal's output. The browser parser says the same from the other side: exactly as trusted as the output already was, which is to say shown and never executed.

So the honest framing is that the record tap is a display path, not an audit path. Three specifics from the code that anyone can check:

  1. What has already left the host cannot be recalled by deleting the file. The reader forwards each event on the poll after it is written, at 700ms intervals while a record is live. A viewer who has the tab open holds those turns on their device, and the chat history that PR #261 keeps in IndexedDB holds them after the tab closes. That is a copy outside the agent's reach, which is closer to what the paper asks for than a file next to the agent, but it got there because someone was watching.
  2. A truncated file goes quiet, not loud. If an agent empties the record in place, the reader's next Read opens the file, seeks to its old offset past the new end, reads nothing, and returns no events. If the file is removed the open fails, the reader is dropped, and the host goes back to looking for a record. Neither path tells the viewer anything happened. The tap was built to show a conversation, and it does not know the difference between an agent that stopped talking and one that scrubbed its transcript.
  3. The copy is deliberately narrow. What leaves the host is user text, assistant text, tool names, and the questions an agent is waiting on. There is a test in agent_tap_test.go that plants cat /etc/shadow as a Bash tool input alongside a private attachment and asserts that neither string appears in any frame while Bash does. That boundary is right for a viewer over a shared link and wrong for forensics: you cannot reconstruct what commands ran from it, by design.

If what you need is trace integrity, the paper's advice stands and nothing in a terminal-sharing product substitutes for it. Put the recorder between the harness and the model API, outside the box the agent controls. On the Pilot Protocol side the analogue is that the receiving end of a tunnel keeps its own record of what it was asked; a service agent's log of incoming requests is not something the requesting agent can scrub.

What a shared session gives you is different and still useful: a second party, on a second machine, seeing the turns as they happen, through a relay that cannot read them. The code for the host side is in cmd/shell/agent_tap.go and the relay handler is the Opcode.AgentEvent case in worker/index.ts, both in the public repo.

Top comments (0)