Controller, claim store, live-session guard, engine link and seal, turn tracker, cohort force stop and recovery, client library, transcript and mediated terminal, with the fake engine and tests. Fixtures only; no live cutover. Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694) approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files). Suites on an export: conversation 152/152, control-board 124, webui 14, seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green. Follow-ups for I3 are in DEFERRED. Gate E stays with Jason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
144 lines
6.0 KiB
JavaScript
144 lines
6.0 KiB
JavaScript
// The live-session guard (#1507, CHAT-03 §2).
|
|
//
|
|
// CHAT-03 is fixture-only in code. The claim root, the socket directory and
|
|
// every session path are constructor arguments, with no default. At
|
|
// construction and again at bind, their real paths must lie inside the
|
|
// explicit fixture root and outside every protected location: the
|
|
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
|
|
// path a seat registration names. A path is refused when it is inside a
|
|
// protected location or contains one. The real protections always apply;
|
|
// options only add to them, so a test can't switch them off.
|
|
//
|
|
// The guard comes out only at cutover (CHAT-07), as a reviewed data-map
|
|
// change.
|
|
|
|
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { ControlRefusal } from "./safe-fs.mjs";
|
|
|
|
export const LIVE_SESSION_REFUSED = "live-session-refused";
|
|
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
|
|
// The real path of `p`, or of its nearest existing ancestor with the rest
|
|
// appended when `p` doesn't exist yet.
|
|
export function realPath(p) {
|
|
const abs = resolve(p);
|
|
const tail = [];
|
|
let cur = abs;
|
|
for (;;) {
|
|
try {
|
|
return join(realpathSync(cur), ...tail.reverse());
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") throw err;
|
|
const up = dirname(cur);
|
|
if (up === cur) return abs;
|
|
tail.push(basename(cur));
|
|
cur = up;
|
|
}
|
|
}
|
|
}
|
|
|
|
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
const overlaps = (a, b) => inside(a, b) || inside(b, a);
|
|
|
|
function configuredDataRoot(home) {
|
|
try {
|
|
const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8"));
|
|
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
|
|
} catch {
|
|
// An unreadable config adds no location; the default data root still applies.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
|
|
// unreadable one adds nothing; the data root itself is protected anyway.
|
|
function registrationsUnder(dataRoot) {
|
|
const out = [];
|
|
const seats = join(dataRoot, "seats");
|
|
let layouts = [];
|
|
try {
|
|
layouts = readdirSync(seats);
|
|
} catch {
|
|
return out;
|
|
}
|
|
for (const layout of layouts) {
|
|
let names = [];
|
|
try {
|
|
names = readdirSync(join(seats, layout));
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const seat of names) {
|
|
try {
|
|
out.push(JSON.parse(readFileSync(join(seats, layout, seat, "registration.json"), "utf8")));
|
|
} catch {
|
|
// skipped
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function pathsIn(value, out) {
|
|
if (typeof value === "string") {
|
|
if (isAbsolute(value)) out.push(value);
|
|
} else if (Array.isArray(value)) {
|
|
for (const v of value) pathsIn(v, out);
|
|
} else if (value && typeof value === "object") {
|
|
for (const v of Object.values(value)) pathsIn(v, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export class LiveSessionGuard {
|
|
// `fixtureRoot` is required. `repoRoots`, `homes`, `dataRoots` and
|
|
// `registrations` add protected locations to the real ones.
|
|
constructor({ fixtureRoot, repoRoots = [], homes = [], dataRoots = [], registrations = [] } = {}) {
|
|
if (typeof fixtureRoot !== "string" || !isAbsolute(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "an absolute fixture root is required");
|
|
if (!existsSync(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "the fixture root does not exist");
|
|
this.fixtureRoot = fixtureRoot;
|
|
const home = homedir();
|
|
const allHomes = [home, ...homes];
|
|
const protectedPaths = [];
|
|
for (const repo of [REPO_ROOT, ...repoRoots]) protectedPaths.push({ path: join(repo, ".pi", "state"), why: "a repository .pi/state" });
|
|
for (const h of allHomes) {
|
|
protectedPaths.push({ path: join(h, ".pi"), why: "~/.pi" });
|
|
protectedPaths.push({ path: join(h, ".claude"), why: "~/.claude" });
|
|
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
|
|
const configured = configuredDataRoot(h);
|
|
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
|
|
}
|
|
for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" });
|
|
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
|
|
const found = roots.flatMap(registrationsUnder);
|
|
for (const reg of [...found, ...registrations]) for (const p of pathsIn(reg, [])) protectedPaths.push({ path: p, why: "a seat registration" });
|
|
this.protected = protectedPaths;
|
|
}
|
|
|
|
// Refuses unless every path is inside the fixture root and clear of every
|
|
// protected location, both as written and as real paths.
|
|
check(paths, when) {
|
|
const root = realPath(this.fixtureRoot);
|
|
const guarded = this.protected.flatMap((p) => [{ ...p, path: resolve(p.path) }, { ...p, path: realPath(p.path) }]);
|
|
for (const [name, p] of Object.entries(paths)) {
|
|
if (typeof p !== "string" || !isAbsolute(p)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} must be an absolute path (${when})`);
|
|
const written = resolve(p), real = realPath(p);
|
|
if (!inside(written, root) && !inside(written, resolve(this.fixtureRoot))) {
|
|
throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} is outside the fixture root (${when})`);
|
|
}
|
|
// The real path must be inside the real fixture root: a symlink out of
|
|
// it is refused even when the link itself sits inside.
|
|
if (!inside(real, root)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} resolves outside the fixture root (${when})`);
|
|
for (const candidate of [written, real]) {
|
|
const hit = guarded.find((g) => overlaps(candidate, g.path));
|
|
if (hit) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} overlaps ${hit.why} (${when})`);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|