DEV Community

Artemii Amelin
Artemii Amelin

Posted on

Claude Code Transcripts Hold Every Secret the Agent Saw. shell.online's New Transcript Reader Ships a Tool's Name, Never Its Input

Claude Code writes every session to disk as JSON lines under ~/.claude/projects/<slug>/<session>.jsonl. The file holds each message, each tool call, and each tool result, verbatim, for as long as you keep it. That is a useful record and it is also the thing people keep finding their credentials in.

The pattern is well documented. In April a user filed anthropics/claude-code#50014 after grepping about a month of session files on their own machine: five distinct secrets across 34 files, 418 MB of transcript, plus copies in the paste cache and file history. In May, #59094 described an agent running Get-Content against a gitignored deploy/.env to check that some variables were present, which printed live brokerage keys to stdout, into the transcript, and to the inference API in one move. Earlier this month CrypLed audited their own logs on dev.to with a 300-line local scanner and found 71 hits in one project, mostly database connection strings and JWTs, and two real AWS access keys in another. The NHI Management Group's advice from August is the right one-liner: redact secrets at capture time, not after indexing.

Any harness that records its own conversation records whatever the conversation contained. OpenClaw's trajectory log and Hermes's SQLite database have the same property.

This matters to us today because shell.online merged three pull requests this morning that read exactly that file.

Why we started reading the transcript at all

A shell.online session is a terminal shared through a browser link. When the program in it is a coding agent, the app offers a chat view instead of a grid. Until now that view was built by parsing the screen, and #256 shows how badly that goes: a replay of one real session had produced 71 chat utterances for three exchanges.

More parsing cannot fix it. A coding agent draws on the alternate screen and repaints, so the viewer's emulator holds one screenful of a projection and everything above it is gone. History has to be reconstructed by diffing frames, the chat changes when someone scrolls, and "thinking" is inferred from a spinner glyph.

The agent's own record has none of those problems: roles, timestamps and turn boundaries are given, not guessed. So #264, #265 and #266 add a Go package, internal/agentlog, that finds the record belonging to a session and follows it.

To be clear first: nothing is wired up yet. As of HEAD 1c238e0, no file outside internal/agentlog imports it. No frame carries these events off the host and no renderer reads them. The package was built and tested first because it is the part that decides what leaves the machine, and that decision deserved to be made before anything depended on it.

What leaves the machine, and what does not

The package reads a file everyone agrees is full of secrets and emits as little of it as possible. The output is one struct, Event: a kind (user, assistant, tool or choice), text, a timestamp in epoch milliseconds, a sequence number, and an optional Choice. That is all downstream code will ever see, for every harness.

The Claude Code decoder in claudecode.go does the filtering:

  • Only records of type user and assistant are considered. Modes, titles, permission state, and attachments that are whole files are dropped at the first switch.
  • Inside a message, only text and tool_use parts are read. A tool_result part is not a case in that switch, so the output of cat .env never becomes an event. Neither does a thinking part.
  • A tool is named, never detailed. A tool_use becomes an event whose text is the tool's name and nothing from its input. The test TestNamesAToolWithoutItsInput writes a Bash call whose input is rm -rf /secret and fails if that string appears in any event.

The one exception is a question. When the agent calls AskUserQuestion, the question, its header and the option labels travel, because a question nobody can see is a session that has silently stopped, which is the one thing a chat view must not do. The tools that count as asking are a named map with one entry, not a guess from the shape of the arguments; a second test sends a Bash call with a question-shaped input and asserts it comes through as a bare tool name.

Why so strict, when the chat view was already showing the agent's replies? Because the transcript holds strictly more than the screen. The screen shows the agent saying it read the config file. The transcript holds the config file. Reading the record instead of the screen is a privacy regression unless the decoder is narrower than the screen was, so the rule is that widening Event is a decision about what leaves a machine, not a detail.

What this does not do is protect the agent's own prose. If the model quotes a key in its reply, that text travels, as it already did on screen. The file on disk is also untouched. Redaction at capture time has to happen in the harness, and issue #50014 is still the place to ask for it.

Finding the right file without trusting its name

Claude Code names the project directory by turning path separators into dashes, which is not reversible: a session started at / lives in a directory called -. So the adapter never reads the directory name. It reads at most the first 40 lines of each candidate for a cwd field, keeps the ones whose stated directory matches, drops any not modified since the session started, and takes the newest. The reader's whole state is a byte offset, and it stops at the first line with no trailing newline, because a record still being flushed is not an event yet.

One adapter per harness

The interface has three methods: Name, Open(home, dir, since) and Answer(index). Adding a harness is one file and one test file.

The OpenClaw adapter reads *.trajectory.jsonl under both directory layouts seen in the wild. A trajectory is an event log, and the conversation lives in two record types: prompt.submitted carries the person's text, and model.completed carries everything the agent said in that run as an array. Those texts are joined rather than split into messages, because the record does not say where one message ended, and inventing boundaries is the guessing this package exists to stop.

Answer is what lets a question be answered with a button. For Claude Code it returns a single digit, index+1, checked by driving a real session to a choice prompt and writing 1. The drawn menu has more entries than the record, because the harness appends "Type something" and "Chat about this" after the recorded options, and the digits line up only because the recorded options come first. Past nine there are no digits left, so an index above eight returns nil rather than pressing the wrong thing. OpenClaw's Answer returns nil unconditionally, because nobody has driven one of its menus yet. A nil answer is not an error. It means the chat lets the person type.

Hermes is absent on purpose: Go cannot read SQLite without a driver, a CGO one breaks the mips, arm and power cross-builds, and a pure-Go one adds megabytes to every binary. That is a dependency decision, not something to slip into an adapter.

What comes next

The remaining steps are laid out in the PR descriptions and none have landed: an AgentEvent opcode, emitted by the CLI through the existing frame cipher so the relay forwards ciphertext and learns nothing new, which is the same rule we hold for tunnel traffic in Pilot Protocol; the relay forwarding it to viewers; the app rendering it through the bubbles it has now; choice prompts as real buttons; and screen parsing demoted to the fallback for programs with no record.

The browser side was settled yesterday in #261: the relay cannot read a session, so each device that watched one keeps what it saw in IndexedDB, sealed with AES-GCM under a key derived with HKDF from the share-link secret.

If you would rather check than trust a blog post, the package is about 530 lines across four non-test files in the shell.online repo, and go test ./internal/agentlog/ passes at HEAD. The test names are the specification: a half-written record is left alone, a tool's input cannot leave, the right session is found among several in one directory, and only the asking tools carry their input.

Top comments (0)