fix(backend): harden principal child isolation

This commit is contained in:
User
2026-07-16 18:38:40 +02:00
parent 458eb13c89
commit b454fb478b
9 changed files with 167 additions and 26 deletions
+24 -2
View File
@@ -1,4 +1,4 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
@@ -10,6 +10,25 @@ export interface PrincipalContext {
isAdmin: boolean;
}
const principalEnvKeys = [
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
] as const;
export function clearPrincipalEnvironment(env: NodeJS.ProcessEnv): void {
for (const key of principalEnvKeys) delete env[key];
}
export function expandLocalHome(path: string, home = homedir()): string {
if (path === "~") return home;
if (path.startsWith("~/")) return join(home, path.slice(2));
return path;
}
function harden(path: string, mode: number): void {
if (process.platform === "win32") return;
try { chmodSync(path, mode); } catch { /* best-effort parity with harness local storage */ }
}
const invalid = (value: string) => value.length === 0 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value);
function required(value: unknown): string | undefined {
@@ -34,12 +53,14 @@ export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalCo
}
export function localPrincipal(): PrincipalContext {
const home = process.env.THT_HOME ?? join(homedir(), ".thothii");
const home = expandLocalHome(process.env.THT_HOME ?? join(homedir(), ".thothii"));
const identityPath = join(home, "identity.json");
mkdirSync(home, { recursive: true, mode: 0o700 });
harden(home, 0o700);
try {
const stored = JSON.parse(readFileSync(identityPath, "utf8"));
if (stored?.issuer === "local" && typeof stored.subject === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(stored.subject)) {
harden(identityPath, 0o600);
return { issuer: "local", subject: stored.subject, isAdmin: false };
}
throw new Error("invalid local identity");
@@ -48,6 +69,7 @@ export function localPrincipal(): PrincipalContext {
const principal = { issuer: "local", subject: randomUUID() };
try {
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
harden(identityPath, 0o600);
return { ...principal, isAdmin: false };
} catch (writeError: any) {
// Another local request won the identity creation race; always converge on its UUID.
+2 -1
View File
@@ -5,7 +5,7 @@ import { SessionBridge } from "../bridge/session-bridge.js";
import type { ThtRunner } from "../tht/tht-runner.js";
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
import { secretValue } from "../config/secret-bundle.js";
import { principalEnvironment, type PrincipalContext } from "../auth/principal.js";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
export interface SessionRuntime {
rpc: RpcClient;
@@ -55,6 +55,7 @@ export class PiProcessManager {
credentialFile: this.cfg.modelApiKeyFile,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
clearPrincipalEnvironment(env);
if (principal) Object.assign(env, principalEnvironment(principal));
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
// this managed session process the adapter values already loaded by the core
+5 -5
View File
@@ -397,7 +397,7 @@ export function sessionRoutes(
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setName(id, (req.body as any).name);
await runnerFor(principal).setName(id, (req.body as any).name, settings.workspace);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
@@ -407,7 +407,7 @@ export function sessionRoutes(
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setGroup(id, (req.body as any).group);
await runnerFor(principal).setGroup(id, (req.body as any).group, settings.workspace);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
@@ -417,7 +417,7 @@ export function sessionRoutes(
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).archive(id);
await runnerFor(principal).archive(id, settings.workspace);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
@@ -427,7 +427,7 @@ export function sessionRoutes(
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).unarchive(id);
await runnerFor(principal).unarchive(id, settings.workspace);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
@@ -458,7 +458,7 @@ export function sessionRoutes(
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
return await runnerFor(principal).documents(id);
return await runnerFor(principal).documents(id, settings.workspace);
} catch { return storageFailure(reply); }
});
}
+7 -6
View File
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { principalEnvironment, type PrincipalContext } from "../auth/principal.js";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
export interface ThtConfig {
thtBin: string;
@@ -68,6 +68,7 @@ export class ThtRunner {
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.THT_DATA_ROOT;
clearPrincipalEnvironment(env);
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
@@ -159,15 +160,15 @@ export class ThtRunner {
closeSession(id: string, workspace?: string) { return this.ok(["session", "close", id], workspace); }
failSession(id: string, workspace?: string) { return this.ok(["session", "fail", id], workspace); }
reopenSession(id: string, workspace?: string) { return this.ok(["session", "reopen", id], workspace); }
setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); }
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
archive(id: string) { return this.ok(["session", "archive", id]); }
unarchive(id: string) { return this.ok(["session", "unarchive", id]); }
setName(id: string, name: string, workspace?: string) { return this.ok(["session", "set-name", id, "--name", name], workspace); }
setGroup(id: string, group: string, workspace?: string) { return this.ok(["session", "set-group", id, "--group", group], workspace); }
archive(id: string, workspace?: string) { return this.ok(["session", "archive", id], workspace); }
unarchive(id: string, workspace?: string) { return this.ok(["session", "unarchive", id], workspace); }
async deleteSession(id: string, workspace?: string) {
const { code, stderr } = await this.run(["session", "delete", id], workspace);
if (code !== 0) throw new Error(`tht session delete exit ${code}: ${stderr.trim()}`);
}
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
documents(id: string, workspace?: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"], workspace); }
preferencesGet(workspace?: string) { return this.json<Record<string, unknown>>(["session", "preferences", "get", "--json"], workspace); }
async preferencesSet(preferences: Record<string, unknown>, workspace?: string): Promise<void> {