fix: harden workspace runtime snapshots

This commit is contained in:
2026-08-03 22:10:09 +02:00
parent 049f8675c6
commit 5d7ebc5b01
6 changed files with 297 additions and 18 deletions
+2
View File
@@ -1,5 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import { join } from "node:path";
import type { AppConfig } from "./config.js";
import { ThtRunner } from "./tht/tht-runner.js";
import { PiProcessManager } from "./pi/pi-process-manager.js";
@@ -40,6 +41,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot,
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
});
+153 -9
View File
@@ -1,5 +1,9 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync, constants as fsConstants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync,
} from "node:fs";
import { isAbsolute, join } from "node:path";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js";
@@ -9,6 +13,7 @@ export interface ThtConfig extends SecretBundleConfig {
harnessDir: string;
configPath: string;
dataRoot?: string;
runtimeSnapshotRoot?: string;
}
export interface SessionRow {
@@ -38,7 +43,18 @@ export interface OllamaEnsureResult {
model_name?: string;
}
interface RuntimeSnapshot {
path: string;
dev: number;
ino: number;
size: number;
mode: number;
digest: string;
}
export class ThtRunner {
private readonly runtimeSnapshots = new Map<string, RuntimeSnapshot>();
constructor(private cfg: ThtConfig, private principal?: PrincipalContext) {}
/** Bind one trusted request principal to every child spawned by this runner. */
@@ -51,7 +67,10 @@ export class ThtRunner {
*/
private configArg(workspaceConfigPath?: string): string[] {
if (workspaceConfigPath) {
if (isAbsolute(workspaceConfigPath)) return ["-c", workspaceConfigPath];
if (isAbsolute(workspaceConfigPath)) {
this.assertTrustedRuntimeSnapshot(workspaceConfigPath);
return ["-c", workspaceConfigPath];
}
if (workspaceConfigPath.includes("/")) {
throw new Error("workspace snapshot config path must be absolute");
}
@@ -64,6 +83,115 @@ export class ThtRunner {
return ["-c", this.cfg.configPath];
}
private runtimeSnapshotDirectory(): string {
if (!this.cfg.runtimeSnapshotRoot) throw new Error("runtime snapshot root is not configured");
if (!isAbsolute(this.cfg.runtimeSnapshotRoot)) throw new Error("runtime snapshot root must be absolute");
mkdirSync(this.cfg.runtimeSnapshotRoot, { recursive: true, mode: 0o700 });
const directory = lstatSync(this.cfg.runtimeSnapshotRoot);
if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 0o077) !== 0) {
throw new Error("runtime snapshot root is not trusted");
}
return realpathSync(this.cfg.runtimeSnapshotRoot);
}
private static isRestrictiveMode(mode: number): boolean {
const permissions = mode & 0o777;
return (permissions & 0o400) !== 0 && (permissions & ~0o600) === 0;
}
private assertTrustedRuntimeSnapshot(path: string): RuntimeSnapshot {
const snapshot = this.runtimeSnapshots.get(path);
if (!snapshot) throw new Error("config path is not a trusted runtime snapshot");
try {
const entry = lstatSync(path);
const stat = statSync(path);
if (
!entry.isFile() || entry.isSymbolicLink()
|| stat.dev !== snapshot.dev || stat.ino !== snapshot.ino || stat.size !== snapshot.size
|| (stat.mode & 0o777) !== snapshot.mode || !ThtRunner.isRestrictiveMode(stat.mode)
|| createHash("sha256").update(readFileSync(path)).digest("hex") !== snapshot.digest
) throw new Error("changed runtime snapshot");
return snapshot;
} catch {
throw new Error("config path is not a trusted runtime snapshot");
}
}
/** Create an opaque, backend-owned temporary config that is safe to hand to `tht`. */
createRuntimeSnapshot(config: string): string {
const directory = this.runtimeSnapshotDirectory();
const path = join(directory, `runtime-${randomUUID()}.yaml`);
const fd = openSync(
path,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
0o600,
);
try {
writeFileSync(fd, config, "utf8");
fsyncSync(fd);
fchmodSync(fd, 0o400);
const stat = fstatSync(fd);
this.runtimeSnapshots.set(path, {
path,
dev: stat.dev,
ino: stat.ino,
size: stat.size,
mode: stat.mode & 0o777,
digest: createHash("sha256").update(config, "utf8").digest("hex"),
});
return path;
} catch (error) {
try { unlinkSync(path); } catch { /* creation did not produce a removable file */ }
throw error;
} finally {
closeSync(fd);
}
}
cleanupRuntimeSnapshot(path: string): void {
const snapshot = this.runtimeSnapshots.get(path);
if (!snapshot) return;
this.runtimeSnapshots.delete(path);
try { unlinkSync(snapshot.path); } catch { /* a changed path is never removed recursively */ }
}
private openTrustedRuntimeSnapshot(path: string): number {
const snapshot = this.assertTrustedRuntimeSnapshot(path);
const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
try {
const stat = fstatSync(fd);
if (
!stat.isFile() || stat.dev !== snapshot.dev || stat.ino !== snapshot.ino || stat.size !== snapshot.size
|| (stat.mode & 0o777) !== snapshot.mode || !ThtRunner.isRestrictiveMode(stat.mode)
) throw new Error("changed runtime snapshot");
const contents = Buffer.alloc(snapshot.size);
let offset = 0;
while (offset < contents.length) {
const bytes = readSync(fd, contents, offset, contents.length - offset, offset);
if (bytes === 0) throw new Error("truncated runtime snapshot");
offset += bytes;
}
if (createHash("sha256").update(contents).digest("hex") !== snapshot.digest) {
throw new Error("changed runtime snapshot");
}
return fd;
} catch {
closeSync(fd);
throw new Error("config path is not a trusted runtime snapshot");
}
}
async runWithRuntimeSnapshot(
args: string[], config: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
): Promise<{ code: number; stdout: string; stderr: string }> {
const snapshot = this.createRuntimeSnapshot(config);
try {
return await this.run(args, snapshot, timeoutMs);
} finally {
this.cleanupRuntimeSnapshot(snapshot);
}
}
/**
* Build the full argv for a `tht` invocation. `--config`/`-c` is a PER-COMMAND
* option in the `tht` CLI (there is NO global `-c`), so it MUST be appended
@@ -80,7 +208,7 @@ export class ThtRunner {
static readonly DWH_TIMEOUT_MS = 120_000;
run(
args: string[], workspace?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = { ...process.env };
@@ -99,10 +227,26 @@ export class ThtRunner {
env.THT_CA = ca;
env.THT_SSL_CA = ca;
}
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
cwd: this.cfg.harnessDir,
env,
});
let snapshotFd: number | undefined;
let ch;
try {
snapshotFd = workspaceConfigPath && isAbsolute(workspaceConfigPath)
? this.openTrustedRuntimeSnapshot(workspaceConfigPath)
: undefined;
ch = spawn(
this.cfg.thtBin,
snapshotFd === undefined
? this.buildArgv(args, workspaceConfigPath)
: [...args, "-c", "/dev/fd/3"],
{
cwd: this.cfg.harnessDir,
env,
...(snapshotFd === undefined ? {} : { stdio: ["ignore", "pipe", "pipe", snapshotFd] }),
},
);
} finally {
if (snapshotFd !== undefined) closeSync(snapshotFd);
}
let stdout = "";
let stderr = "";
let settled = false;
@@ -119,8 +263,8 @@ export class ThtRunner {
finish({ code: 124, stdout, stderr: stderr || `timed out after ${timeoutMs}ms` });
}, timeoutMs);
}
ch.stdout.on("data", (d: Buffer) => (stdout += d));
ch.stderr.on("data", (d: Buffer) => (stderr += d));
ch.stdout?.on("data", (d: Buffer) => (stdout += d));
ch.stderr?.on("data", (d: Buffer) => (stderr += d));
ch.on("error", (error) => finish({ code: 1, stdout, stderr: stderr || error.message }));
ch.on("close", (code) => finish({ code: code ?? 0, stdout, stderr }));
});
+7 -4
View File
@@ -31,10 +31,10 @@ function requireBinding(binding: ResolvedBinding, name: string): string {
function legacyDirectConnection(
binding: ResolvedBinding,
names: { host: string; port: string; user: string; passwordFile: string },
names: { host: string; port: string; user: string; passwordFile: string; tlsCaFile: string },
identity: { database: string; schema: string },
): Record<string, unknown> {
return {
const connection: Record<string, unknown> = {
host: requireBinding(binding, names.host),
port: Number(requireBinding(binding, names.port)),
database: identity.database,
@@ -42,6 +42,9 @@ function legacyDirectConnection(
user: requireBinding(binding, names.user),
password_file: requireBinding(binding, names.passwordFile),
};
const tlsCaFile = bindingValue(binding, names.tlsCaFile);
if (tlsCaFile !== undefined) connection.ssl_ca_file = tlsCaFile;
return connection;
}
function legacyRestEndpoint(
@@ -93,13 +96,13 @@ export function renderRuntimeConfig(
const database = dwhDirect
? { ...legacyDirectConnection(bindings.dwh, {
host: name("DWH", "HOST"), port: name("DWH", "PORT"), user: name("DWH", "USER"),
passwordFile: name("DWH", "PASSWORD_FILE"),
passwordFile: name("DWH", "PASSWORD_FILE"), tlsCaFile: name("DWH", "TLS_CA_FILE"),
}, dwhIdentity), transport: "direct" }
: placeholderConnection(dwhIdentity);
const vectorDb = vectorDirect
? legacyDirectConnection(bindings.vector, {
host: name("VECTOR", "HOST"), port: name("VECTOR", "PORT"), user: name("VECTOR", "USER"),
passwordFile: name("VECTOR", "PASSWORD_FILE"),
passwordFile: name("VECTOR", "PASSWORD_FILE"), tlsCaFile: name("VECTOR", "TLS_CA_FILE"),
}, vectorIdentity)
: placeholderConnection(vectorIdentity);
const embedding: Record<string, unknown> = {