Our CLI reads Claude Code transcripts from ~/.claude/projects/ and counts tokens. It reads nothing
else: no prompt text, no code, no file contents. A decision record says so, as an explicit list of
fields.
For its first six weeks, transcript discovery read one directory level:
const entries = await readdir(root, { withFileTypes: true });
return entries.filter(e => e.isDirectory()).map(e => join(root, e.name));
Then one .jsonl glob per project directory. Claude Code does not lay transcripts out flat. Subagent
sessions write their own files nested below the project directory, and we never saw them. That was
about 8% of real usage machine-wide and 21% in agent-heavy sessions. Two statistics we displayed,
agent runs and the agent-driven share of a session, were zero for every user, because the data that
would have made them non-zero was never read.
The fix is the one anyone would write. Recurse, cap the depth so a pathological tree cannot turn a sync into a filesystem crawl, and de-duplicate by resolved path so a directory linking into its own ancestry cannot loop.
On the branch, before it merged, that fix opened a way out of the directory we had promised to stay in.
The feature that was also the escape
readdir with withFileTypes classifies a symlinked directory as a link, not a directory, so
isDirectory() is false for it. The one-level version never cared. A symlink got skipped, and there was
nothing below it to find.
Recursion changes that. If part of a project's tree is linked (common for anyone who keeps dotfiles in a repo) and the walk skips links, it misses those subagent transcripts. So the recursive walk has to follow them:
} else if (entry.isDirectory() || entry.isSymbolicLink()) {
await walk(full, depth + 1);
}
And now a symlink under ~/.claude/projects/ pointing anywhere on the disk gets walked, and any .jsonl
beneath it is read and folded into the numbers we upload.
The requirement that made the fix necessary is the one that made the escape possible. Following links is not an oversight in that code. It is the feature.
How bad it was
Not very. The blast radius is aggregate token counts. The parser takes five integers, a timestamp, a model name and two booleans from each line and discards everything else, so a successful redirection yields sums of numbers from files that are not transcripts, never their contents. Setting it up needs local write access to the user's home directory, and anyone with that has far better options. There is no remote trigger.
By the usual calculus it is a backlog note: low impact behind a high-privilege precondition. It was caught in the branch's security review and closed in the same change, so it never reached a release. The question worth writing about is why it was closed at all.
A written boundary changes the severity
On 11 June we had written a privacy decision record. It is not a marketing page. It is a field-level inventory: what is read from a transcript, what is never read, and a rule that any feature needing prompt text or file contents has to come back as a new record with explicit consent.
It makes a specific claim: the companion reads a bounded, named set of files. A walk that can be steered outside that set makes the claim untrue. Not "mostly true", and not "true for realistic threat models". We could no longer say what the set was, because it had become a function of the filesystem rather than of our code.
The record's amendment says it directly:
The exposure was small — aggregate token counts, never file contents — and setting it up required local write access to the user's home directory. It is closed anyway: this ADR's whole claim is that we read a bounded, named set of files, and a walk that can be steered outside that set makes the claim untrue regardless of what the walk happens to extract.
Once you have told users the tool stays in one directory, leaving that directory is a correctness bug with its own severity, whatever you find outside.
There was a second argument, and it grew as the product changed. The same release moved this scan from a command a person types to a Claude Code hook that runs after every assistant turn, unattended, for as long as the tool is installed. An escape in code a person runs is a bug. The same escape in code that runs by itself on every turn is a standing condition. Closing it cost a path comparison.
The fix
Resolve the root. Resolve every candidate. Refuse anything that does not sit inside.
let base: string;
try {
base = await realpath(dir);
} catch {
return out; // the project directory itself is gone
}
/** Is `target` the root, or genuinely beneath it? */
function isInside(target: string): boolean {
if (target === base) return true;
const rel = relative(base, target);
// `..` escapes upward; an absolute result means a different root entirely
// (another drive on Windows). Segment-aware, so `projects/foo-evil` is not
// mistaken for a child of `projects/foo`.
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
And inside the walk, resolve before deciding anything:
let real: string;
try { real = await realpath(current); } catch { return; }
if (!isInside(real)) return;
if (seen.has(real)) return;
seen.add(real);
Three details do more work than they look like they do.
relative(), not startsWith(). The obvious check, target.startsWith(base), is wrong: a sibling
called projects-evil starts with the string projects and is not inside it. relative() works in path
segments, so it answers the question actually being asked. This is the kind of error that passes every
test you would think to write, because you have to think of the sibling-prefix case first.
The absolute check. On Windows, relative() between two drives returns an absolute path, not a
chain of ... Without that clause, a link to another drive passes a check built to stop exactly that.
The root is resolved first. This is the detail that keeps the fix from breaking legitimate setups.
Plenty of developers symlink ~/.claude into a dotfiles repo. Compare against the unresolved path and
their whole transcript tree looks outside the root, so we would read nothing. Resolving the base first
puts their tree under one real prefix.
The seen set is keyed on resolved paths, so it both breaks cycles and stops one file being counted
twice when two links reach it. Four tests pin the behaviour: an escape, a nested escape, a legitimate
in-tree link, and the name-prefix sibling that startsWith would have let through.
What it cost
- A depth ceiling that is a guess.
MAX_DEPTHis 4, on the reasoning that Claude nests subagent transcripts one or two levels down. If that layout changes, we under-count again, silently, with a plausible number on screen: the same failure this change fixed. - A
realpathper directory. More syscalls, now on a path that runs after every turn. A cheap pre-check elsewhere decides whether to scan at all, so most turns never walk. - The numbers moved. Counting subagents raised everyone's usage by about 8% overnight, 21% for agent-heavy users. A correctness fix that looks like a regression from outside.
What we would do differently
- Review what a change permits, not only whether it is correct. The recursive walk was correct. The review question that found the escape was "what can this now reach that it could not before?", and it should be a standing question for any change that widens a traversal.
- Pin the depth assumption with a test on real layouts. A fixture of an actual Claude Code project tree, checked when Claude Code updates, would turn "silently under-count again" into a failing test.
- Link the privacy record from the code it constrains. The boundary did its job here because a reviewer remembered it existed. The file that could break a promise should name the promise.
Write the boundary down so it can be broken
Before the privacy record, "the CLI reads token counts" described behaviour. After it, it was a claim with a scope, and code that could violate the scope was a defect regardless of impact. That is the strongest case for writing such a document: not for the users who read it, but because it gives a code review something to check against.
Where code runs is part of its threat model too. Nothing about this function changed between "fine" and "fix before merge" except that it was about to run by itself after every turn.