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]>
129 lines
5.4 KiB
JavaScript
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 };
|