// 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 /seats///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; } }