Files
stack/packages/conversation/src/safe-fs.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

129 lines
5.4 KiB
JavaScript

// Read-only file access for approved Pi session roots (#1507, CHAT-02).
//
// A root is <projectRoot>/.pi/state/<seat>/sessions. The project root comes
// from the board's own configuration and is trusted as given (it may itself
// be a symlink, like the compatibility path to this checkout). Every
// component below it must be a real directory, never a symlink, and a
// session file must be a regular file directly inside the root.
//
// A file is opened O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and the descriptor's
// (dev, ino) must match the lstat taken before the open. Node has no openat,
// so a directory component swapped between the checks and the open is caught
// by re-checking the components after the open, not prevented outright. The
// seat that owns a root can write there anyway; the checks keep anything
// outside the root from being read through it.
//
// Nothing here writes, renames, creates or migrates a file.
import { lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, constants } from "node:fs";
import { join, relative, isAbsolute, sep, basename } from "node:path";
export class Refusal extends Error {
constructor(code, message, { reconcile = false } = {}) {
super(message);
this.code = code;
this.reconcile = reconcile;
}
}
// Refusals of the CHAT-03 control layer (controller, claims, guard, engine
// pin). They reach a socket client, never the reader's HTTP routes, so the
// control board's status map covers only the reader's own codes.
export class ControlRefusal extends Refusal {}
const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*\.jsonl$/;
// Permission errors inside a root are one conversation's problem, not the
// catalogue's: they become a refusal instead of a thrown error.
function denied(err, what) {
if (err.code === "EACCES" || err.code === "EPERM") return new Refusal("unreadable", `${what} is not readable`);
return err;
}
// A directory above the file without search permission is refused the same
// way, so one bad root does not fail the catalogue.
function lstatOrNull(path) {
try {
return lstatSync(path, { bigint: true });
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
throw denied(err, "a session path component");
}
}
// Every component from the project root down to the sessions directory must
// be a real directory.
export function checkRoot(root) {
const rel = relative(root.projectRoot, root.dir);
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Refusal("unsafe-path", "session root is outside its project");
let path = root.projectRoot;
for (const part of rel.split(sep)) {
path = join(path, part);
const st = lstatOrNull(path);
if (!st) throw new Refusal("unavailable", "session root does not exist");
if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink");
if (!st.isDirectory()) throw new Refusal("unsafe-path", "session root is not a directory");
}
}
// Session files directly inside a root, by name. Symlinks and anything that
// is not a regular *.jsonl file are reported, never followed.
export function listSessionFiles(root) {
checkRoot(root);
const files = [], refused = [];
let dirents;
try {
dirents = readdirSync(root.dir, { withFileTypes: true });
} catch (err) {
throw denied(err, "session root");
}
for (const dirent of dirents) {
if (!dirent.name.endsWith(".jsonl")) continue;
if (!SESSION_NAME.test(dirent.name)) refused.push({ name: dirent.name, code: "unsafe-path" });
else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" });
else if (dirent.isFile()) files.push(dirent.name);
}
return { files: files.sort(), refused };
}
// Opens one session file read-only. The caller must close the returned fd.
export function openSessionFile(root, name) {
if (typeof name !== "string" || name !== basename(name) || !SESSION_NAME.test(name)) throw new Refusal("unsafe-path", "not a session file name");
checkRoot(root);
const path = join(root.dir, name);
const before = lstatOrNull(path);
if (!before) throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true });
if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink");
if (!before.isFile()) throw new Refusal("unsafe-path", "session file is not a regular file");
let fd;
try {
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (err) {
if (err.code === "ELOOP") throw new Refusal("unsafe-path", "session file became a symlink");
if (err.code === "ENOENT") throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true });
throw denied(err, "session file");
}
try {
const st = fstatSync(fd, { bigint: true });
if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino) throw new Refusal("unsafe-path", "session file changed while it was opened");
checkRoot(root);
return { fd, dev: st.dev.toString(), ino: st.ino.toString(), size: Number(st.size) };
} catch (err) {
closeSync(fd);
throw err;
}
}
export function readRange(fd, start, length) {
const buf = Buffer.alloc(length);
let done = 0;
while (done < length) {
const n = readSync(fd, buf, done, length - done, start + done);
if (n === 0) break;
done += n;
}
return done === length ? buf : buf.subarray(0, done);
}
export { closeSync };