From 4c9c849fcdf060108c92ff0af8cc493649958dc0 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 11 Aug 2026 11:30:02 +0200 Subject: [PATCH] fix: harden runtime config lease identity publication --- .../workspace-runtime-config-lease.test.ts | 118 +++++++++++++- harness/tht/config.py | 84 +++++++++- harness/tht/runtime_config_lease_io.py | 145 ++++++++++++++++-- 3 files changed, 329 insertions(+), 18 deletions(-) diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index 34530ae7..c56d10b6 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -32,7 +32,7 @@ llm_policy: allowed: [zai/glm-5.2] `; -function fixture() { +function fixture(extraEnv: Record = {}) { const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-")); const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor)); const snapshots = join(root, "snapshots"); @@ -73,13 +73,13 @@ function fixture() { env: { THT_WS_ABC_DWH_TRANSPORT: "postgres_direct", THT_WS_ABC_DWH_HOST: "dwh", THT_WS_ABC_DWH_PORT: "5432", THT_WS_ABC_DWH_USER: "reader", - THT_WS_ABC_DWH_PASSWORD_FILE: secret, + THT_WS_ABC_DWH_PASSWORD_FILE: secret, ...extraEnv, }, secretRoots: [root], semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, }, }); - return { root, snapshotPath, factory, canonicalDescriptor }; + return { root, repo, snapshotPath, factory, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") }; } test("session and maintenance share deterministic bytes and path", () => { @@ -141,3 +141,113 @@ test("same-byte replacement of the registry descriptor is refused", () => { first.release(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); + + +test("manifest binds the complete canonical destination directory chain", () => { + const f = fixture(); + try { + const lease = f.factory.acquireSession(f.snapshotPath); + const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8")); + expect(manifest.directory_identities.length).toBeGreaterThan(5); + expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain( + `${process.platform === "darwin" ? "/private" : ""}${join(f.root, "data", "sessions", workspace, "preprocessing")}`, + ); + expect(manifest.directory_identities.every((entry: Record) => + ["path", "dev", "ino", "mode", "uid"].every((key) => typeof entry[key] === "string"), + )).toBe(true); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("raw Git identity ignores replacement refs", () => { + const f = fixture(); + try { + const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil"); + chmodSync(join(f.repo, "workspaces", `${workspace}.yaml`), 0o600); + writeFileSync(join(f.repo, "workspaces", `${workspace}.yaml`), evil); + execFileSync("git", ["add", "."], { cwd: f.repo }); + execFileSync("git", ["commit", "-m", "evil"], { cwd: f.repo }); + const evilCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.repo, encoding: "utf8" }).trim(); + const oldCommit = JSON.parse(readFileSync(f.snapshotManifest, "utf8")).head; + execFileSync("git", ["replace", oldCommit, evilCommit], { cwd: f.repo }); + chmodSync(f.snapshotPath, 0o600); + writeFileSync(f.snapshotPath, evil); + chmodSync(f.snapshotPath, 0o400); + const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8")); + snapshot.files[`${workspace}.yaml`] = createHash("sha256").update(evil).digest("hex"); + chmodSync(f.snapshotManifest, 0o600); + writeFileSync(f.snapshotManifest, JSON.stringify(snapshot)); + chmodSync(f.snapshotManifest, 0o400); + expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("replacement of canonical destination directories is refused", () => { + const f = fixture(); + try { + const lease = f.factory.acquireSession(f.snapshotPath); + const original = join(f.root, "data", "sessions", workspace); + const moved = `${original}.moved`; + renameSync(original, moved); + mkdirSync(join(original, "preprocessing", "runtime-config"), { recursive: true, mode: 0o700 }); + mkdirSync(join(original, "preprocessing", "runtime-config-manifests"), { recursive: true, mode: 0o700 }); + renameSync(join(moved, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`), join(original, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`)); + renameSync(join(moved, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`), join(original, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`)); + expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|same-revision|identity|trusted/i); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("rename faults fail closed and remove staging files", () => { + const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" }); + try { + expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i); + const runtime = join(f.root, "data", "sessions", workspace, "preprocessing"); + for (const dir of ["runtime-config", "runtime-config-manifests"]) { + if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0); + } + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + + +test("session and operator outputs retain normalized private-host policy and binding", () => { + const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" }); + try { + const session = f.factory.acquireSession(f.snapshotPath); + const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath }); + const output = readFileSync(session.path, "utf8"); + expect(output).toContain("http_private_host_allowlist"); + expect(output).toContain("- internal.example"); + expect(output).toContain("- warehouse.example"); + expect(output).toBe(readFileSync(maintenance.path, "utf8")); + const manifest = JSON.parse(readFileSync(session.manifestPath, "utf8")); + expect(manifest.config_dwh_binding).toEqual({ + workspace_id: expect.any(String), config_fingerprint: expect.any(String), input_fingerprint: expect.any(String), + }); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + + +test("unexpected manifest fields are refused before handoff", () => { + const f = fixture(); + try { + const lease = f.factory.acquireSession(f.snapshotPath); + const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8")); + manifest.unexpected = true; + chmodSync(lease.manifestPath, 0o600); + writeFileSync(lease.manifestPath, JSON.stringify(manifest)); + expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test("runtime config symlink replacement is refused", () => { + const f = fixture(); + try { + const lease = f.factory.acquireSession(f.snapshotPath); + const replacement = `${lease.path}.real`; + writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 }); + chmodSync(lease.path, 0o600); + rmSync(lease.path); + // A no-follow handoff must never consume this pathname. + execFileSync("ln", ["-s", replacement, lease.path]); + expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|changed|configuration|symbolic/i); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); diff --git a/harness/tht/config.py b/harness/tht/config.py index 256a379d..eec41d22 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -599,10 +599,14 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]: "version", "workspace_id", "workspace_revision", "descriptor_git_blob", "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", - "config_uid", "config_nlink", + "config_uid", "config_nlink", "directory_identities", } if not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1: raise ConfigError("Manifest runtime non valido") + if (not isinstance(raw.get("workspace_id"), str) or not re.fullmatch(r"[a-z][a-z0-9-]{2,62}", raw["workspace_id"]) + or not isinstance(raw.get("workspace_revision"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["workspace_revision"]) + or not isinstance(raw.get("descriptor_git_blob"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["descriptor_git_blob"])): + raise ConfigError("Manifest runtime non valido") if not isinstance(raw.get("config_dwh_binding"), dict): raise ConfigError("Manifest runtime non valido") binding = raw["config_dwh_binding"] @@ -614,12 +618,88 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]: 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") + identities = raw.get("directory_identities") + if (not isinstance(identities, list) or not identities or + any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"} + or not isinstance(item["path"], str) or not item["path"].startswith("/") + or any(not isinstance(item[key], str) or not item[key].isdigit() + for key in ("dev", "ino", "mode", "uid")) + or item["mode"] == "0" + for item in identities)): + raise ConfigError("Manifest runtime non valido") + if len({item["path"] for item in identities}) != len(identities): + raise ConfigError("Manifest runtime non valido") if raw["config_mode"] != "400": raise ConfigError("Manifest runtime non valido") return raw +def _canonical_runtime_path(path: Path) -> Path: + value = str(path) + if value == "/tmp" or value.startswith("/tmp/"): + return Path("/private" + value) + if value == "/var" or value.startswith("/var/"): + return Path("/private" + value) + return path + + +def _runtime_directory_identities(paths: list[Path]) -> list[dict[str, str]]: + """Open/stat every directory component and return its current identity chain.""" + out: list[dict[str, str]] = [] + seen: set[str] = set() + for path in paths: + canonical = _canonical_runtime_path(path) + parts = list(canonical.parts) + if not parts or parts[0] != "/": + raise OSError("runtime config path is invalid") + current = "/" + components = ["/"] + parts[1:] + for component in components: + if component != "/": + current = current.rstrip("/") + "/" + component + # Re-open shared prefixes for each destination branch too: a + # replacement between config-parent and manifest-parent traversal + # must not be hidden by de-duplication. + # lstat before and fstat after open closes the stat/open replacement + # window for each component, including canonical destination parents. + parent = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + for name in [part for part in Path(current).parts[1:-1]]: + nxt = _open_runtime_component(parent, name) + os.close(parent); parent = nxt + if current == "/": + fd = os.dup(parent) + else: + name = Path(current).name + fd = _open_runtime_component(parent, name) + try: + info = os.fstat(fd) + if not stat.S_ISDIR(info.st_mode) or info.st_nlink < 1: + raise OSError("unsafe runtime directory") + identity = {"path": current, "dev": str(info.st_dev), "ino": str(info.st_ino), + "mode": format(stat.S_IMODE(info.st_mode), "o"), + "uid": str(info.st_uid)} + if current in seen: + previous = next(item for item in out if item["path"] == current) + if identity != previous: + raise OSError("runtime config directory changed") + else: + out.append(identity) + seen.add(current) + finally: + os.close(fd) + finally: + os.close(parent) + return out + + +def _verify_runtime_directory_identities(config_path: Path, manifest_path: Path, expected: object) -> None: + current = _runtime_directory_identities([config_path.parent, manifest_path.parent]) + if current != expected: + raise ConfigError("Destinazione config runtime modificata") + + 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): @@ -687,6 +767,7 @@ def load_config(path: Path) -> Config: 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"))) + _verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"]) 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 @@ -715,6 +796,7 @@ def load_config(path: Path) -> Config: 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"))) + _verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"]) 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 diff --git a/harness/tht/runtime_config_lease_io.py b/harness/tht/runtime_config_lease_io.py index 97182ce4..9ef4076e 100644 --- a/harness/tht/runtime_config_lease_io.py +++ b/harness/tht/runtime_config_lease_io.py @@ -2,10 +2,12 @@ from __future__ import annotations +import ctypes import fcntl import hashlib import json import os +import platform import stat import subprocess import sys @@ -17,6 +19,99 @@ def fail(msg: str) -> None: raise RuntimeError(msg) +def _canonical_root(root: str) -> str: + # Darwin exposes /tmp and /var as symlink aliases. The trusted path boundary + # records the real OS-owned prefix so a manifest never contains a symlink. + if root == "/tmp" or root.startswith("/tmp/"): + return "/private" + root + if root == "/var" or root.startswith("/var/"): + return "/private" + root + return root + + +def _identity(st: os.stat_result, path: str) -> dict[str, str]: + return {"path": path, "dev": str(st.st_dev), "ino": str(st.st_ino), + "mode": format(stat.S_IMODE(st.st_mode), "o"), "uid": str(st.st_uid)} + + +def _rename_noreplace(src: str, dst: str, directory_fd: int, kind: str) -> None: + """Atomically rename *src* to *dst* without replacing an existing entry. + + ``link`` is deliberately not used here: the state-file protocol requires a + rename, and a hard-link publication leaves a second name visible during a + crash. Unsupported platforms fail closed rather than silently weakening the + protocol. The env seam is intentionally narrow so fault tests can exercise + every publication rename without monkey-patching the privileged process. + """ + if os.environ.get("THT_RUNTIME_CONFIG_RENAME_FAIL") in {"1", kind}: + fail("runtime config rename failed") + libc = ctypes.CDLL(None, use_errno=True) + src_b = os.fsencode(src) + dst_b = os.fsencode(dst) + if sys.platform == "darwin": + fn = getattr(libc, "renameatx_np", None) + if fn is None: + fail("runtime config no-replace rename is unavailable") + fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + fn.restype = ctypes.c_int + # RENAME_EXCL is the Darwin no-overwrite operation. + rc = fn(directory_fd, src_b, directory_fd, dst_b, 0x00000004) + elif sys.platform.startswith("linux"): + # renameat2(2), RENAME_NOREPLACE. syscall numbers are stable for the + # supported Linux architectures; an unavailable syscall fails closed. + number = {"x86_64": 316, "aarch64": 276, "arm64": 276}.get(platform.machine()) + if number is None or not hasattr(libc, "syscall"): + fail("runtime config no-replace rename is unavailable") + libc.syscall.argtypes = [ctypes.c_long, ctypes.c_int, ctypes.c_char_p, + ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + libc.syscall.restype = ctypes.c_long + rc = libc.syscall(number, directory_fd, src_b, directory_fd, dst_b, 1) + else: + fail("runtime config no-replace rename is unavailable") + if rc != 0: + err = ctypes.get_errno() + if err == 17: + raise FileExistsError(err, os.strerror(err), dst) + raise OSError(err, os.strerror(err), dst) + + +def directory_identities(root: str, paths: list[tuple[str, int]]) -> list[dict[str, str]]: + """Return the ordered, no-follow identity chain bound by a publication. + + ``paths`` contains canonical absolute directory paths paired with already-open + descriptors. Prefix components are stat'ed without following symlinks; the + terminal workspace-owned components are additionally represented by fstat on + the descriptors opened by ``walk``. + """ + canonical = _canonical_root(root) + root_parts = [part for part in Path(canonical).parts if part not in ("", "/")] + entries: list[dict[str, str]] = [] + current = "/" + st = os.stat("/", follow_symlinks=False) + entries.append(_identity(st, current)) + for part in root_parts: + current = (current.rstrip("/") + "/" + part) if current != "/" else "/" + part + st = os.stat(current, follow_symlinks=False) + if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode): + fail("runtime config directory is not trusted") + entries.append(_identity(st, current)) + for path, fd in paths: + cpath = _canonical_root(path) + st = os.fstat(fd) + if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) != 0o700 or st.st_uid != os.getuid(): + fail("runtime config directory is not trusted") + # Keep one ordered entry per path. Existing prefixes are left in place. + if not any(item["path"] == cpath for item in entries): + entries.append(_identity(st, cpath)) + else: + for item in entries: + if item["path"] == cpath: + if item["dev"] != str(st.st_dev) or item["ino"] != str(st.st_ino): + fail("runtime config directory changed") + break + return entries + + def safe_id(v: str) -> bool: return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v)) @@ -162,7 +257,7 @@ def strict_manifest(value: object) -> dict: "version", "workspace_id", "workspace_revision", "descriptor_git_blob", "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", - "config_uid", "config_nlink", + "config_uid", "config_nlink", "directory_identities", } if set(value) != required or value.get("version") != 1: fail("runtime config manifest is invalid") @@ -179,6 +274,17 @@ def strict_manifest(value: object) -> dict: 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") + identities = value.get("directory_identities") + if (not isinstance(identities, list) or not identities or + any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"} + or not isinstance(item["path"], str) or not os.path.isabs(item["path"]) + or any(not isinstance(item[key], str) or not item[key].isdigit() + for key in ("dev", "ino", "mode", "uid")) + or item["mode"] == "0" + for item in identities)): + fail("runtime config manifest is invalid") + if len({item["path"] for item in identities}) != len(identities): + fail("runtime config manifest is invalid") if value["config_mode"] != "400": fail("runtime config manifest is invalid") return value @@ -213,6 +319,14 @@ def publish(inp: dict) -> dict: checked_dir(cfgdir) mandir = open_dir(prep, "runtime-config-manifests", True) checked_dir(mandir) + canonical = _canonical_root(root) + directory_manifest = directory_identities(root, [ + (f"{canonical}/sessions", sessions), + (f"{canonical}/sessions/{wid}", ws), + (f"{canonical}/sessions/{wid}/preprocessing", prep), + (f"{canonical}/sessions/{wid}/preprocessing/runtime-config", cfgdir), + (f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests", mandir), + ]) # 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) @@ -249,15 +363,11 @@ def publish(inp: dict) -> dict: os.fchmod(fd, 0o400) os.fsync(fd) try: - os.link( - stage, name, src_dir_fd=cfgdir, dst_dir_fd=cfgdir, follow_symlinks=False - ) + _rename_noreplace(stage, name, cfgdir, "config") except FileExistsError: + # A concurrent equal publisher may already have won. It is + # accepted only after reopening and comparing its bytes below. pass - # Keep metadata ordering explicit even on filesystems where a - # hardlink publication does not retain fchmod as expected. - os.fchmod(fd, 0o400) - os.fsync(fd) finally: os.close(fd) try: @@ -290,6 +400,7 @@ def publish(inp: dict) -> dict: "config_mode": format(stat.S_IMODE(s.st_mode), "o"), "config_uid": str(s.st_uid), "config_nlink": str(s.st_nlink), + "directory_identities": directory_manifest, } ) strict_manifest(manifest) @@ -314,7 +425,7 @@ def publish(inp: dict) -> dict: os.fchmod(fd, 0o600) os.fsync(fd) try: - os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False) + _rename_noreplace(stage, mname, mandir, "manifest") except FileExistsError: # A no-replace loser is successful only after validating the # durable winner byte-for-byte and against the strict schema. @@ -438,14 +549,22 @@ def verified_snapshot(inp: dict) -> dict: repo = inp.get("repository_root") if not isinstance(repo, str) or not os.path.isabs(repo): fail("invalid repository root") + # Git replacement refs and ambient repository/config variables are attacker + # controlled process state. Snapshot identity must be the raw object named by + # the commit, with fixed Git configuration and repository boundaries. + # Keep no inherited GIT_* controls at all (including GIT_CONFIG_PARAMETERS, + # alternates, and repository path overrides), then add only fixed semantics. + git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull}) try: blob = subprocess.check_output( - ["git", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"], - stderr=subprocess.DEVNULL, text=True, timeout=5, + ["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"], + stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env, ).strip() git_source = subprocess.check_output( - ["git", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"], - stderr=subprocess.DEVNULL, timeout=5, + ["git", "--no-replace-objects", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"], + stderr=subprocess.DEVNULL, timeout=5, env=git_env, ) except (OSError, subprocess.SubprocessError): fail("workspace Git revision is unavailable")