From e9613767c5e4e30ecb0fe2b135d1ffca483a7c77 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 10:48:24 +0200 Subject: [PATCH] fix: harden runtime config snapshot publication --- backend/src/tht/tht-runner.ts | 30 ++-- .../src/workspaces/runtime-config-lease.ts | 39 ++++- .../workspace-runtime-config-lease.test.ts | 53 +++++- harness/tht/config.py | 103 ++++++++++-- harness/tht/runtime_config_lease_io.py | 156 +++++++++++------- 5 files changed, 280 insertions(+), 101 deletions(-) diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index e459a5ac..e7841cd2 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -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 = ""; diff --git a/backend/src/workspaces/runtime-config-lease.ts b/backend/src/workspaces/runtime-config-lease.ts index 7d480305..488acc6e 100644 --- a/backend/src/workspaces/runtime-config-lease.ts +++ b/backend/src/workspaces/runtime-config-lease.ts @@ -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): 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): {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), }; } } diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index b414f992..34530ae7 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -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 }); } +}); diff --git a/harness/tht/config.py b/harness/tht/config.py index 1973fab3..256a379d 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -3,6 +3,7 @@ import json import os import re import stat +import sys import warnings from ipaddress import ip_address from pathlib import Path @@ -596,8 +597,9 @@ def _read_runtime_fd(fd: int, label: str, expected_mode: int = 0o400) -> tuple[b def _strict_runtime_manifest(raw: object) -> dict[str, object]: required = { "version", "workspace_id", "workspace_revision", "descriptor_git_blob", - "descriptor_sha256", "config_sha256", "config_dwh_binding", "config_dev", - "config_ino", "config_size", "config_mode", "config_uid", "config_nlink", + "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", + "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", + "config_uid", "config_nlink", } if not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1: raise ConfigError("Manifest runtime non valido") @@ -609,7 +611,7 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]: for key in ("descriptor_sha256", "config_sha256"): if not isinstance(raw[key], str) or not re.fullmatch(r"[0-9a-f]{64}", raw[key]): raise ConfigError("Manifest runtime non valido") - for key in ("config_dev", "config_ino", "config_size", "config_uid", "config_nlink"): + for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"): if not isinstance(raw[key], str) or not raw[key].isdigit(): raise ConfigError("Manifest runtime non valido") if raw["config_mode"] != "400": @@ -617,22 +619,100 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]: return raw + +def _open_runtime_component(parent: int, name: str) -> int: + before = os.stat(name, dir_fd=parent, follow_symlinks=False) + if stat.S_ISLNK(before.st_mode): + raise OSError("runtime config path contains a symlink") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW + fd = os.open(name, flags, dir_fd=parent) + after = os.fstat(fd) + if (before.st_dev != after.st_dev or before.st_ino != after.st_ino + or not stat.S_ISDIR(after.st_mode)): + os.close(fd) + raise OSError("runtime config path changed during open") + return fd + + +def _open_runtime_file(path: Path, expected_mode: int) -> int: + if not path.is_absolute(): + raise OSError("runtime config path must be absolute") + parts = list(path.parts) + if sys.platform == "darwin" and len(parts) > 1 and parts[1] in ("var", "tmp"): + parts = ["/", "private", *parts[1:]] + if not parts or parts[0] != "/" or any(part in ("", ".", "..") or "/" in part for part in parts[1:]): + raise OSError("runtime config path is invalid") + current = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + for component in parts[1:-1]: + nxt = _open_runtime_component(current, component) + os.close(current) + current = nxt + fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=current) + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != expected_mode or info.st_uid != os.getuid()): + os.close(fd) + raise OSError("unsafe runtime file") + return fd + finally: + os.close(current) + + +def _runtime_manifest_path(config_path: Path) -> Path: + if config_path.name == "" or config_path.suffix != ".yaml" or config_path.parent.name != "runtime-config": + raise OSError("runtime config path is not canonical") + if not re.fullmatch(r"[0-9a-f]{40}", config_path.stem): + raise OSError("runtime config path is not canonical") + return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json" + def load_config(path: Path) -> Config: - # Backend runtime leases pass the verified canonical config as fd 3 while retaining - # the ordinary absolute -c argument for diagnostics and source identity. Never reopen - # that pathname: an ancestor or leaf replacement after spawn must not alter bytes used - # by the harness. + # Registry leases authenticate the canonical pathname through a durable manifest + # digest. The config and manifest are opened component-by-component; FD 3/4 are + # reserved for the maintenance writer/root ABI. runtime_manifest: dict[str, object] | None = None + expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256") runtime_fd = os.environ.get("THT_CONFIG_FD") manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD") - expected_manifest = os.environ.get("THT_CONFIG_MANIFEST_SHA256") - if runtime_fd is not None or manifest_fd is not None or expected_manifest is not None: - if runtime_fd is None or manifest_fd is None or expected_manifest is None or not re.fullmatch(r"[0-9a-f]{64}", expected_manifest): + legacy_expected = os.environ.get("THT_CONFIG_MANIFEST_SHA256") + if expected_manifest is not None: + if not re.fullmatch(r"[0-9a-f]{64}", expected_manifest): + raise ConfigError("Handoff runtime incompleto") + config_fd = manifest_fd_local = None + try: + config_fd = _open_runtime_file(path, 0o400) + manifest_fd_local = _open_runtime_file(_runtime_manifest_path(path), 0o600) + config_bytes, config_info = _read_runtime_fd(config_fd, "config") + manifest_bytes, manifest_info = _read_runtime_fd(manifest_fd_local, "manifest", 0o600) + if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest: + raise ConfigError("Manifest runtime modificato") + runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8"))) + if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() + or int(runtime_manifest["config_dev"]) != config_info.st_dev + or int(runtime_manifest["config_ino"]) != config_info.st_ino + or int(runtime_manifest["config_size"]) != config_info.st_size + or int(runtime_manifest["config_uid"]) != config_info.st_uid + or int(runtime_manifest["config_nlink"]) != config_info.st_nlink + or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino): + raise ConfigError("Identità config runtime non valida") + source_text = config_bytes.decode("utf-8") + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc: + if isinstance(exc, ConfigError): + raise + raise ConfigError("File di configurazione runtime non attendibile") from exc + finally: + if config_fd is not None: + os.close(config_fd) + if manifest_fd_local is not None: + os.close(manifest_fd_local) + elif runtime_fd is not None or manifest_fd is not None or legacy_expected is not None: + # Compatibility for direct /dev/fd callers. New backend leases never use it. + if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected): raise ConfigError("Handoff runtime incompleto") try: config_bytes, config_info = _read_runtime_fd(int(runtime_fd), "config") manifest_bytes, manifest_info = _read_runtime_fd(int(manifest_fd), "manifest", 0o600) - if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest: + if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected: raise ConfigError("Manifest runtime modificato") runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8"))) if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() @@ -655,6 +735,7 @@ def load_config(path: Path) -> Config: source_text = path.read_text() except OSError as exc: raise ConfigError(f"File di configurazione non trovato: {path}") from exc + try: raw = yaml.safe_load(source_text) except yaml.YAMLError as exc: diff --git a/harness/tht/runtime_config_lease_io.py b/harness/tht/runtime_config_lease_io.py index 424622ea..97182ce4 100644 --- a/harness/tht/runtime_config_lease_io.py +++ b/harness/tht/runtime_config_lease_io.py @@ -26,22 +26,41 @@ def safe_rev(v: str) -> bool: def open_dir(parent: int | None, name: str, create: bool = False) -> int: - # Darwin rejects O_NOFOLLOW|openat for directories (ELOOP); lstat the - # component before opening and verify the resulting descriptor below. Linux - # uses the stronger flag where available. - flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) - if sys.platform != "darwin": - flags |= os.O_NOFOLLOW - try: - entry = os.stat(name, dir_fd=parent, follow_symlinks=False) - if stat.S_ISLNK(entry.st_mode): - fail("runtime config directory is not trusted") - return os.open(name, flags, dir_fd=parent) - except FileNotFoundError: - if not create: - raise - os.mkdir(name, 0o700, dir_fd=parent) - return os.open(name, flags, dir_fd=parent) + """Open one directory component without following a replaced entry. + + The pre-open lstat and post-open fstat identity check is required on Darwin, + where O_NOFOLLOW has historically been unavailable for directory openat. + mkdir races are resolved by opening and validating the winner. + """ + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW + while True: + try: + entry = os.stat(name, dir_fd=parent, follow_symlinks=False) + if stat.S_ISLNK(entry.st_mode): + fail("runtime config directory is not trusted") + fd = os.open(name, flags, dir_fd=parent) + try: + opened = os.fstat(fd) + if (opened.st_dev != entry.st_dev or opened.st_ino != entry.st_ino + or not stat.S_ISDIR(opened.st_mode)): + fail("runtime config directory changed during open") + return fd + except BaseException: + os.close(fd) + raise + except FileNotFoundError: + if not create: + raise + try: + os.mkdir(name, 0o700, dir_fd=parent) + except FileExistsError: + # Another publisher won creation. Re-enter the identity-checked + # open path instead of exposing EEXIST to the caller. + continue + if parent is not None: + os.fsync(parent) + # Re-open through the same no-follow and identity checks. + continue def checked_dir(fd: int, expected_mode: int = 0o700) -> None: @@ -141,8 +160,9 @@ def strict_manifest(value: object) -> dict: fail("runtime config manifest is invalid") required = { "version", "workspace_id", "workspace_revision", "descriptor_git_blob", - "descriptor_sha256", "config_sha256", "config_dwh_binding", "config_dev", - "config_ino", "config_size", "config_mode", "config_uid", "config_nlink", + "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", + "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", + "config_uid", "config_nlink", } if set(value) != required or value.get("version") != 1: fail("runtime config manifest is invalid") @@ -156,7 +176,7 @@ def strict_manifest(value: object) -> dict: binding_value = value.get("config_dwh_binding") if not isinstance(binding_value, dict) or set(binding_value) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(x, str) for x in binding_value.values()): fail("runtime config manifest is invalid") - for key in ("config_dev", "config_ino", "config_size", "config_uid", "config_nlink"): + for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"): if not isinstance(value[key], str) or not value[key].isdigit(): fail("runtime config manifest is invalid") if value["config_mode"] != "400": @@ -193,19 +213,10 @@ def publish(inp: dict) -> dict: checked_dir(cfgdir) mandir = open_dir(prep, "runtime-config-manifests", True) checked_dir(mandir) - lockfd = os.open( - "runtime-config.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=prep - ) + # The retained preprocessing directory is the single cross-process lock seam. + # No pathname lock file is created in the workspace layout. + fcntl.flock(prep, fcntl.LOCK_EX) try: - ls = os.fstat(lockfd) - if ( - not stat.S_ISREG(ls.st_mode) - or ls.st_nlink != 1 - or stat.S_IMODE(ls.st_mode) != 0o600 - or ls.st_uid != os.getuid() - ): - fail("runtime config lock is not trusted") - fcntl.flock(lockfd, fcntl.LOCK_EX) name = f"{rev}.yaml" mname = f"{rev}.json" @@ -302,9 +313,25 @@ def publish(inp: dict) -> dict: write_all(fd, mb) os.fchmod(fd, 0o600) os.fsync(fd) - os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False) - except FileExistsError: - pass + try: + os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False) + except FileExistsError: + # A no-replace loser is successful only after validating the + # durable winner byte-for-byte and against the strict schema. + winner = current(mandir, mname, 0o600) + if winner is None: + fail("runtime config manifest publication raced") + wfd, _ = winner + try: + existing = read_all(wfd) + finally: + os.close(wfd) + try: + strict_manifest(json.loads(existing.decode())) + except (ValueError, TypeError, UnicodeError, RuntimeError): + fail("runtime config manifest is invalid") + if existing != mb: + fail("same-revision runtime configuration changed") finally: os.close(fd) try: @@ -321,7 +348,6 @@ def publish(inp: dict) -> dict: "ino": s.st_ino, } finally: - os.close(lockfd) os.close(cfgdir) os.close(mandir) os.close(prep) @@ -346,7 +372,7 @@ def verified_snapshot(inp: dict) -> dict: checked_dir(rdir) fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir) try: - read_regular(fd, 0o400) + descriptor_info = read_regular(fd, 0o400) chunks = [] while True: x = os.read(fd, 1024 * 1024) @@ -375,16 +401,39 @@ def verified_snapshot(inp: dict) -> dict: fail("workspace snapshot integrity check failed") records = manifest.get("revisions") files = manifest.get("files") - record = next((r for r in records if isinstance(r, dict) and r.get("id") == wid), None) if isinstance(records, list) else None + if not isinstance(records, list) or not isinstance(files, dict) or not records: + fail("workspace snapshot integrity check failed") + record_by_id: dict[str, dict] = {} + for item in records: + if not isinstance(item, dict) or set(item) != {"id", "commit", "blob", "snapshotPath"}: + fail("workspace snapshot integrity check failed") + item_id = item.get("id") + if not isinstance(item_id, str) or not safe_id(item_id) or item_id in record_by_id: + fail("workspace snapshot integrity check failed") + if item.get("commit") != rev or item.get("snapshotPath") != f"{root}/{rev}/{item_id}.yaml": + fail("workspace snapshot integrity check failed") + if not isinstance(item.get("blob"), str) or not safe_rev(item["blob"]): + fail("workspace snapshot integrity check failed") + record_by_id[item_id] = item + expected_names = {name for item_id in record_by_id for name in (f"{item_id}.yaml", f"{item_id}.env.example", f"{item_id}.md")} + if set(files) != expected_names or any(not isinstance(v, str) or not __import__("re").fullmatch(r"[0-9a-f]{64}", v) for v in files.values()): + fail("workspace snapshot integrity check failed") + # Verify every immutable file declared by snapshot.json, not just the selected + # descriptor. This prevents extra records/files from smuggling a second state. + for filename in sorted(expected_names): + f = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir) + try: + read_regular(f, 0o400) + actual = hashlib.sha256(read_all(f)).hexdigest() + finally: + os.close(f) + if actual != files[filename]: + fail("workspace snapshot integrity check failed") + record = record_by_id.get(wid) expected_path = f"{root}/{rev}/{wid}.yaml" - if ( - manifest.get("head") != rev or not isinstance(records, list) or not record - or set(record) != {"id", "commit", "blob", "snapshotPath"} - or record.get("commit") != rev or record.get("snapshotPath") != expected_path - or not isinstance(record.get("blob"), str) or not safe_rev(record.get("blob")) - or not isinstance(files, dict) - or files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest() - ): + if record is None or manifest.get("head") != rev or record.get("snapshotPath") != expected_path: + fail("workspace snapshot integrity check failed") + if files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest(): fail("workspace snapshot integrity check failed") repo = inp.get("repository_root") if not isinstance(repo, str) or not os.path.isabs(repo): @@ -401,25 +450,20 @@ def verified_snapshot(inp: dict) -> dict: except (OSError, subprocess.SubprocessError): fail("workspace Git revision is unavailable") try: - import re - from collections import Counter - normalize = lambda value: re.findall(r"[A-Za-z0-9_.:/@+-]+", value) - git_tokens = Counter(normalize(git_source.decode("utf-8"))) - snapshot_tokens = Counter(normalize(source.decode("utf-8"))) - # The registry canonicalizer may add schema defaults/reorder mappings. - # Every token from the exact Git descriptor must nevertheless survive; - # replacements (including non-rendered workspace.name) are rejected. - equivalent = all(snapshot_tokens[k] >= count for k, count in git_tokens.items()) + git_text = git_source.decode("utf-8") except UnicodeDecodeError: - equivalent = False - if blob != record.get("blob") or not equivalent: + fail("workspace Git descriptor identity mismatch") + if blob != record.get("blob"): fail("workspace Git descriptor identity mismatch") return { "workspace_id": wid, "workspace_revision": rev, "source": source.decode(), + "git_source": git_text, "sha256": hashlib.sha256(source).hexdigest(), "descriptor_git_blob": record.get("blob"), + "descriptor_dev": descriptor_info.st_dev, + "descriptor_ino": descriptor_info.st_ino, "snapshot_path": f"{root}/{rev}/{wid}.yaml", }