From 3b4e2665a6bde7f3ea272ebae93217c564139529 Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Tue, 27 Oct 2026 09:48:50 +0000 Subject: [PATCH] fix(state): one store per project; hooks never create one; bounded touch trail baseDir() honored CLAUDE_PLUGIN_DATA. Claude Code sets it for hooks and the MCP server but never for the model's own shell, so hook writes went to a shadow store that the CLI never read. The stop-check nag read the shadow too, so /ratchet:compile could never clear it. The base is now RATCHET_DATA_DIR, else ~/.ratchet, for every caller. Hooks now act only on a store that already exists. session-start and stop-check used to create a store in every directory a session opened in: one machine had 992 empty stores. touchedFiles keeps only the newest touch of each path, newest last, up to TOUCHED_FILES_MAX (500). The cap covers the hook, ratchet touch and state append. One store had reached 22,761 rows (4.4 MB), and every edit rewrote all of it. On a copy of that store, the first touch shrinks it to 96 KB. Three new tests in test/cli.test.js went red on the unpatched tree. npm test passes all 16 suites on an export of this commit's index. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++++++++ README.md | 14 ++++--- SECURITY.md | 2 +- src/cli.js | 19 +++++---- src/state.js | 11 ++++-- src/verbs.js | 31 +++++++++++++++ test/cli.test.js | 100 ++++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 175 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a286121..e79ffc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **One store per project, and a bounded touch trail (found 2026-10-27 reading a live + install; all CLI-enforced, each test seen red against the unpatched tree):** + - **One base for every caller.** `baseDir()` honored `$CLAUDE_PLUGIN_DATA`, which Claude + Code sets for hooks and the MCP server but never for the model's own shell. Hook + writes therefore landed in a shadow store the CLI never read, and the `stop-check` + nag read that shadow store too, so `/ratchet:compile` could never clear it. The base + is now `$RATCHET_DATA_DIR`, else `~/.ratchet`, for all callers. Stores left under + `$CLAUDE_PLUGIN_DATA` are no longer read and are never moved or deleted. On the + machine where this was found, all 992 of them held only hook touch rows. + - **Hooks never create a store.** `session-start` initialized a store, and `stop-check` + loaded one, in every directory a session opened in. Hooks now act only on a store + that already exists, so a project gets a store only where someone runs ratchet. + - **`touchedFiles` is bounded.** Every edit appended a row and rewrote the whole list, + and nothing trimmed it: one store reached 22,761 rows (4.4 MB). Each path now keeps + only its newest touch, newest last, capped at `TOUCHED_FILES_MAX` (500). The cap + applies to the hook, `ratchet touch` and `state append touchedFiles`, and an + oversized trail collapses on the next touch. The receipt's "touched since compile" + set is unchanged, because it only ever read each path's newest touch. + - **Safe-core hardening (three live defects found by the 4c design review rounds 2–3, 2026-08-01; all CLI-enforced, each with a falsifier (H1–H5) seen red against the unpatched tree):** diff --git a/README.md b/README.md index 468cb20..2db3b78 100644 --- a/README.md +++ b/README.md @@ -523,14 +523,18 @@ ratchet status ### State location -State survives plugin updates. It is written to, in order of preference: +State survives plugin updates. Every caller (the CLI, the hooks and the MCP server) writes to +one base: -1. `$CLAUDE_PLUGIN_DATA` — set by Claude Code for enabled plugins. -2. `$RATCHET_DATA_DIR` — override it yourself. -3. `~/.ratchet` — fallback. +1. `$RATCHET_DATA_DIR` — override it yourself. +2. `~/.ratchet` — default. + +`$CLAUDE_PLUGIN_DATA` is not used: Claude Code sets it for hooks and the MCP server but not +for the model's own shell, so honoring it would split one project into two stores. State is scoped per project (by working-directory path), so multiple repos never collide in -one shared data directory. +one shared data directory. Hooks only update a store that already exists; they never create +one. --- diff --git a/SECURITY.md b/SECURITY.md index 51ce77c..cc1c881 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,7 +29,7 @@ Torque Loop is a Claude Code plugin and a **zero-dependency**, local Node.js CLI network surface and no runtime dependencies. The most relevant areas for security review are: - **Local file & state handling** — the CLI reads and writes state under the resolved data - directory (`$CLAUDE_PLUGIN_DATA`, `$RATCHET_DATA_DIR`, or `~/.ratchet`). + directory (`$RATCHET_DATA_DIR`, or `~/.ratchet`). - **Hooks** — the bundled hooks (`hooks/hooks.json`) run on session events. By design they never run tests or edits on their own; a hook that executes something unexpected is a bug worth reporting. diff --git a/src/cli.js b/src/cli.js index 336022b..6ba51b6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -242,10 +242,7 @@ function run(argv) { const file = sub; if (!file) throw new Error('usage: ratchet touch '); assertMayWrite('touch'); - mutate(cwd, 'touch', (s) => { - s.touchedFiles.push({ path: file, at: schemas.nowIso() }); - s.dirty = true; - }); + mutate(cwd, 'touch', (s) => verbs.recordTouch(s, file)); return out(`touched ${file}`); } @@ -627,9 +624,13 @@ function cmdExport(cwd, sub) { function cmdHook(cwd, sub) { try { + // Hooks fire in every project the plugin is enabled in, so they act only on + // a store that already exists. Creating one here left a store behind in + // every directory a session ever opened in — 992 empty ones on one machine. + const hasStore = fs.existsSync(state.statePath(cwd)); switch (sub) { case 'session-start': { - state.initProject(cwd); + if (hasStore) state.initProject(cwd); return; // silent } case 'post-edit': { @@ -641,19 +642,17 @@ function cmdHook(cwd, sub) { } catch (_e) { /* ignore malformed */ } - if (file) { + if (file && hasStore) { // A hook fires while the session's own commands are running, so this // is the write most likely to race one. It goes through the boundary // like every other mutation — and, like every other mutation, it is // refused for a propose-only agent (which leaves no footprint at all). - mutate(cwd, 'hook post-edit', (s) => { - s.touchedFiles.push({ path: file, at: schemas.nowIso() }); - s.dirty = true; - }); + mutate(cwd, 'hook post-edit', (s) => verbs.recordTouch(s, file)); } return; // silent, non-blocking } case 'stop-check': { + if (!hasStore) return; // ratchet never ran here: nothing to compile, nothing to nag const s = state.loadState(cwd); const stale = s.dirty && (!s.lastCompileAt || s.lastCompileAt < s.updatedAt); if (stale) { diff --git a/src/state.js b/src/state.js index 3b31565..def6274 100644 --- a/src/state.js +++ b/src/state.js @@ -10,13 +10,16 @@ const schemas = require('./schemas'); const wal = require('./wal'); // --------------------------------------------------------------------------- -// Location. State survives plugin updates when CLAUDE_PLUGIN_DATA is set. -// Everything is scoped per-project so multiple repos never collide in one -// shared plugin data directory. +// Location. One base for every caller: RATCHET_DATA_DIR, else ~/.ratchet. +// CLAUDE_PLUGIN_DATA is deliberately ignored. Claude Code sets it for hooks and +// the MCP server but never for the model's own shell, so honoring it split one +// project into two stores: hook writes landed in a shadow store the CLI never +// read. ~/.ratchet already survives plugin updates. +// Everything is scoped per-project so multiple repos never collide in one base. // --------------------------------------------------------------------------- function baseDir() { - const explicit = process.env.RATCHET_DATA_DIR || process.env.CLAUDE_PLUGIN_DATA; + const explicit = process.env.RATCHET_DATA_DIR; if (explicit && explicit.trim()) return explicit.trim(); const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); return path.join(home, '.ratchet'); diff --git a/src/verbs.js b/src/verbs.js index 4f40e82..aeaa170 100644 --- a/src/verbs.js +++ b/src/verbs.js @@ -59,10 +59,39 @@ function appendItem(s, collection, item, mintId) { } const record = { id: item.id || mintId(schemas.STATE_COLLECTIONS[collection], 'record'), at: schemas.nowIso(), ...item }; s[collection].push(record); + if (collection === 'touchedFiles') boundTouched(s); s.dirty = true; return { record }; } +// touchedFiles is a trail, not a log. The receipt reads it as the set of paths +// changed since the last compile, so only each path's newest touch carries +// information. Unbounded, the post-edit hook grew one store to 22,761 rows +// (4.4 MB) and rewrote all of it on every edit. Keep one row per path, newest +// last, at most TOUCHED_FILES_MAX of them; an oversized trail collapses on the +// next touch. +const TOUCHED_FILES_MAX = 500; + +function boundTouched(s) { + const seen = new Set(); + const kept = []; + for (let i = s.touchedFiles.length - 1; i >= 0 && kept.length < TOUCHED_FILES_MAX; i--) { + const f = s.touchedFiles[i]; + const key = f && f.path; + if (key && seen.has(key)) continue; // an older touch of a path already kept + if (key) seen.add(key); + kept.push(f); + } + s.touchedFiles = kept.reverse(); +} + +// The hook's and `ratchet touch`'s one write. +function recordTouch(s, file) { + s.touchedFiles.push({ path: file, at: schemas.nowIso() }); + boundTouched(s); + s.dirty = true; +} + // `state close`: transition one openLoop or assumption to its terminal status. // Parking is a close too: it stops the nagging, but it STILL DRAINS, because a // parked question is an unanswered question with an owner, not an answered @@ -170,6 +199,8 @@ module.exports = { setScalar, BIRTH_STATUS, appendItem, + TOUCHED_FILES_MAX, + recordTouch, closeRecord, compileDone, fogAlreadyOnRecord, diff --git a/test/cli.test.js b/test/cli.test.js index 0662dd3..9c6973d 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -1030,7 +1030,13 @@ ok('a propose-only hook says it is propose-only, not that the state is unreadabl const proj = path.join(tmp, 'hook-propose-only'); fs.rmSync(proj, { recursive: true, force: true }); fs.mkdirSync(proj, { recursive: true }); - const r = spawnSync(process.execPath, [path.resolve(__dirname, '..', 'bin', 'ratchet'), 'hook', 'post-edit'], { + // The store must exist: a hook in a project with no store records nothing, so + // there would be no write for the guard to refuse. + const bin = path.resolve(__dirname, '..', 'bin', 'ratchet'); + const init = { ...process.env, RATCHET_DATA_DIR: path.join(proj, '.data') }; + delete init.RATCHET_AGENT; + assert.strictEqual(spawnSync(process.execPath, [bin, 'init'], { cwd: proj, env: init, encoding: 'utf8' }).status, 0); + const r = spawnSync(process.execPath, [bin, 'hook', 'post-edit'], { cwd: proj, env: { ...process.env, RATCHET_DATA_DIR: path.join(proj, '.data'), RATCHET_AGENT: 'ratchet-builder' }, input: JSON.stringify({ tool_input: { file_path: 'README.md' } }), @@ -1042,6 +1048,98 @@ ok('a propose-only hook says it is propose-only, not that the state is unreadabl assert.ok(!/closure state unreadable/.test(text), `and must not claim the record is unreadable; got: ${text.trim()}`); }); +ok('hooks and the CLI resolve one store, whether or not CLAUDE_PLUGIN_DATA is set', () => { + // Claude Code sets CLAUDE_PLUGIN_DATA for hooks and the MCP server, never for + // the model's own shell. Honoring it put every hook write in a shadow store + // the CLI never read: one project grew 22,761 touch rows there while its real + // record sat untouched under ~/.ratchet. + const { spawnSync } = require('child_process'); + const bin = path.resolve(__dirname, '..', 'bin', 'ratchet'); + const proj = path.join(tmp, 'one-base'); + fs.rmSync(proj, { recursive: true, force: true }); + fs.mkdirSync(proj, { recursive: true }); + const home = path.join(proj, '.home'); + const pluginData = path.join(proj, '.plugin-data'); + const shell = { ...process.env, HOME: home, USERPROFILE: home, RATCHET_EVOLVE_LOG: path.join(proj, 'evolve-log.jsonl') }; + delete shell.RATCHET_DATA_DIR; + delete shell.RATCHET_AGENT; + delete shell.CLAUDE_PLUGIN_DATA; + const hook = { ...shell, CLAUDE_PLUGIN_DATA: pluginData }; + + assert.strictEqual(spawnSync(process.execPath, [bin, 'state', 'set', 'objective', 'one base'], { cwd: proj, env: shell, encoding: 'utf8' }).status, 0); + const r = spawnSync(process.execPath, [bin, 'hook', 'post-edit'], { + cwd: proj, env: hook, input: JSON.stringify({ tool_input: { file_path: 'README.md' } }), encoding: 'utf8', + }); + assert.strictEqual(r.status, 0, r.stderr); + + assert.ok(!fs.existsSync(path.join(pluginData, 'projects')), 'the hook must not open a second store under CLAUDE_PLUGIN_DATA'); + const projects = path.join(home, '.ratchet', 'projects'); + const stores = fs.readdirSync(projects); + assert.strictEqual(stores.length, 1, `one store for one project — got: ${stores.join(', ')}`); + const s = JSON.parse(fs.readFileSync(path.join(projects, stores[0], 'state.json'), 'utf8')); + assert.strictEqual(s.objective, 'one base'); + assert.deepStrictEqual(s.touchedFiles.map((f) => f.path), ['README.md'], 'the hook touch lands in the store the CLI wrote'); +}); + +ok('hooks never create a store in a project that has none', () => { + // A hook fires in every project the plugin is enabled in. Creating a store + // there filled one plugin data dir with 992 empty stores; a store exists only + // where someone ran ratchet. + const { spawnSync } = require('child_process'); + const bin = path.resolve(__dirname, '..', 'bin', 'ratchet'); + const proj = path.join(tmp, 'hook-no-store'); + fs.rmSync(proj, { recursive: true, force: true }); + fs.mkdirSync(proj, { recursive: true }); + const data = path.join(proj, '.data'); + const env = { ...process.env, RATCHET_DATA_DIR: data }; + delete env.RATCHET_AGENT; + for (const sub of ['session-start', 'post-edit', 'stop-check']) { + const r = spawnSync(process.execPath, [bin, 'hook', sub], { + cwd: proj, env, input: JSON.stringify({ tool_input: { file_path: 'README.md' } }), encoding: 'utf8', + }); + assert.strictEqual(r.status, 0, `${sub} never breaks the session`); + assert.strictEqual(String(r.stderr || ''), '', `${sub} has nothing to say about a project ratchet never ran in`); + } + assert.ok(!fs.existsSync(path.join(data, 'projects')), 'no store was created'); +}); + +ok('touchedFiles keeps one row per path, newest last, and stays bounded', () => { + // The receipt reads touchedFiles as the set of paths changed since the last + // compile, so only each path's newest touch carries information. Unbounded, + // every edit appended a row and rewrote the whole list. + const verbs = require('../src/verbs'); + const max = verbs.TOUCHED_FILES_MAX; + const proj = path.join(tmp, 'touch-bound'); + fs.rmSync(proj, { recursive: true, force: true }); + fs.mkdirSync(proj, { recursive: true }); + state.initProject(proj, { force: true }); + inProject(proj, () => { + const s = state.loadState(proj); + s.touchedFiles = [{ path: 'dup.md', at: '2026-01-01T00:00:00.000Z' }]; + for (let i = 0; i < max + 50; i++) s.touchedFiles.push({ path: `f${i}.md`, at: '2026-01-02T00:00:00.000Z' }); + s.touchedFiles.push({ path: 'dup.md', at: '2026-01-03T00:00:00.000Z' }); + state.saveState(proj, s); + + say(() => cli.run(['node', 'ratchet', 'touch', 'new.md'])); + let t = state.loadState(proj).touchedFiles; + assert.strictEqual(t.length, max, `an oversized trail collapses to the bound on the next touch — got ${t.length}`); + assert.strictEqual(t[t.length - 1].path, 'new.md', 'the newest touch is last'); + const dups = t.filter((f) => f.path === 'dup.md'); + assert.strictEqual(dups.length, 1, 'one row per path'); + assert.strictEqual(dups[0].at, '2026-01-03T00:00:00.000Z', 'and it is the newest touch of that path'); + assert.ok(!t.some((f) => f.path === 'f0.md'), 'the oldest paths fall off first'); + + say(() => cli.run(['node', 'ratchet', 'touch', 'new.md'])); + t = state.loadState(proj).touchedFiles; + assert.strictEqual(t.filter((f) => f.path === 'new.md').length, 1, 'touching a path again moves it, never repeats it'); + + say(() => cli.run(['node', 'ratchet', 'state', 'append', 'touchedFiles', '{"path":"appended.md"}'])); + t = state.loadState(proj).touchedFiles; + assert.strictEqual(t.length, max, 'state append is bounded by the same rule'); + assert.strictEqual(t[t.length - 1].path, 'appended.md'); + }); +}); + ok('score confidence leaves no write footprint for a propose-only agent', () => { state.initProject(cwd, { force: true }); const s = state.loadState(cwd);