diff --git a/.changeset/pod-guest-door-dir-host-agnostic.md b/.changeset/pod-guest-door-dir-host-agnostic.md new file mode 100644 index 00000000..d293576b --- /dev/null +++ b/.changeset/pod-guest-door-dir-host-agnostic.md @@ -0,0 +1,13 @@ +--- +"@bounded-systems/prx": patch +--- + +Mount the door fabric at a host-agnostic in-container path (`guestDoorDir`, +default `/run/prx/doors`) instead of the real host `doorDir`. Previously +every room mounted the fabric at the identical host path (e.g. +`/Users/bobby/.local/run/prx/doors`), letting a guest fingerprint the host's +OS, username, and home layout from its own mount table / env vars even +though the doors themselves are otherwise access-controlled. `doorDir` now +means the host-side source only; all in-container references (kube +`mountPath`, `Volume=`/`--volume` destinations, `podRoomEnv`'s projected +env, `--socket` args) resolve through `guestDoorDir`. diff --git a/packages/prx/src/room/per-repo-pod.ts b/packages/prx/src/room/per-repo-pod.ts index e547147c..494c7a79 100644 --- a/packages/prx/src/room/per-repo-pod.ts +++ b/packages/prx/src/room/per-repo-pod.ts @@ -22,7 +22,7 @@ import { claudeRoom } from "./claude-room.ts"; import { DOLT_BOX_IMAGE, DOLT_BOX_ENV, DOLT_DATA_VOLUME, DOLT_DATA_DIR } from "./dolt-service.ts"; import { forgeDRoom } from "./forge-d-room.ts"; import { keeperdRoom } from "./keeperd-room.ts"; -import { DEFAULT_DOOR_DIR } from "./pod.ts"; +import { DEFAULT_DOOR_DIR, DEFAULT_GUEST_DOOR_DIR } from "./pod.ts"; import type { PodSpec } from "./pod.ts"; import { podFor, type SlugResolver } from "./pod-identity.ts"; import type { PodService } from "./spec.ts"; @@ -49,6 +49,7 @@ export const perRepoPod: PodSpec = { rooms: [claudeRoom, beadsdRoom, keeperdRoom, forgeDRoom, beadsdBridgeRoom], services: [doltService], doorDir: DEFAULT_DOOR_DIR, + guestDoorDir: DEFAULT_GUEST_DOOR_DIR, }; /** diff --git a/packages/prx/src/room/pod.ts b/packages/prx/src/room/pod.ts index 39a7a2d8..74761303 100644 --- a/packages/prx/src/room/pod.ts +++ b/packages/prx/src/room/pod.ts @@ -36,6 +36,19 @@ export const DEFAULT_DOOR_DIR = getEnv("XDG_RUNTIME_DIR") ? `${getEnv("XDG_RUNTIME_DIR")}/prx/doors` : `${getEnv("HOME") ?? "/tmp"}/.local/run/prx/doors`; +/** + * The default IN-CONTAINER mount path for the door fabric — deliberately + * host-agnostic (no username, no home-dir layout), unlike {@link DEFAULT_DOOR_DIR} + * which is a real host path. A 2026-07-03 finding: mounting the fabric at the + * SAME path on both sides (`${doorDir}:${doorDir}`) let a guest fingerprint the + * host (macOS, username, home layout) purely from its own mountinfo/env, even + * though the doors themselves are unstatable/credential-free. Guest-side code + * should always resolve door sockets via {@link podRoomEnv}'s projected env + * vars, never by reconstructing a path from `doorDir` — this constant exists + * so a guest ps/mount listing reveals nothing about the host. + */ +export const DEFAULT_GUEST_DOOR_DIR = "/run/prx/doors"; + export const PodSpecSchema = z.object({ name: z.string().min(1), /** The shared house — the executor every member room runs in (per-repo). */ @@ -48,8 +61,17 @@ export const PodSpecSchema = z.object({ * with their named data volumes; they expose no doors. Empty by default. */ services: z.array(PodServiceSchema).default([]), - /** The shared tmpfs dir the door sockets live on. */ + /** The shared tmpfs dir the door sockets live on, on the HOST. */ doorDir: z.string().min(1).default(DEFAULT_DOOR_DIR), + /** + * The path the door fabric is mounted at INSIDE each room's container — + * deliberately distinct from {@link doorDir} so the guest can't infer the + * host's OS/username/home layout from its own mount table (2026-07-03 + * finding). Every in-container reference (kube `mountPath`, `Volume=` + * destinations, the env `podRoomEnv` projects, `--socket` args) uses this; + * only host-side provisioning/polling uses the real `doorDir`. + */ + guestDoorDir: z.string().min(1).default(DEFAULT_GUEST_DOOR_DIR), /** * Absolute host path of the repo this pod operates on, bind-mounted at `/work` * (the daemon images' `WorkingDir`) in every room (prx-u5lx). One pod = one @@ -178,7 +200,7 @@ export function podRoomEnv(pod: PodSpec, roomName: string): Record d.consumer === roomName) - .reduce>((env, d) => ({ ...env, ...doorEnv(d, p.doorDir) }), {}); + .reduce>((env, d) => ({ ...env, ...doorEnv(d, p.guestDoorDir) }), {}); } /** diff --git a/packages/prx/src/room/podman.ts b/packages/prx/src/room/podman.ts index 66685192..93a232f5 100644 --- a/packages/prx/src/room/podman.ts +++ b/packages/prx/src/room/podman.ts @@ -154,7 +154,7 @@ export function renderPodmanKube(pod: PodSpec): string { } lines.push(" volumeMounts:"); lines.push(` - name: ${DOOR_VOLUME}`); - lines.push(` mountPath: ${dq(p.doorDir)}`); + lines.push(` mountPath: ${dq(p.guestDoorDir)}`); if (p.repo) { lines.push(` - name: ${REPO_VOLUME}`); lines.push(` mountPath: ${dq(WORK_DIR)}`); @@ -184,7 +184,7 @@ export function renderPodmanKube(pod: PodSpec): string { // qualifies while claude-room's sealed `control` does not. const socketArgs = room.doors .filter((d) => d.direction === "expose" && d.state !== "closed") - .flatMap((d) => ["--socket", `${p.doorDir}/${d.socket.split("/").at(-1) ?? d.socket}`]); + .flatMap((d) => ["--socket", `${p.guestDoorDir}/${d.socket.split("/").at(-1) ?? d.socket}`]); // room.extraArgs (already honored by the secret/run path, renderPodmanRun) // is honored here too — e.g. a door-bridge room (prx-8uf2) with no exposed // door of its own, whose CMD is `sh -c 'prx door bridge --port

--socket @@ -318,12 +318,15 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] { } // The shared door fabric: the SAME host doorDir the kube pod hostPath-mounts, // so this standalone container's exposed door is reachable across runtimes. + // Mounted at guestDoorDir (host-agnostic), NOT the host doorDir itself — a + // same-path mount would let the container fingerprint the host's OS/user/ + // home layout from its own mount table (2026-07-03 finding). // `:z` = SHARED SELinux relabel (container_file_t, shared) — required on an // SELinux-enforcing host (e.g. a Fedora podman machine), else the keeper hits // EACCES creating its socket on the unlabeled (var_run_t) dir; SHARED (`:z`) // not private (`:Z`) because the dir is shared with the kube pod's containers. // A no-op on non-SELinux hosts. Live-validated on the host (prx-3urm). - args.push("--volume", `${p.doorDir}:${p.doorDir}:z`); + args.push("--volume", `${p.doorDir}:${p.guestDoorDir}:z`); // TCP port mapping for rooms that declare tcpPort — the macOS virtiofs // workaround (prx-zj8): virtiofs exposes the socket file but not the socket // semantics, so the Mac-host client can't connect via Unix; publishing a port @@ -356,7 +359,7 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] { for (const door of room.doors) { if (door.direction === "expose") { const socketFile = door.socket.split("/").at(-1) ?? door.socket; - args.push("--env", `${door.name.toUpperCase()}_SOCK=${p.doorDir}/${socketFile}`); + args.push("--env", `${door.name.toUpperCase()}_SOCK=${p.guestDoorDir}/${socketFile}`); } } // The room's -box image; full registry ref resolved at deploy (prx-zj8). @@ -368,7 +371,7 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] { for (const door of room.doors) { if (door.direction === "expose") { const socketFile = door.socket.split("/").at(-1) ?? door.socket; - args.push("--socket", `${p.doorDir}/${socketFile}`); + args.push("--socket", `${p.guestDoorDir}/${socketFile}`); } } // Room-specific CMD arg overrides (e.g. keeperd's --key ). @@ -445,13 +448,16 @@ export function renderPodmanQuadlet(pod: PodSpec, roomName: string): string { lines.push(`Secret=${secret.name},target=${secret.target}`); } // The shared door fabric — the SAME host doorDir the kube pod hostPath-mounts, - // so this unit's exposed door is reachable across runtimes. `:z` (shared - // SELinux relabel) for the same reason renderPodmanRun emits it (prx-3urm): - // on an SELinux-enforcing host — the common case for a production quadlet — - // the bare mount leaves the door dir `var_run_t` and the keeper hits EACCES - // creating its socket; `:z` relabels to `container_file_t`. Shared, not `:Z`, - // because the fabric is shared with the kube pod; a no-op on non-SELinux hosts. - lines.push(`Volume=${p.doorDir}:${p.doorDir}:z`); + // so this unit's exposed door is reachable across runtimes. Mounted at + // guestDoorDir (host-agnostic), NOT the host doorDir itself — see the + // matching note in renderPodmanRun (2026-07-03 fingerprinting finding). + // `:z` (shared SELinux relabel) for the same reason renderPodmanRun emits it + // (prx-3urm): on an SELinux-enforcing host — the common case for a + // production quadlet — the bare mount leaves the door dir `var_run_t` and + // the keeper hits EACCES creating its socket; `:z` relabels to + // `container_file_t`. Shared, not `:Z`, because the fabric is shared with + // the kube pod; a no-op on non-SELinux hosts. + lines.push(`Volume=${p.doorDir}:${p.guestDoorDir}:z`); // The repo bind-mount + WorkingDir (mirrors renderPodmanRun; prx-u5lx). `:z` // for the same SELinux reason — the repo is shared by every room. if (p.repo) { diff --git a/packages/prx/test/room/concierged-room.test.ts b/packages/prx/test/room/concierged-room.test.ts index 581eafdb..efeee813 100644 --- a/packages/prx/test/room/concierged-room.test.ts +++ b/packages/prx/test/room/concierged-room.test.ts @@ -50,7 +50,7 @@ describe("conciergedRoom renders as a secret room (podman run --secret)", () => const i = argv.indexOf("--socket"); expect(i).toBeGreaterThanOrEqual(0); expect(argv[i + 1]!.endsWith("/concierged.sock")).toBe(true); - expect(argv[i + 1]!.startsWith(pod.doorDir)).toBe(true); + expect(argv[i + 1]!.startsWith(pod.guestDoorDir)).toBe(true); }); test("runs the concierged-box image", () => { diff --git a/packages/prx/test/room/pod.test.ts b/packages/prx/test/room/pod.test.ts index 54a9f59c..29e0173f 100644 --- a/packages/prx/test/room/pod.test.ts +++ b/packages/prx/test/room/pod.test.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { perRepoPod } from "../../src/room/per-repo-pod.ts"; import { DEFAULT_DOOR_DIR, + DEFAULT_GUEST_DOOR_DIR, PodSpecSchema, effectiveExecutor, podRoomEnv, @@ -15,9 +16,10 @@ import { type PodSpec, } from "../../src/room/pod.ts"; -// Socket paths are rebased to doorDir at projection time. -const beadsSock = `${DEFAULT_DOOR_DIR}/beadsd.sock`; -const keeperSock = `${DEFAULT_DOOR_DIR}/keeperd.sock`; +// Socket paths are rebased to guestDoorDir (the in-container mount path, +// host-agnostic by design) at projection time — NOT the host doorDir. +const beadsSock = `${DEFAULT_GUEST_DOOR_DIR}/beadsd.sock`; +const keeperSock = `${DEFAULT_GUEST_DOOR_DIR}/keeperd.sock`; import { RoomSpecSchema } from "../../src/room/spec.ts"; // Author rooms as schema INPUT (tier/doors/grants default in, executor optional); @@ -168,7 +170,7 @@ describe("podRoomEnv — the keystone", () => { ], }; expect(podRoomEnv(pod([c, forgeDProvider]), "consumer")).toEqual({ - PRX_FORGE_DOOR: `${DEFAULT_DOOR_DIR}/forge-d.sock`, + PRX_FORGE_DOOR: `${DEFAULT_GUEST_DOOR_DIR}/forge-d.sock`, }); }); @@ -204,7 +206,7 @@ describe("perRepoPod", () => { }); test("fires the gate env into claude-room (beadsd + keeperd + forge-d doors)", () => { - const dir = perRepoPod.doorDir; + const dir = perRepoPod.guestDoorDir; expect(podRoomEnv(perRepoPod, "claude-room")).toEqual({ PRX_BEADS_DOOR: "beadsd", PRX_BEADS_SOCKET: `${dir}/beadsd.sock`, diff --git a/packages/prx/test/room/podman.test.ts b/packages/prx/test/room/podman.test.ts index a2fc1c8f..b0125409 100644 --- a/packages/prx/test/room/podman.test.ts +++ b/packages/prx/test/room/podman.test.ts @@ -42,8 +42,10 @@ describe("renderPodmanKube", () => { expect(manifest).toContain(`path: "${dir}"`); expect(manifest).toContain("type: DirectoryOrCreate"); expect(manifest).not.toContain("emptyDir:"); - // Every NON-SECRET room mounts it at the pod's doorDir. - const mountLine = `mountPath: "${dir}"`; + // Every NON-SECRET room mounts it at the pod's guestDoorDir — a + // host-agnostic path distinct from the host-side hostPath source above, + // so the guest can't fingerprint the host from its own mount table. + const mountLine = `mountPath: "${perRepoPod.guestDoorDir}"`; const mounts = manifest.split(mountLine).length - 1; expect(mounts).toBe(kubeRooms.length); }); @@ -74,7 +76,7 @@ describe("renderPodmanKube", () => { // it reaches the keeper on the shared fabric (the whole point of the split). expect(manifest).toContain("PRX_KEEPER_DOOR"); expect(manifest).toContain(`value: "keeperd"`); - expect(manifest).toContain(`value: "${perRepoPod.doorDir}/keeperd.sock"`); + expect(manifest).toContain(`value: "${perRepoPod.guestDoorDir}/keeperd.sock"`); }); test("falls back to a placeholder for a room with no image", () => { @@ -91,23 +93,24 @@ describe("renderPodmanKube", () => { // The beadsd consume↔expose pair resolves → claude-room gets the gate env. expect(manifest).toContain("PRX_BEADS_DOOR"); expect(manifest).toContain(`value: "beadsd"`); - expect(manifest).toContain(`value: "${perRepoPod.doorDir}/beadsd.sock"`); + expect(manifest).toContain(`value: "${perRepoPod.guestDoorDir}/beadsd.sock"`); }); test("overrides beadsd-room's --socket to the shared fabric path (prx-asr)", () => { // The beadsd-box image bakes `--socket /run/prx/doors/beadsd.sock`; the kube - // container must override it to the mounted doorDir so the socket lands on - // the fabric consumers read (else beadsd serves off-fabric, unreachable). + // container must override it to the mounted guestDoorDir so the socket + // lands on the fabric consumers read (else beadsd serves off-fabric, + // unreachable). expect(manifest).toContain("args:"); expect(manifest).toContain(`- "--socket"`); - expect(manifest).toContain(`- "${perRepoPod.doorDir}/beadsd.sock"`); + expect(manifest).toContain(`- "${perRepoPod.guestDoorDir}/beadsd.sock"`); }); test("does NOT add a --socket override for claude-room's sealed control door", () => { // claude-room exposes only `control` (state: closed) and its occupant is // `claude`, which would choke on a stray --socket. Assert no control socket // arg is emitted. - expect(manifest).not.toContain(`${perRepoPod.doorDir}/control.sock`); + expect(manifest).not.toContain(`${perRepoPod.guestDoorDir}/control.sock`); }); test("does not emit env for a room with no wired door (beadsd-room)", () => { @@ -197,7 +200,7 @@ describe("renderPodmanKube — backing services (prx-asr / dolt-box)", () => { const slice = lines.slice(start).join("\n"); expect(slice).not.toContain("--socket"); // The dolt container mounts ONLY its data volume, not the door fabric. - expect(slice).not.toContain(`mountPath: "${svcPod.doorDir}"`); + expect(slice).not.toContain(`mountPath: "${svcPod.guestDoorDir}"`); }); test("emits no persistentVolumeClaim when the pod has no services", () => { @@ -237,8 +240,8 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => { expect(i).toBeGreaterThanOrEqual(0); // `:z` (shared) so an SELinux-enforcing host lets the keeper write its socket // on the dir shared with the kube pod (prx-3urm); shared, not private `:Z`. - const dir = perRepoPod.doorDir; - expect(argv[i + 1]).toBe(`${dir}:${dir}:z`); + // Mounted at guestDoorDir (host-agnostic), not the host doorDir itself. + expect(argv[i + 1]).toBe(`${perRepoPod.doorDir}:${perRepoPod.guestDoorDir}:z`); }); test("image precedes CMD args; CMD args override entrypoint socket + key (prx-9yv3)", () => { @@ -250,7 +253,7 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => { const cmdArgs = argv.slice(imageIdx + 1); const socketIdx = cmdArgs.lastIndexOf("--socket"); expect(socketIdx).toBeGreaterThanOrEqual(0); - expect(cmdArgs[socketIdx + 1]).toBe(`${perRepoPod.doorDir}/keeperd.sock`); + expect(cmdArgs[socketIdx + 1]).toBe(`${perRepoPod.guestDoorDir}/keeperd.sock`); const keyIdx = cmdArgs.lastIndexOf("--key"); expect(keyIdx).toBeGreaterThanOrEqual(0); expect(cmdArgs[keyIdx + 1]).toBe("/run/secrets/keeper-key"); @@ -302,7 +305,7 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => { // onto the shared fabric (not the in-box default /run/keeperd.sock). expect(envPairs.some((e) => e.startsWith("KEEPERD_SOCK="))).toBe(true); const keeperSockEnv = envPairs.find((e) => e.startsWith("KEEPERD_SOCK=")); - expect(keeperSockEnv).toBe(`KEEPERD_SOCK=${perRepoPod.doorDir}/keeperd.sock`); + expect(keeperSockEnv).toBe(`KEEPERD_SOCK=${perRepoPod.guestDoorDir}/keeperd.sock`); }); test("throws for a non-member room", () => { @@ -341,8 +344,8 @@ describe("renderPodmanQuadlet (prx-b44y — production systemd form)", () => { test("mounts the shared door fabric with a shared :z relabel (prx-3urm)", () => { // `:z` so an SELinux-enforcing host (the common production case) lets the // keeper write its socket on the shared door dir; shared, not private `:Z`. - const dir = perRepoPod.doorDir; - expect(lines).toContain(`Volume=${dir}:${dir}:z`); + // Mounted at guestDoorDir (host-agnostic), not the host doorDir itself. + expect(lines).toContain(`Volume=${perRepoPod.doorDir}:${perRepoPod.guestDoorDir}:z`); }); test("borrows claude-box's hardening floor; keeps egress for the push", () => {