Skip to content

fix(store): harden JSON-file store — unique staging, list shape-guard, .. partition escape (#502, #501, #499) - #513

Draft
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-47zroh
Draft

fix(store): harden JSON-file store — unique staging, list shape-guard, .. partition escape (#502, #501, #499)#513
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-47zroh

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Auto-resolves three related open bug reports that all harden the JSON-file store family against foreign / escaped files silently breaking data or permanently 500-ing GET /v1/conversations. #501 and #499 both stem from the same root (files landing in the flat conversation dir), and #501 explicitly cross-references the #499 .. escape, so they're fixed together.

#502 — shared .tmp drops concurrent writes to one id

atomicWrite derived its staging file solely from the target path, so all concurrent writers of one id shared a single <path>.tmp. Writer B truncates A's tmp; whoever renames second finds nothing and rejects with ENOENT, silently dropping a write. Reachable without a second socket: POST /v1/conversations/:id/messages (+ /stream) have no in-flight guard and both catch-and-ignore the save failure, so a double-submit / client retry / two open tabs loses an entire turn with nothing shown to the user.

Fix: stage to a per-writer <path>.<pid>.<uuid>.tmp and clean it up in finally. The name still ends in .tmp (not .json), so every directory-scanning list() keeps skipping leftover litter. Semantics become honest last-writer-wins.

#501 — one foreign JSON file permanently 500s the conversation list

JsonFileConversationStore.list blind-cast every parsed .json and dereferenced stored.messages.length with no shape guard and no per-file try/catch. One foreign-but-well-formed file — workspaces.json flushed into the same base dir, or a row written by the #499 escape — threw a TypeError that escaped list() and the endpoint; the sidebar went empty and stayed empty.

Fix: skip records whose messages isn't an array and wrap the per-file body in try/catch, matching activity-store.ts and observability/json-file.ts.

#499.. project_id escapes the storage partition (security)

encodeId treats . as safe, so encodeId("..") === "..". As a filename the .json suffix neutralises it, but JsonFileRequirementStore.#projectDir and JsonFileCommentStore.#requirementDir join it as a bare directory component, so a project_id / requirement_id of .. escapes into the sibling conversation dir (both stores open off the same base). requirement.create { project_id: ".." } drops a messages-less row into the conversation dir (the visible symptom is the #501 outage), and requirements.list("..") returns every conversation coerced into a Requirement. Reachable from both the agent tool surface and the REST API.

Fix: add encodePartition — identical to encodeId for real ids, but throws StoreError when the encoded segment is ``, ., or `..` (the only `encodeId` outputs that can traverse; `/` and `` are already percent-encoded). Route both partition-dir helpers through it — the single storage choke point every read/write/list/delete passes through. `encodeId` itself is unchanged, so the `memory.summary:` filename layout and existing on-disk data are untouched.

Changes

  • packages/store/src/json-file.ts — unique per-writer staging + finally cleanup in atomicWrite; shape guard + per-file try/catch in list(); new encodePartition.
  • packages/store/src/index.ts — export encodePartition.
  • packages/store/src/requirement-store.ts, comment-store.ts — route partition-dir helpers through encodePartition.
  • store.test.ts, requirement-store.test.ts, comment-store.test.ts — regression tests for all three.

Testing

  • pnpm --filter @jarvis/store typecheck — clean.
  • Suites pass: store (21), requirement-store (16), comment-store (21), learning (84), todo (30), observability (31), workspace-store (15). CI build was green on the first commit.

Closes #502
Closes #501
Closes #499

🤖 Generated with Claude Code

https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB

claude added 2 commits July 22, 2026 01:26
json-file.ts backs every JSON-file store plus @jarvis/observability and
@jarvis/learning, so both bugs here are broadly reachable.

#502 — atomicWrite derived its staging file solely from the target path, so
all concurrent writers of one id shared a single `<path>.tmp`. Writer B
truncates A's tmp and whoever renames second finds nothing and rejects with
ENOENT, silently dropping a write (the REST message routes catch-and-ignore
the failure). Stage to a per-writer `<path>.<pid>.<uuid>.tmp` and clean it up
in `finally`; the name still ends in `.tmp` (not `.json`) so every directory
scanner keeps skipping leftover litter. Semantics become honest
last-writer-wins.

#501 — JsonFileConversationStore.list blind-cast every parsed `.json` and
dereferenced `stored.messages.length` with no shape guard and no per-file
try/catch. One foreign-but-well-formed file (e.g. workspaces.json flushed
into the same dir) threw a TypeError that escaped list() and permanently
500'd GET /v1/conversations. Skip records whose `messages` isn't an array and
wrap the per-file body in try/catch, matching activity-store /
observability/json-file.

Adds regression tests for both: concurrent same-id saves settle with no ENOENT
and no tmp litter, and list survives a foreign wrong-shape JSON file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB
#499)

`encodeId` treats `.` as a safe byte, so `encodeId("..") === ".."`. As a
filename the trailing `.json` neutralises it, but `JsonFileRequirementStore
.#projectDir` and `JsonFileCommentStore.#requirementDir` join it as a bare
*directory* component — so a `project_id`/`requirement_id` of `..` escapes the
partition into the sibling conversation dir (both stores open off the same
base). A `requirement.create { project_id: ".." }` then drops a requirement
row (no `messages`) into the conversation dir, which — combined with the
missing list() shape-guard — permanently 500s GET /v1/conversations, and
`requirements.list("..")` returns every conversation coerced into a Requirement.
Reachable from both the agent tool surface and the REST API (labelled security).

Add `encodePartition`: same as `encodeId` for real ids, but throws StoreError
when the encoded segment is ``, `.`, or `..` (the only encodeId outputs that
can traverse — `/`/`\` are already percent-encoded). Route both partition-dir
helpers through it, so the guard sits at the single storage choke point every
read/write/list/delete already passes through. `encodeId` itself is unchanged,
so the `__memory__.summary:` filename layout and on-disk data are untouched.

Regression tests: a `..` project_id / requirement_id is rejected (never writes
into the base dir) on both stores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB
@TYRMars TYRMars changed the title fix(store): unique atomicWrite staging + list shape-guard (#502, #501) fix(store): harden JSON-file store — unique staging, list shape-guard, .. partition escape (#502, #501, #499) Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment