Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 9 additions & 9 deletions src/tracking/track-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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; }
}
Expand Down
29 changes: 29 additions & 0 deletions src/tracking/track-lock-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,32 @@ export function withTrackLockSync<T>(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<T>(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 */ }
}
}

90 changes: 90 additions & 0 deletions test/track-journal-toctou.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => new Promise((r) => setTimeout(r, ms));

async function waitFor(cond: () => boolean, ms: number): Promise<void> {
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<number | null>((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<number | null>((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;
}
});
});