fix: harden runtime config snapshot publication
This commit is contained in:
@@ -306,6 +306,12 @@ export class ThtRunner {
|
||||
return new Promise((resolve) => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.THT_DATA_ROOT;
|
||||
// Handoff variables are backend-owned capabilities, never inherited from an
|
||||
// operator shell or forwarded request environment.
|
||||
delete env.THT_RUNTIME_CONFIG_MANIFEST_SHA256;
|
||||
delete env.THT_CONFIG_FD;
|
||||
delete env.THT_CONFIG_MANIFEST_FD;
|
||||
delete env.THT_CONFIG_MANIFEST_SHA256;
|
||||
clearPrincipalEnvironment(env);
|
||||
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
||||
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
|
||||
@@ -321,41 +327,27 @@ export class ThtRunner {
|
||||
env.THT_SSL_CA = ca;
|
||||
}
|
||||
let snapshotFd: number | undefined;
|
||||
let canonicalFd: number | undefined;
|
||||
let manifestFd: number | undefined;
|
||||
let ch;
|
||||
try {
|
||||
snapshotFd = workspaceConfigPath && this.runtimeSnapshots.has(workspaceConfigPath)
|
||||
? this.openTrustedRuntimeSnapshot(workspaceConfigPath) : undefined;
|
||||
const lease = workspaceConfigPath ? this.runtimeLeases.get(workspaceConfigPath) : undefined;
|
||||
// Registry leases retain the verified config bytes in fd 3 and the separately
|
||||
// published manifest in fd 4. The argv remains the canonical -c pathname for
|
||||
// diagnostics/compatibility; the harness never trusts that pathname for bytes.
|
||||
canonicalFd = snapshotFd === undefined && lease
|
||||
? openSync(lease.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) : undefined;
|
||||
manifestFd = lease
|
||||
? openSync(lease.manifestPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) : undefined;
|
||||
if (lease) {
|
||||
env.THT_CONFIG_FD = "3";
|
||||
env.THT_CONFIG_MANIFEST_FD = "4";
|
||||
env.THT_CONFIG_MANIFEST_SHA256 = lease.manifestSha256;
|
||||
}
|
||||
const handoffFd = snapshotFd ?? canonicalFd;
|
||||
// Runtime leases use the canonical path plus an authenticated manifest digest.
|
||||
// FD 3/4 remain reserved for the maintenance writer/root ABI.
|
||||
if (lease) env.THT_RUNTIME_CONFIG_MANIFEST_SHA256 = lease.manifestSha256;
|
||||
ch = spawn(
|
||||
this.cfg.thtBin,
|
||||
snapshotFd === undefined ? this.buildArgv(args, workspaceConfigPath) : [...args, "-c", "/dev/fd/3"],
|
||||
{
|
||||
cwd: this.cfg.harnessDir,
|
||||
env,
|
||||
...(handoffFd === undefined ? {} : {
|
||||
stdio: ["ignore", "pipe", "pipe", handoffFd, ...(manifestFd === undefined ? [] : [manifestFd])],
|
||||
...(snapshotFd === undefined ? {} : {
|
||||
stdio: ["ignore", "pipe", "pipe", snapshotFd],
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (snapshotFd !== undefined) closeSync(snapshotFd);
|
||||
if (canonicalFd !== undefined) closeSync(canonicalFd);
|
||||
if (manifestFd !== undefined) closeSync(manifestFd);
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RuntimePaths,
|
||||
type SemanticRuntimeConfig,
|
||||
} from "./runtime-renderer.js";
|
||||
import { parseWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
import { parseWorkspaceYaml, serializeWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
|
||||
export interface RuntimeConfigLease {
|
||||
path: string;
|
||||
@@ -50,6 +50,8 @@ interface SnapshotIdentity {
|
||||
revisionContentRoot: string;
|
||||
digest: string;
|
||||
descriptorBlob?: string;
|
||||
descriptorDev: string;
|
||||
descriptorIno: string;
|
||||
}
|
||||
interface PublishedIdentity {
|
||||
path: string;
|
||||
@@ -154,6 +156,8 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
workspace_id: snapshot.workspaceId, workspace_revision: snapshot.workspaceRevision,
|
||||
descriptor_git_blob: snapshot.descriptorBlob!,
|
||||
descriptor_sha256: snapshot.digest,
|
||||
descriptor_dev: snapshot.descriptorDev,
|
||||
descriptor_ino: snapshot.descriptorIno,
|
||||
config_sha256: renderedDigest,
|
||||
config_dwh_binding: this.computeBinding(rendered),
|
||||
};
|
||||
@@ -168,10 +172,14 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
|
||||
private helper(action: string, extra: Record<string, unknown>): any {
|
||||
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
||||
const executable = existsSync(python) ? python : (process.env.PYTHON ?? "python3");
|
||||
const modulePath = existsSync(join(this.input.harnessDir, "tht", "runtime_config_lease_io.py"))
|
||||
? join(this.input.harnessDir, "tht", "runtime_config_lease_io.py")
|
||||
: join(process.cwd(), "../harness/tht/runtime_config_lease_io.py");
|
||||
// Fixtures may provide a temporary harness directory; still execute the real
|
||||
// project helper environment, never a fabricated TypeScript binding.
|
||||
const projectPython = join(dirname(dirname(modulePath)), ".venv", "bin", "python");
|
||||
const executable = existsSync(python) ? python
|
||||
: existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
|
||||
const helperArgs = existsSync(modulePath) ? [modulePath] : ["-m", "tht.runtime_config_lease_io"];
|
||||
const result = spawnSync(executable, helperArgs, { cwd: this.input.harnessDir,
|
||||
input: JSON.stringify({ action, ...extra }), encoding: "utf8",
|
||||
@@ -188,12 +196,10 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
try {
|
||||
const value = this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
|
||||
if (value && typeof value.workspace_id === "string" && typeof value.config_fingerprint === "string" && typeof value.input_fingerprint === "string") return value;
|
||||
throw new Error("runtime config binding helper returned malformed output");
|
||||
} catch (error) {
|
||||
// Development fixtures may intentionally omit the harness virtualenv. Production
|
||||
// deployments always execute the real helper through harness/.venv/bin/python.
|
||||
if (existsSync(join(this.input.harnessDir, ".venv", "bin", "python"))) throw error;
|
||||
throw error instanceof Error ? error : new Error("runtime config binding failed");
|
||||
}
|
||||
return { workspace_id: "unknown", config_fingerprint: `sha256:${digest(content)}`, input_fingerprint: `sha256:${digest(content)}` };
|
||||
}
|
||||
|
||||
private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): {path:string; manifestPath:string; manifest:string; manifest_sha256:string} {
|
||||
@@ -231,11 +237,25 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
snapshots_root: root, repository_root: repositoryRoot,
|
||||
workspace_revision: match[1], workspace_id: match[2],
|
||||
});
|
||||
if (!verified || typeof verified.source !== "string"
|
||||
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path) {
|
||||
if (!verified || typeof verified.source !== "string" || typeof verified.git_source !== "string"
|
||||
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path
|
||||
|| !/^\d+$/.test(String(verified.descriptor_dev)) || !/^\d+$/.test(String(verified.descriptor_ino))) {
|
||||
throw new Error("workspace snapshot integrity check failed");
|
||||
}
|
||||
const workspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.source));
|
||||
// The registry's production canonicalizer is the sole descriptor equivalence
|
||||
// rule. Raw token containment is not identity: it permits changed values.
|
||||
let workspace: WorkspaceDescriptor;
|
||||
let gitWorkspace: WorkspaceDescriptor;
|
||||
try {
|
||||
workspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.source));
|
||||
gitWorkspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.git_source));
|
||||
if (serializeWorkspaceYaml(workspace) !== serializeWorkspaceYaml(gitWorkspace)
|
||||
|| serializeWorkspaceYaml(workspace) !== verified.source) {
|
||||
throw new Error("canonical descriptor differs from Git");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`workspace snapshot integrity check failed: ${error instanceof Error ? error.message : "invalid descriptor"}`);
|
||||
}
|
||||
if (workspace.workspace.id !== match[2] || typeof verified.descriptor_git_blob !== "string") {
|
||||
throw new Error("workspace snapshot integrity check failed");
|
||||
}
|
||||
@@ -243,6 +263,7 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
workspace, workspaceId: match[2], workspaceRevision: match[1],
|
||||
revisionContentRoot: join(root, match[1]), digest: verified.sha256,
|
||||
descriptorBlob: verified.descriptor_git_blob,
|
||||
descriptorDev: String(verified.descriptor_dev), descriptorIno: String(verified.descriptor_ino),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { WorkspaceRuntimeConfigLeaseFactory } from "../src/workspaces/runtime-config-lease.js";
|
||||
import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||
|
||||
const workspace = "abc";
|
||||
const descriptor = `workspace:
|
||||
@@ -33,13 +34,14 @@ llm_policy:
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-"));
|
||||
const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor));
|
||||
const snapshots = join(root, "snapshots");
|
||||
const repo = join(root, "repo");
|
||||
mkdirSync(join(repo, "workspaces"), { recursive: true });
|
||||
execFileSync("git", ["init", "--initial-branch=main"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.email", "fixture@example.invalid"], { cwd: repo });
|
||||
writeFileSync(join(repo, "workspaces", `${workspace}.yaml`), descriptor);
|
||||
writeFileSync(join(repo, "workspaces", `${workspace}.yaml`), canonicalDescriptor);
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo });
|
||||
const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim();
|
||||
@@ -50,17 +52,22 @@ function fixture() {
|
||||
const harness = join(root, "harness");
|
||||
mkdirSync(snapshotsDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(snapshots, 0o700);
|
||||
const publicFiles = {
|
||||
[`${workspace}.yaml`]: canonicalDescriptor,
|
||||
[`${workspace}.env.example`]: "# fixture\n",
|
||||
[`${workspace}.md`]: "# Lease\n",
|
||||
};
|
||||
for (const [name, contents] of Object.entries(publicFiles)) writeFileSync(join(snapshotsDir, name), contents, { mode: 0o400 });
|
||||
writeFileSync(join(snapshotsDir, "snapshot.json"), JSON.stringify({
|
||||
head: actualCommit,
|
||||
revisions: [{ id: workspace, commit: actualCommit, blob, snapshotPath }],
|
||||
files: { [`${workspace}.yaml`]: createHash("sha256").update(descriptor).digest("hex") },
|
||||
files: Object.fromEntries(Object.entries(publicFiles).map(([name, contents]) => [name, createHash("sha256").update(contents).digest("hex")])),
|
||||
}), { mode: 0o400 });
|
||||
mkdirSync(harness);
|
||||
writeFileSync(snapshotPath, descriptor, { mode: 0o400 });
|
||||
const secret = join(root, "password");
|
||||
writeFileSync(secret, "secret", { mode: 0o600 });
|
||||
const configPath = join(harness, "config.yaml");
|
||||
writeFileSync(configPath, "profile: test\n");
|
||||
writeFileSync(configPath, "profile: workstation\n");
|
||||
const factory = new WorkspaceRuntimeConfigLeaseFactory({
|
||||
dataRoot, runtimeSnapshotRoot: snapshots, harnessDir: harness, configPath,
|
||||
env: {
|
||||
@@ -72,7 +79,7 @@ function fixture() {
|
||||
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
|
||||
},
|
||||
});
|
||||
return { root, snapshotPath, factory };
|
||||
return { root, snapshotPath, factory, canonicalDescriptor };
|
||||
}
|
||||
|
||||
test("session and maintenance share deterministic bytes and path", () => {
|
||||
@@ -84,6 +91,9 @@ test("session and maintenance share deterministic bytes and path", () => {
|
||||
expect(readFileSync(session.path, "utf8")).toBe(readFileSync(maintenance.path, "utf8"));
|
||||
expect(lstatSync(session.path).mode & 0o777).toBe(0o400);
|
||||
expect(existsSync(maintenance.manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(maintenance.manifestPath, "utf8"));
|
||||
expect(manifest).toMatchObject({ version: 1, descriptor_dev: expect.any(String), descriptor_ino: expect.any(String) });
|
||||
expect(existsSync(join(dirname(dirname(maintenance.path)), "runtime-config.lock"))).toBe(false);
|
||||
session.release(); maintenance.release();
|
||||
expect(existsSync(session.path)).toBe(true);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
@@ -100,3 +110,34 @@ test("same revision changed bytes are refused", () => {
|
||||
first.release();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("snapshot descriptor must equal the Git canonical descriptor", () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics");
|
||||
chmodSync(f.snapshotPath, 0o600);
|
||||
writeFileSync(f.snapshotPath, mutated, { mode: 0o600 });
|
||||
chmodSync(f.snapshotPath, 0o400);
|
||||
const manifestPath = join(dirname(f.snapshotPath), "snapshot.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
manifest.files[`${workspace}.yaml`] = createHash("sha256").update(mutated).digest("hex");
|
||||
chmodSync(manifestPath, 0o600);
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||
chmodSync(manifestPath, 0o400);
|
||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("same-byte replacement of the registry descriptor is refused", () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = f.factory.acquireSession(f.snapshotPath);
|
||||
const replacement = `${f.snapshotPath}.replacement`;
|
||||
writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 });
|
||||
chmodSync(f.snapshotPath, 0o600);
|
||||
rmSync(f.snapshotPath);
|
||||
writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 });
|
||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/identity|changed|mismatch|trusted/i);
|
||||
first.release();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user