Skip to content

fix(hook): stop stdin choosing which directory is a project's transcript store - #370

Open
fstubner wants to merge 2 commits into
mainfrom
fix/hook-store-dir-containment
Open

fix(hook): stop stdin choosing which directory is a project's transcript store#370
fstubner wants to merge 2 commits into
mainfrom
fix/hook-store-dir-containment

Conversation

@fstubner

@fstubner fstubner commented Sep 9, 2026

Copy link
Copy Markdown
Owner

The session-start hook reads transcript_path from stdin and recorded dirname() of it into .xtctx/state/store-dirs.json as this project's claude-code store, read on every later scan. Nothing checked where it pointed.

Reproduced before the fix

A payload naming this project correctly in cwd, and pointing elsewhere in transcript_path:

{"cwd":"<project>","transcript_path":"<project>/../../../../../../evil/x.jsonl"}

recorded the escaped directory verbatim. The scraper reads a recorded store dir with exactDirectory: true — the mode that lets a record carrying no cwd past the ownership check — so an attacker-chosen directory's contents came back as this project's own history, to the next agent, with no provenance check.

The cwd guard already in the hook doesn't cover this: it validates a different field, and a payload can name the project correctly there while pointing transcript_path anywhere on the machine. That's the shape used to reproduce it.

The fix

A legitimate value always sits under the tool's own store root as <store>/<encoded-project>/<id>.jsonl, so containment against that root is the check that fits.

  • The root comes from the tool definition, not an assumption — CLAUDE_CONFIG_DIR moving the tree is the case this payload exists to handle in the first place.
  • resolve collapses .. before comparing, so the decision is made on the destination rather than the string.
  • A path outside the root is dropped, not raised. The hook runs inside another agent's startup and must not fail a session over a payload it merely mistrusts; dropping it costs only the reconstruction this was optimising away.

Verification, running the built CLI

payload result
<project>/../../../../../../evil/x.jsonl rejected
C:/Windows/Temp/x.jsonl rejected
~/.claude/projects/h--some-project/abc.jsonl recorded — the case the payload exists for

That last row matters: rejecting it too would be a "fix" that silently turns the optimisation off for everyone.

mutation result
remove the containment check 2 of 3 tests fail

709 tests pass, typecheck and lint clean.

Found by an audit agent fixturing hostile input against the real hook.

…nd keep design drafts out of search

CHANGELOG.md listed 112 "release xtctx X.Y.Z" entries running up to 0.74.0
under the 0.20.0 heading. No tag exists above 0.21.8 and npm has never
published past 0.19.0, so a reader scanning the file for what shipped was
being shown versions that do not exist.

They are real commits — an automated pipeline cut them in a few hours on
2026-08-29 before the version was reset to 0.20.0 and the pipeline replaced by
a manually-triggered release. So this summarises what happened in one entry
rather than deleting the history: the churn is worth recording, the 112
individual bullets are not.

Separately, `landing/src/pages/v9.astro` is a design draft that builds to a
public URL and carried no `noindex`, so a near-duplicate of the landing page
was indexable alongside the real one — and its hand-copied FAQ has already
drifted from the live copy, describing four MCP tools where the real page
lists five. The `concepts/` drafts already carried the tag; v9 did not, and
nothing carried a robots.txt.

Verified against a real build: `landing/dist/robots.txt` is emitted, the built
v9 page carries the meta tag, and the real landing page does NOT — a noindex
on the published page is the failure this could most easily have introduced.

The robots.txt deliberately names no sitemap. There is no sitemap.xml in the
build, and pointing crawlers at a 404 is its own small false claim.
…ipt store

The session-start hook reads `transcript_path` from its stdin and recorded
`dirname()` of it into `.xtctx/state/store-dirs.json` as this project's
claude-code store, to be read on every later scan. Nothing checked where it
pointed.

Reproduced end to end before the fix: a payload naming this project in `cwd`
and `<project>/../../../../../../evil/x.jsonl` in `transcript_path` recorded
the escaped directory verbatim. The scraper reads a recorded store dir with
`exactDirectory: true` — the mode that lets a record carrying no `cwd` past
the ownership check — so an attacker-chosen directory's contents came back as
this project's own history, to the next agent, with no provenance check.

The `cwd` guard already in the hook does not cover this. It validates a
different field, and a payload can name the project correctly there while
pointing `transcript_path` anywhere on the machine; that is the shape used to
reproduce it.

A legitimate value always sits under the tool's own store root as
`<store>/<encoded-project>/<id>.jsonl`, so containment against that root is
the check that fits. The root comes from the tool definition rather than being
assumed, because `CLAUDE_CONFIG_DIR` moving the tree is the case this payload
exists to handle in the first place. `resolve` collapses `..` before the
comparison, so the decision is made on the destination rather than the string.

A path outside the root is dropped rather than raised: the hook runs inside
another agent's startup and must not fail a session over a payload it merely
mistrusts. Dropping it costs only the reconstruction this was optimising away.

Verified by running the built CLI: traversal rejected, an unrelated absolute
path rejected, a genuine `~/.claude/projects/...` path still recorded.
Removing the containment check turns two of the three tests red.

Found by an audit agent fixturing hostile input against the real hook.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant