Files
stack/packages/conversation/src/guard.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)
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]>
2026-10-04 15:47:53 -05:00

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