From a79715262a234a4385cd45823fa5b13dc75c5af1 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Fri, 24 Jul 2026 13:39:53 +0200 Subject: [PATCH 1/2] fix(tracking): close append/compaction TOCTOU lost-write (blocking append lock) The journal append path used a bare appendFileSync (O_APPEND) with no mutual exclusion against the compactor. Compaction is rename -> fold -> unlink on a fresh inode; an append could straddle open->write between compaction's rename and unlink, landing its write in the renamed inode that then got unlinked -- a silent lost write, never surfaced by the per-line HMAC (fail-closed per line has nothing to reject: the write simply never reached the surviving file). Fix: appendEvent now serialises on withTrackLockSyncBlocking, the same track.lock the compaction takes, spinning (1ms step, stale-lock TTL as the only anti-deadlock guard) until acquired -- never skipped, unlike the existing non-blocking withTrackLockSync used elsewhere. An append can no longer race compaction's rename/fold/unlink window. Probe (test/track-journal-toctou.test.ts) is deterministic and proven non-vacant independently: (A) a child process blocked on a manually held track.lock must NOT complete its append until the lock is released -- red without the fix (bare appendFileSync ignores the lock), green with it. (B) a real appendEvent loop running during a real maybeCompactJournal must serialise behind the compaction with zero event loss -- red without the fix (loop finishes inside the compaction window), green with it. Verified non-flaky 5/5, full suite 900/0. --- src/tracking/track-journal.ts | 18 +++---- src/tracking/track-lock-sync.ts | 29 ++++++++++ test/track-journal-toctou.test.ts | 90 +++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 test/track-journal-toctou.test.ts diff --git a/src/tracking/track-journal.ts b/src/tracking/track-journal.ts index 43ec1fb..42f439d 100644 --- a/src/tracking/track-journal.ts +++ b/src/tracking/track-journal.ts @@ -2,17 +2,18 @@ * @module track-journal * Append-only, per-line-signed event journal for {@link SessionTrack} — the * fan-out-immune replacement for the locked RMW (generalises the freshness/ - * ref-journal.ts pattern to every field). O_APPEND keeps short appends from - * concurrent hook processes non-interleaved: no lock, no lost write (compaction - * is rename-atomic — see track-compact.ts). Per-line HMAC (same key/scheme as - * integrity.ts): a tampered line is dropped, never the whole file (fail-closed - * PER LINE). + * ref-journal.ts pattern to every field). Appends serialise on a SHORT + * BLOCKING track lock (never skipped, sub-ms wait — see appendEvent) so they + * can never race the rename-atomic compaction: zero lost write. Per-line HMAC + * (same key/scheme as integrity.ts): a tampered line is dropped, never the + * whole file (fail-closed PER LINE). * @packageDocumentation */ -import { appendFileSync, mkdirSync } from "node:fs"; +import { appendFileSync } from "node:fs"; import { dirname } from "node:path"; import { randomBytes } from "node:crypto"; import { computeMac, loadOrCreateKey } from "./integrity"; +import { withTrackLockSyncBlocking } from "./track-lock-sync"; import { emptyTrack, type SessionTrack } from "./session-state"; import type { AuthEntry } from "../freshness/doc-helpers"; import type { SessionTarget } from "../policy/apex-authorization"; @@ -38,13 +39,12 @@ export function signEvent(field: string, op: TrackEvent["op"], value: unknown, t return { v: 1, field, op, value, ts, nonce, mac: computeMac(loadOrCreateKey(), data, nonce) }; } -/** Append one signed event line. Fail-open: returns false instead of throwing. */ +/** Append one signed event line under the BLOCKING track lock (same `track.lock` as the compaction — an append can never straddle rename/fold/unlink; never skipped). Fail-open on I/O error. */ export function appendEvent(logPath: string, field: string, op: TrackEvent["op"], value: unknown, ts: number): boolean { try { const ev = signEvent(field, op, value, ts); if (!ev) return false; - mkdirSync(dirname(logPath), { recursive: true }); - appendFileSync(logPath, JSON.stringify(ev) + "\n", "utf8"); + withTrackLockSyncBlocking(dirname(logPath), () => appendFileSync(logPath, JSON.stringify(ev) + "\n", "utf8")); return true; } catch { return false; } } diff --git a/src/tracking/track-lock-sync.ts b/src/tracking/track-lock-sync.ts index cfbfcf1..63d7f48 100644 --- a/src/tracking/track-lock-sync.ts +++ b/src/tracking/track-lock-sync.ts @@ -52,3 +52,32 @@ export function withTrackLockSync(dir: string, fn: () => T): T | typeof LOCK_ try { unlinkSync(lock); } catch { /* best-effort release */ } } } + +/** + * Blocking twin of {@link withTrackLockSync}: NEVER skips — spins (1 ms step) + * until the lock is acquired. Used by the journal append path (track-journal + * `appendEvent`): an append must wait out an in-flight compaction (same + * `track.lock`), never race its rename/fold/unlink, never be skipped — a lost + * write is not an option. The stale-lock TTL (dead-process reclamation) is the + * only anti-deadlock guard. Do NOT use on paths that may already hold the lock. + */ +export function withTrackLockSyncBlocking(dir: string, fn: () => T): T { + mkdirSync(dir, { recursive: true }); + const lock = join(dir, "track.lock"); + for (;;) { + try { + const fd = openSync(lock, "wx"); + closeSync(fd); + break; + } catch { + if (isStale(lock)) { try { unlinkSync(lock); } catch { /* raced */ } } + sleepSync(1); + } + } + try { + return fn(); + } finally { + try { unlinkSync(lock); } catch { /* best-effort release */ } + } +} + diff --git a/test/track-journal-toctou.test.ts b/test/track-journal-toctou.test.ts new file mode 100644 index 0000000..8c080a0 --- /dev/null +++ b/test/track-journal-toctou.test.ts @@ -0,0 +1,90 @@ +/** + * Deterministic probes for the append/compaction TOCTOU (the publish-CI lost + * write: an append preempted between open and write while compaction ran + * rename → fold → unlink landed in the renamed inode and was unlinked). + * Test A is the non-regression guard (the real appendEvent must BLOCK on + * track.lock). Test B runs the REAL integration cross-process: a real + * appendEvent loop executing WHILE a real maybeCompactJournal compacts the + * same dir — it must serialise behind the compaction and lose nothing. + */ +import { test, expect } from "bun:test"; +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { loadTrack } from "../src/tracking/store"; +import { appendEvent } from "../src/tracking/track-journal"; +import { journalLogPath, maybeCompactJournal } from "../src/tracking/track-compact"; +import { dir, TJOURNAL, withEnv } from "./helpers/track-env"; + +const BASE = 1_700_000_000_000; +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(cond: () => boolean, ms: number): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > ms) throw new Error("waitFor timeout"); + await sleep(25); + } +} + +test("A) appendEvent BLOCKS on track.lock (mutual exclusion with compaction) — deterministic", async () => { + await withEnv(undefined, async () => { + const d = dir(), file = join(d, "track.json"), log = journalLogPath(file); + appendEvent(log, "refsRead", "add", "a.md", BASE); + writeFileSync(join(d, "track.lock"), "held"); // same lockfile the compaction takes + const script = `import { appendEvent } from ${JSON.stringify(TJOURNAL)}; +process.stdout.write("READY\\n"); +appendEvent(${JSON.stringify(log)}, "refsRead", "add", "probe.md", ${BASE + 1}); +process.stdout.write("APPENDED\\n");`; + const p = spawn("bun", ["-e", script], { env: { ...process.env } }); // inherited HOME → same .key + let out = ""; + p.stdout!.on("data", (c: Buffer) => (out += c.toString())); + const exit = new Promise((done) => p.on("close", done)); + await waitFor(() => out.includes("READY"), 10_000); // child sits AT the append point + await sleep(400); // an unlocked append would have landed long ago + expect(out).not.toContain("APPENDED"); // RED before the fix: bare appendFileSync ignores the lock + rmSync(join(d, "track.lock")); // release: the blocked append must now proceed + expect(await exit).toBe(0); + expect(out).toContain("APPENDED"); + expect((await loadTrack(file)).refsRead).toContain("probe.md"); // 0 loss + }); +}); + +test("B) real appendEvent loop DURING a real maybeCompactJournal: serialises, 0 loss — deterministic", async () => { + await withEnv(undefined, async () => { + const prevCap = process.env.FUSE_TRACK_COMPACT_BYTES; + process.env.FUSE_TRACK_COMPACT_BYTES = "1"; // the real compaction fires immediately + try { + const d = dir(), file = join(d, "track.json"), log = journalLogPath(file); + const PRE = 20_000, N = 2_000; + for (let i = 0; i < PRE; i++) appendEvent(log, "refsRead", "add", `pre-${i}.md`, BASE + i); // ~600 ms fold window + const go = join(d, "go"); + const script = `import { appendEvent } from ${JSON.stringify(TJOURNAL)}; +import { existsSync } from "node:fs"; +process.stdout.write("READY\\n"); +while (!existsSync(${JSON.stringify(go)})) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); +for (let i = 0; i < ${N}; i++) appendEvent(${JSON.stringify(log)}, "refsRead", "add", "b-" + i + ".md", ${BASE} + i); +process.stdout.write("DONE " + Date.now() + "\\n");`; + const p = spawn("bun", ["-e", script], { env: { ...process.env } }); // inherited HOME → same .key + let out = ""; + p.stdout!.on("data", (c: Buffer) => (out += c.toString())); + const exit = new Promise((done) => p.on("close", done)); + await waitFor(() => out.includes("READY"), 10_000); + writeFileSync(go, "1"); // release the appender INTO the compaction window + await maybeCompactJournal(file); // REAL compaction: rename → fold 20k lines → unlink + const compactEnd = Date.now(); + expect(await exit).toBe(0); + const doneTs = Number(out.match(/DONE (\d+)/)?.[1]); + // Without the append lock the loop finishes ~200 ms after GO, deep INSIDE + // the ~600 ms compaction → doneTs < compactEnd (RED). With it, the loop + // blocks on track.lock until the compaction releases → doneTs > compactEnd. + expect(doneTs).toBeGreaterThan(compactEnd); + const refs = (await loadTrack(file)).refsRead; + expect(refs.length).toBe(PRE + N); // 0 perte + expect(refs).toContain(`b-${N - 1}.md`); + } finally { + if (prevCap === undefined) delete process.env.FUSE_TRACK_COMPACT_BYTES; + else process.env.FUSE_TRACK_COMPACT_BYTES = prevCap; + } + }); +}); From 20f72154e8876e02180ba7f8dd8a018005968cb5 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Fri, 24 Jul 2026 13:40:33 +0200 Subject: [PATCH 2/2] chore: update CHANGELOG to 0.1.83 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc502a..767110c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.83] - 2026-07-24 + +### Fixed + +- **Append/compaction TOCTOU lost-write in the event journal** (`src/tracking/track-journal.ts`, `src/tracking/track-lock-sync.ts`) — `appendEvent` used a bare `appendFileSync` with no mutual exclusion against the compactor's rename→fold→unlink; an append straddling open→write during compaction could land in the renamed inode and be silently unlinked. `appendEvent` now serialises on `withTrackLockSyncBlocking`, a blocking twin of the existing non-blocking lock (spins on `track.lock`, stale-lock TTL as the only anti-deadlock guard, never skipped), the same lock the compaction takes — zero lost write. Deterministic, non-vacant probes (`test/track-journal-toctou.test.ts`): a manually-held-lock block test, and a real `appendEvent` loop racing a real `maybeCompactJournal` with zero event loss asserted. + ## [0.1.82] - 2026-07-24 ### Added diff --git a/package.json b/package.json index ce74edc..aa4a5db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.82", + "version": "0.1.83", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts",