From a5d524134e9d1ecb8c0f8a0b67fcbc11d651c92b Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Wed, 2 Sep 2026 11:02:39 +0200 Subject: [PATCH 01/10] Fix Windows VM helper rejecting setgid source directories GNU chmod leaves setuid/setgid on directories for numeric modes of four digits or fewer, so chmod 0700 cannot satisfy the exact-700 mount check when ~/Windows was created with g+s. Harden with a-s,u=rwx,go= and print the observed modes when the check still fails. --- bin/omarchy-windows-vm | 15 ++++++++++--- .../shell.d/windows-vm-mount-boundary-test.sh | 12 +++++++++++ test/shell.d/windows-vm-test.sh | 21 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f672ed7eee1..4b9420287ab 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -554,6 +554,14 @@ bind_mount_leaf() { } } +# Make a directory mode 700, including leftover setuid/setgid. GNU chmod keeps +# those bits on directories for numeric modes of four digits or fewer, so +# `chmod 0700` cannot satisfy the exact-700 checks when ~/Windows was created +# setgid (omacom/omarchy#9698). +chmod_private_dir() { + chmod a-s,u=rwx,go= -- "$@" +} + prepare_caller_mounts() { local storage_fd storage_id shared_fd shared_id storage_mode shared_mode CALLER_MOUNTS_NEW_STORAGE=0 @@ -580,7 +588,7 @@ prepare_caller_mounts() { # Privacy is an explicit preflight step for both already-pinned sources, not # a side effect halfway through the two-mount transaction. Old umask-022 # installs are hardened together before either Docker-facing anchor changes. - chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { + chmod_private_dir "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { exec {storage_fd}<&- exec {shared_fd}<&- return 1 @@ -588,6 +596,7 @@ prepare_caller_mounts() { storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then + echo "omarchy-windows-vm: VM source directories must be mode 700 (storage=$storage_mode shared=$shared_mode)" >&2 exec {storage_fd}<&- exec {shared_fd}<&- return 1 @@ -1015,7 +1024,7 @@ prepare_user_mount_sources() { echo "omarchy-windows-vm: storage and shared must be different directories" >&2 return 1 } - chmod 0700 -- "$storage" "$shared" + chmod_private_dir "$storage" "$shared" || return 1 } storage_space_path() { @@ -1058,7 +1067,7 @@ write_credentials() { local username="$1" password="$2" old_umask dir tmp dir=$(dirname -- "$CREDENTIALS_FILE") mkdir -p "$dir" || return 1 - chmod 0700 "$dir" || return 1 + chmod_private_dir "$dir" || return 1 old_umask=$(umask) umask 077 tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 13623d81645..74075422649 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -123,6 +123,18 @@ if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared fi pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" +# GNU chmod 0700 leaves directory setgid; leftover g+s used to fail the +# exact-700 check and block every privileged action (omacom/omarchy#9698). +chmod 2700 /home/storage-target +chmod 2777 /home/shared-target +chown 1000:1000 /home/storage-target /home/shared-target +with_vm_lock prepare_caller_mounts || fail "root could not harden setgid VM source directories" +[[ $(command stat -Lc '%a' /home/storage-target) == 700 ]] || fail "storage still had special bits after hardening" +[[ $(command stat -Lc '%a' /home/shared-target) == 700 ]] || fail "shared still had special bits after hardening" +[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 && + $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "setgid hardening did not leave caller-owned 0700 anchors" +pass "setgid VM source directories harden to exactly 700" + # Existing production boundary components are never repaired in place when # their ownership or write permissions are unsafe. Both the preparation path # and the final pre-Docker guard must fail closed without disturbing the binds. diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index e2a04daede1..d1ce41047f3 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -27,3 +27,24 @@ rg -q 'tag = "-default-opacity"' "$windows_vm_rules" || rg -q 'opacity = "1 1"' "$windows_vm_rules" || fail "Windows VM stays fully opaque" pass "Windows VM stays fully opaque" + +# User-side source hardening must clear leftover directory setgid. GNU chmod +# 0700 does not, so a pre-existing ~/Windows mode 2700/2777 used to survive +# prepare_user_mount_sources and then fail the privileged exact-700 check. +( + test_home=$(mktemp -d) + trap 'rm -rf "$test_home"' EXIT + mkdir -p "$test_home/.windows" "$test_home/Windows" "$test_home/.config/windows" + chmod 2700 "$test_home/.windows" + chmod 2777 "$test_home/Windows" + chmod 2755 "$test_home/.config/windows" + HOME=$test_home + set -- help + source "$windows_vm_command" >/dev/null + prepare_user_mount_sources || fail "user mount source hardening failed on setgid directories" + [[ $(stat -Lc '%a' "$HOME/.windows") == 700 ]] || fail "storage mode is $(stat -Lc '%a' "$HOME/.windows"), expected 700" + [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "shared mode is $(stat -Lc '%a' "$HOME/Windows"), expected 700" + write_credentials alice secret || fail "write_credentials failed on a setgid config dir" + [[ $(stat -Lc '%a' "$HOME/.config/windows") == 700 ]] || fail "credentials dir mode is $(stat -Lc '%a' "$HOME/.config/windows"), expected 700" +) +pass "user mount sources with leftover setgid harden to exactly 700" From e4ec3bed0c8b527ee04c5c9e70d83bb3812fc496 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Wed, 2 Sep 2026 11:28:29 +0200 Subject: [PATCH 02/10] Stop dockur Samba from chmod 2777 on ~/Windows samba.sh treats an empty /shared bind as uninitialized and chmod 2777s it at container start, undoing the host 700 privacy check after every launch. Keep a hidden sentinel in the share and re-harden the directory after docker compose up. --- bin/omarchy-windows-vm | 31 ++++++++++++++++++++++++++++++- test/shell.d/windows-vm-test.sh | 1 + 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 4b9420287ab..005821b198e 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -562,6 +562,24 @@ chmod_private_dir() { chmod a-s,u=rwx,go= -- "$@" } +# dockur samba.sh does `chmod 2777` on an empty /shared bind at container start. +# A hidden sentinel keeps the share non-empty so that path is skipped, and the +# host privacy mode 700 is restored after `docker compose up`. +SHARED_SENTINEL=.omarchy-keep + +ensure_shared_sentinel() { + local shared="$1" sentinel + [[ -d $shared ]] || return 0 + sentinel="$shared/$SHARED_SENTINEL" + [[ -e $sentinel ]] || : >"$sentinel" || return 1 +} + +restore_shared_privacy() { + local shared="${1:-$EXPECTED_SHARED}" + [[ -n $shared && -d $shared ]] || return 0 + chmod_private_dir "$shared" || return 1 +} + prepare_caller_mounts() { local storage_fd storage_id shared_fd shared_id storage_mode shared_mode CALLER_MOUNTS_NEW_STORAGE=0 @@ -601,6 +619,11 @@ prepare_caller_mounts() { exec {shared_fd}<&- return 1 fi + ensure_shared_sentinel "/proc/$BASHPID/fd/$shared_fd" || { + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + } if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW @@ -902,7 +925,11 @@ assert_mounts_safe() { } } -__priv_up() { assert_mounts_safe && dc up -d; } +__priv_up() { + assert_mounts_safe || return 1 + dc up -d || return 1 + restore_shared_privacy +} __priv_down() { dc down; } @@ -915,6 +942,7 @@ __priv_up_wait() { if [[ $status != "running" ]]; then dc up -d || return 1 fi + restore_shared_privacy || return 1 # docker logs persists across restarts, so anchor the scan to the current # start time; an empty --since would match a stale "started successfully". @@ -1025,6 +1053,7 @@ prepare_user_mount_sources() { return 1 } chmod_private_dir "$storage" "$shared" || return 1 + ensure_shared_sentinel "$shared" || return 1 } storage_space_path() { diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index d1ce41047f3..8582fee80be 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -44,6 +44,7 @@ pass "Windows VM stays fully opaque" prepare_user_mount_sources || fail "user mount source hardening failed on setgid directories" [[ $(stat -Lc '%a' "$HOME/.windows") == 700 ]] || fail "storage mode is $(stat -Lc '%a' "$HOME/.windows"), expected 700" [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "shared mode is $(stat -Lc '%a' "$HOME/Windows"), expected 700" + [[ -f $HOME/Windows/.omarchy-keep ]] || fail "shared sentinel was not created" write_credentials alice secret || fail "write_credentials failed on a setgid config dir" [[ $(stat -Lc '%a' "$HOME/.config/windows") == 700 ]] || fail "credentials dir mode is $(stat -Lc '%a' "$HOME/.config/windows"), expected 700" ) From e3d31903127018b867f0266501d769ee50930742 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Wed, 2 Sep 2026 22:22:52 +0200 Subject: [PATCH 03/10] Drop privileged share sentinel after review Creating ~/.omarchy-keep as root in a caller-owned directory is a symlink-follow write primitive. Restore mode 700 on the pinned directory inodes after the guest reports ready, and never fail a successful start on that chmod. --- bin/omarchy-windows-vm | 40 +++++++++++---------------------- test/shell.d/windows-vm-test.sh | 6 ++++- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 005821b198e..ad06deb942a 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -562,22 +562,16 @@ chmod_private_dir() { chmod a-s,u=rwx,go= -- "$@" } -# dockur samba.sh does `chmod 2777` on an empty /shared bind at container start. -# A hidden sentinel keeps the share non-empty so that path is skipped, and the -# host privacy mode 700 is restored after `docker compose up`. -SHARED_SENTINEL=.omarchy-keep - -ensure_shared_sentinel() { - local shared="$1" sentinel - [[ -d $shared ]] || return 0 - sentinel="$shared/$SHARED_SENTINEL" - [[ -e $sentinel ]] || : >"$sentinel" || return 1 -} - +# dockur samba.sh chmod 2777s an empty /shared at container start. Re-harden +# the already-pinned directory inodes only — never create files in the +# caller-owned share as root (that is a symlink-follow write primitive). +# Best-effort: a failed chmod must not fail a VM that already started. restore_shared_privacy() { - local shared="${1:-$EXPECTED_SHARED}" - [[ -n $shared && -d $shared ]] || return 0 - chmod_private_dir "$shared" || return 1 + local dir + for dir in "$EXPECTED_SHARED" "$LEGACY_SHARED"; do + [[ -n $dir && -d $dir && ! -L $dir ]] || continue + chmod_private_dir "$dir" || true + done } prepare_caller_mounts() { @@ -619,11 +613,6 @@ prepare_caller_mounts() { exec {shared_fd}<&- return 1 fi - ensure_shared_sentinel "/proc/$BASHPID/fd/$shared_fd" || { - exec {storage_fd}<&- - exec {shared_fd}<&- - return 1 - } if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW @@ -925,11 +914,7 @@ assert_mounts_safe() { } } -__priv_up() { - assert_mounts_safe || return 1 - dc up -d || return 1 - restore_shared_privacy -} +__priv_up() { assert_mounts_safe && dc up -d; } __priv_down() { dc down; } @@ -942,7 +927,6 @@ __priv_up_wait() { if [[ $status != "running" ]]; then dc up -d || return 1 fi - restore_shared_privacy || return 1 # docker logs persists across restarts, so anchor the scan to the current # start time; an empty --since would match a stale "started successfully". @@ -950,6 +934,9 @@ __priv_up_wait() { while true; do started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then + # samba.sh has already run by the time the guest reports ready, so this + # chmod lands after the 2777 rather than racing `dc up -d`. + restore_shared_privacy return 0 fi sleep 2 @@ -1053,7 +1040,6 @@ prepare_user_mount_sources() { return 1 } chmod_private_dir "$storage" "$shared" || return 1 - ensure_shared_sentinel "$shared" || return 1 } storage_space_path() { diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 8582fee80be..281a9547dd8 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -44,8 +44,12 @@ pass "Windows VM stays fully opaque" prepare_user_mount_sources || fail "user mount source hardening failed on setgid directories" [[ $(stat -Lc '%a' "$HOME/.windows") == 700 ]] || fail "storage mode is $(stat -Lc '%a' "$HOME/.windows"), expected 700" [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "shared mode is $(stat -Lc '%a' "$HOME/Windows"), expected 700" - [[ -f $HOME/Windows/.omarchy-keep ]] || fail "shared sentinel was not created" write_credentials alice secret || fail "write_credentials failed on a setgid config dir" [[ $(stat -Lc '%a' "$HOME/.config/windows") == 700 ]] || fail "credentials dir mode is $(stat -Lc '%a' "$HOME/.config/windows"), expected 700" + chmod 2777 "$HOME/Windows" + EXPECTED_SHARED=$HOME/Windows LEGACY_SHARED=$HOME/Windows restore_shared_privacy + [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows")" + mkdir -p "$HOME/missing-parent" + EXPECTED_SHARED=$HOME/missing-parent/nope LEGACY_SHARED="" restore_shared_privacy || fail "restore_shared_privacy failed on a missing path" ) pass "user mount sources with leftover setgid harden to exactly 700" From bacb9085f51f2c46197fc492df4abc5dc4e4abf0 Mon Sep 17 00:00:00 2001 From: Omabot Date: Thu, 3 Sep 2026 05:22:17 -0700 Subject: [PATCH 04/10] Re-harden the share through the protected anchor only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_shared_privacy also chmodded $LEGACY_SHARED, which is $HOME/Windows: a pathname the unprivileged caller owns. The [[ -d && ! -L ]] test and the chmod are two syscalls, so the caller can swap the directory for a symlink in between and make the root half of __priv_up_wait chmod an arbitrary path to 0700 with the set-ID bits cleared. On a worker a swapper loop won that race on its 260th iteration, taking a root-owned 4755 binary outside the caller's home to root:700. The loop's other element already covers the case. $EXPECTED_SHARED sits in the root-owned 0711 boundary tree the caller cannot write, and assert_mounts_safe has just proved through mounts_ready that it is a bind of the same inode as $LEGACY_SHARED — so chmodding the anchor is what ~/Windows ends up at, measured rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-windows-vm | 13 ++++++------- test/shell.d/windows-vm-test.sh | 6 ++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index ad06deb942a..f146fe609b1 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -563,15 +563,14 @@ chmod_private_dir() { } # dockur samba.sh chmod 2777s an empty /shared at container start. Re-harden -# the already-pinned directory inodes only — never create files in the -# caller-owned share as root (that is a symlink-follow write primitive). +# only the protected anchor, which sits in the root-owned boundary tree the +# caller cannot write: it is a bind of the same inode as $LEGACY_SHARED, so this +# is what ~/Windows ends up at. Never chmod $LEGACY_SHARED by pathname — the +# caller can swap it for a symlink between the test and the chmod. # Best-effort: a failed chmod must not fail a VM that already started. restore_shared_privacy() { - local dir - for dir in "$EXPECTED_SHARED" "$LEGACY_SHARED"; do - [[ -n $dir && -d $dir && ! -L $dir ]] || continue - chmod_private_dir "$dir" || true - done + [[ -n $EXPECTED_SHARED && -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] || return 0 + chmod_private_dir "$EXPECTED_SHARED" || true } prepare_caller_mounts() { diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 281a9547dd8..efeeea984b7 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -51,5 +51,11 @@ pass "Windows VM stays fully opaque" [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows")" mkdir -p "$HOME/missing-parent" EXPECTED_SHARED=$HOME/missing-parent/nope LEGACY_SHARED="" restore_shared_privacy || fail "restore_shared_privacy failed on a missing path" + # The home pathname is caller-controlled, so root must never chmod it directly. + mkdir -p "$HOME/legacy-only" + chmod 2777 "$HOME/legacy-only" + EXPECTED_SHARED="" LEGACY_SHARED=$HOME/legacy-only restore_shared_privacy + [[ $(stat -Lc '%a' "$HOME/legacy-only") == 2777 ]] || + fail "restore_shared_privacy chmodded the caller-controlled home pathname" ) pass "user mount sources with leftover setgid harden to exactly 700" From b50d539b8800d08c980d1479cc1394393aeb12eb Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Thu, 3 Sep 2026 17:41:32 +0200 Subject: [PATCH 05/10] Restore share privacy on install, stop, and up_wait timeout install uses priv up, which returned as soon as the container started and never re-hardened ~/Windows after samba.sh chmod 2777. Wait for that 2777 (or the shared-folder log line) before restoring, and also restore after dc down and when the guest-ready wait times out. --- bin/omarchy-windows-vm | 42 +++++++++++++++++++++++++++++++-- test/shell.d/windows-vm-test.sh | 3 +++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f146fe609b1..8f351c08310 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -573,6 +573,33 @@ restore_shared_privacy() { chmod_private_dir "$EXPECTED_SHARED" || true } +# `dc up -d` returns when the container is started, not when samba.sh has +# chmodded /shared. Wait until that has happened (or a short timeout) so the +# restore lands after 2777 rather than before it. Used by `up` (install) which +# cannot wait for the full guest-ready line. +wait_then_restore_shared_privacy() { + local i mode started_at + [[ -n $EXPECTED_SHARED ]] || return 0 + started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) || started_at="" + if [[ -z $started_at ]]; then + restore_shared_privacy + return 0 + fi + for i in {1..60}; do + mode=$(stat -Lc '%a' "$EXPECTED_SHARED" 2>/dev/null) || mode="" + if [[ $mode == 2777 || $mode == 777 ]]; then + restore_shared_privacy + return 0 + fi + if docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qiE 'shared folder|samba'; then + restore_shared_privacy + return 0 + fi + sleep 0.25 + done + restore_shared_privacy +} + prepare_caller_mounts() { local storage_fd storage_id shared_fd shared_id storage_mode shared_mode CALLER_MOUNTS_NEW_STORAGE=0 @@ -913,9 +940,19 @@ assert_mounts_safe() { } } -__priv_up() { assert_mounts_safe && dc up -d; } +__priv_up() { + assert_mounts_safe || return 1 + dc up -d || return 1 + wait_then_restore_shared_privacy + return 0 +} -__priv_down() { dc down; } +__priv_down() { + local rc=0 + dc down || rc=$? + resolve_caller && restore_shared_privacy + return "$rc" +} # Bring the VM up and wait until the guest reports it is ready, all under a # single elevation so the readiness poll does not prompt on every iteration. @@ -941,6 +978,7 @@ __priv_up_wait() { sleep 2 ((++count > 60)) && { echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 + restore_shared_privacy return 1 } done diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index efeeea984b7..f40fd38b357 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -51,6 +51,9 @@ pass "Windows VM stays fully opaque" [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows")" mkdir -p "$HOME/missing-parent" EXPECTED_SHARED=$HOME/missing-parent/nope LEGACY_SHARED="" restore_shared_privacy || fail "restore_shared_privacy failed on a missing path" + chmod 2777 "$HOME/Windows" + CONTAINER=omarchy-windows-does-not-exist EXPECTED_SHARED=$HOME/Windows wait_then_restore_shared_privacy + [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "wait_then_restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows") without a container" # The home pathname is caller-controlled, so root must never chmod it directly. mkdir -p "$HOME/legacy-only" chmod 2777 "$HOME/legacy-only" From c2ca99a9898cf4924752431e490d16e003cffa40 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Fri, 4 Sep 2026 20:50:32 +0200 Subject: [PATCH 06/10] Close the install-path 2777 window without holding pkexec The 15s wait on priv up expires before dockur finishes the ISO download, so samba.sh still chmod 2777s afterwards. Watch the caller's share in the background after install and restore as the owner. On stop, re-harden every protected per-uid share under the runtime mounts tree instead of resolve_caller (no PKEXEC_UID under direct sudo, and a second user would restore the wrong anchor). --- bin/omarchy-windows-vm | 66 +++++++++++++++++---------------- test/shell.d/windows-vm-test.sh | 13 +++++-- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 8f351c08310..9e6eb758204 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -573,31 +573,39 @@ restore_shared_privacy() { chmod_private_dir "$EXPECTED_SHARED" || true } -# `dc up -d` returns when the container is started, not when samba.sh has -# chmodded /shared. Wait until that has happened (or a short timeout) so the -# restore lands after 2777 rather than before it. Used by `up` (install) which -# cannot wait for the full guest-ready line. -wait_then_restore_shared_privacy() { - local i mode started_at - [[ -n $EXPECTED_SHARED ]] || return 0 - started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) || started_at="" - if [[ -z $started_at ]]; then - restore_shared_privacy - return 0 - fi - for i in {1..60}; do - mode=$(stat -Lc '%a' "$EXPECTED_SHARED" 2>/dev/null) || mode="" - if [[ $mode == 2777 || $mode == 777 ]]; then - restore_shared_privacy - return 0 - fi - if docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qiE 'shared folder|samba'; then - restore_shared_privacy - return 0 - fi - sleep 0.25 +# After `dc down` there is no PKEXEC_UID on a direct `sudo ... stop`, and a +# second user on the box would resolve a different per-uid anchor. Walk the +# root-owned mounts tree instead of calling resolve_caller. +restore_all_shared_privacy() { + local dir canonical prefix + prefix=$(realpath -e -- "$RUNTIME_DIR/mounts/users" 2>/dev/null) || return 0 + for dir in "$prefix"/*/shared; do + [[ -d $dir && ! -L $dir ]] || continue + canonical=$(realpath -e -- "$dir" 2>/dev/null) || continue + [[ $canonical == "$dir" ]] || continue + [[ $canonical == "$prefix/"*"/shared" ]] || continue + chmod_private_dir "$canonical" || true done - restore_shared_privacy +} + +# Unprivileged: samba.sh runs only after dockur's ISO download (10-15 minutes +# on a fresh install). Do not hold a polkit session open for that. Watch the +# caller's own share and chmod it as the owner once it becomes 2777. +schedule_share_privacy_restore() { + local dir="$HOME/Windows" + [[ -e $dir ]] || return 0 + ( + local i mode + for i in {1..1800}; do + mode=$(stat -Lc '%a' "$dir" 2>/dev/null) || mode="" + if [[ $mode == 2777 || $mode == 777 ]]; then + chmod_private_dir "$dir" || true + exit 0 + fi + sleep 1 + done + ) >/dev/null 2>&1 & + disown || true } prepare_caller_mounts() { @@ -940,17 +948,12 @@ assert_mounts_safe() { } } -__priv_up() { - assert_mounts_safe || return 1 - dc up -d || return 1 - wait_then_restore_shared_privacy - return 0 -} +__priv_up() { assert_mounts_safe && dc up -d; } __priv_down() { local rc=0 dc down || rc=$? - resolve_caller && restore_shared_privacy + restore_all_shared_privacy return "$rc" } @@ -1395,6 +1398,7 @@ EOF echo " - Port already in use: check if another VM is running" exit 1 fi + schedule_share_privacy_restore echo "" echo "Windows VM is starting up!" diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index f40fd38b357..a17ffa93c77 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -51,14 +51,21 @@ pass "Windows VM stays fully opaque" [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows")" mkdir -p "$HOME/missing-parent" EXPECTED_SHARED=$HOME/missing-parent/nope LEGACY_SHARED="" restore_shared_privacy || fail "restore_shared_privacy failed on a missing path" - chmod 2777 "$HOME/Windows" - CONTAINER=omarchy-windows-does-not-exist EXPECTED_SHARED=$HOME/Windows wait_then_restore_shared_privacy - [[ $(stat -Lc '%a' "$HOME/Windows") == 700 ]] || fail "wait_then_restore_shared_privacy left mode $(stat -Lc '%a' "$HOME/Windows") without a container" # The home pathname is caller-controlled, so root must never chmod it directly. mkdir -p "$HOME/legacy-only" chmod 2777 "$HOME/legacy-only" EXPECTED_SHARED="" LEGACY_SHARED=$HOME/legacy-only restore_shared_privacy [[ $(stat -Lc '%a' "$HOME/legacy-only") == 2777 ]] || fail "restore_shared_privacy chmodded the caller-controlled home pathname" + mkdir -p "$test_home/runtime/mounts/users/1000/shared" "$test_home/runtime/mounts/users/1001/shared" + chmod 2777 "$test_home/runtime/mounts/users/1000/shared" "$test_home/runtime/mounts/users/1001/shared" + chmod 2777 "$HOME/Windows" + RUNTIME_DIR=$test_home/runtime restore_all_shared_privacy + [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared") == 700 ]] || + fail "restore_all_shared_privacy left uid 1000 share at $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared")" + [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared") == 700 ]] || + fail "restore_all_shared_privacy left uid 1001 share at $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared")" + [[ $(stat -Lc '%a' "$HOME/Windows") == 2777 ]] || + fail "restore_all_shared_privacy chmodded the caller home share" ) pass "user mount sources with leftover setgid harden to exactly 700" From e75738ec465a07be52e6c3d73300abf774d66cb9 Mon Sep 17 00:00:00 2001 From: Omabot Date: Sat, 5 Sep 2026 05:30:18 -0700 Subject: [PATCH 07/10] Keep the install share watcher alive when its terminal closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disown only stops bash from hupping a background job when the shell itself exits. The watcher runs in the install terminal's foreground process group, so the kernel hangs it up when that terminal goes away — and install is launched by omarchy-launch-floating-terminal-with-presentation, which closes as soon as the user dismisses the "Press any key" prompt, minutes before dockur's samba.sh reaches the chmod 2777 the watcher exists to undo. Ignoring SIGHUP in the subshell is what disown was reaching for. An asynchronous command already ignores SIGINT and SIGQUIT when job control is off, so HUP is the only gap. Measured under a pty: without the trap the share is still 2777 four seconds after the terminal exits; with it the watcher restores 700. The new test reproduces that shape with script(1) and fails when the trap is removed. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-windows-vm | 4 ++++ test/shell.d/windows-vm-test.sh | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 9e6eb758204..d916e0c3a3e 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -595,6 +595,10 @@ schedule_share_privacy_restore() { local dir="$HOME/Windows" [[ -e $dir ]] || return 0 ( + # disown only keeps bash from hupping this on exit. The watcher is in the + # install terminal's foreground process group, so closing that terminal + # still hangs it up from the kernel side, seconds after install returns. + trap '' HUP local i mode for i in {1..1800}; do mode=$(stat -Lc '%a' "$dir" 2>/dev/null) || mode="" diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index a17ffa93c77..1ecaefe29ec 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -69,3 +69,28 @@ pass "Windows VM stays fully opaque" fail "restore_all_shared_privacy chmodded the caller home share" ) pass "user mount sources with leftover setgid harden to exactly 700" + +# install runs in a floating terminal that closes as soon as it returns, while +# dockur is still ten minutes from the chmod 2777 the watcher exists to undo. +# script(1) reproduces that shape: a pty whose controlling process exits. +( + test_home=$(mktemp -d) + trap 'rm -rf "$test_home"' EXIT + mkdir -p "$test_home/Windows" + chmod 700 "$test_home/Windows" + cat >"$test_home/install.sh" </dev/null +schedule_share_privacy_restore +EOF + script -q -c "bash $test_home/install.sh" /dev/null >/dev/null 2>&1 + chmod 2777 "$test_home/Windows" + for _ in {1..40}; do + [[ $(stat -Lc '%a' "$test_home/Windows") == 700 ]] && break + sleep 0.25 + done + [[ $(stat -Lc '%a' "$test_home/Windows") == 700 ]] || + fail "the install share watcher left the share at $(stat -Lc '%a' "$test_home/Windows")" +) +pass "the install share watcher outlives the terminal install ran in" From ab2d8e9b34042a0cfbefcead20a9ea5ed69b48b6 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Sat, 5 Sep 2026 18:57:11 +0200 Subject: [PATCH 08/10] Restore share privacy for sudoless-Docker stops and harden the install watcher --- bin/omarchy-windows-vm | 44 ++++++++++++++++--- .../shell.d/windows-vm-mount-boundary-test.sh | 43 ++++++++++++++++++ test/shell.d/windows-vm-test.sh | 15 +++++-- 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index d916e0c3a3e..f38d2504493 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -573,11 +573,17 @@ restore_shared_privacy() { chmod_private_dir "$EXPECTED_SHARED" || true } -# After `dc down` there is no PKEXEC_UID on a direct `sudo ... stop`, and a -# second user on the box would resolve a different per-uid anchor. Walk the -# root-owned mounts tree instead of calling resolve_caller. +# Root-only: the mounts tree is 0711, so an unprivileged caller cannot list it +# and the glob below would silently match nothing. After `dc down` there is no +# PKEXEC_UID on a direct `sudo ... stop`, and a second user on the box would +# resolve a different per-uid anchor, so root walks the tree instead. A +# sudoless caller restores its own anchor through restore_shared_privacy. restore_all_shared_privacy() { local dir canonical prefix + ((EUID == 0)) || return 0 + # Same refusal prepare_runtime_tree applies: a non-standard privileged + # runtime is supported only for unprivileged tests/development. + [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || return 0 prefix=$(realpath -e -- "$RUNTIME_DIR/mounts/users" 2>/dev/null) || return 0 for dir in "$prefix"/*/shared; do [[ -d $dir && ! -L $dir ]] || continue @@ -600,14 +606,25 @@ schedule_share_privacy_restore() { # still hangs it up from the kernel side, seconds after install returns. trap '' HUP local i mode - for i in {1..1800}; do + # 2x the documented 10-15 minute download is a thin margin on a slow link, + # so budget an hour; on expiry the final restore below still closes the + # 2777 window for anything that happened before it. + for i in {1..3600}; do mode=$(stat -Lc '%a' "$dir" 2>/dev/null) || mode="" if [[ $mode == 2777 || $mode == 777 ]]; then - chmod_private_dir "$dir" || true - exit 0 + chmod_private_dir "$dir" 2>/dev/null || true + # Do not exit on a failed chmod: a transient failure (a swapped path, + # an unwritable moment) must not end the watcher permanently while the + # share is still exposed. Leave only once the mode is really 700. + [[ $(stat -Lc '%a' "$dir" 2>/dev/null) == 700 ]] && exit 0 fi sleep 1 done + chmod_private_dir "$dir" 2>/dev/null || true + # The subshell output goes to /dev/null, so the only visible trace of an + # expiry is this journal entry. + logger -t omarchy-windows-vm \ + "share privacy watcher timed out; applied a final restore to $dir" 2>/dev/null || true ) >/dev/null 2>&1 & disown || true } @@ -957,7 +974,20 @@ __priv_up() { assert_mounts_safe && dc up -d; } __priv_down() { local rc=0 dc down || rc=$? - restore_all_shared_privacy + if ((EUID == 0)); then + restore_all_shared_privacy + else + # A sudoless-Docker stop runs unelevated, where the mounts tree is not + # listable. restore_shared_privacy still works here: the anchor sits under + # the root-owned boundary tree the caller cannot rename, and while the + # bind exists it is the caller's own inode, so the owner chmod succeeds. + # If the bind is gone (fresh reboot) the chmod fails and the next launch + # re-hardens through prepare_user_mount_sources instead. Best-effort: the + # container is already down, so a failed restore must not fail the stop. + if resolve_caller; then + restore_shared_privacy + fi + fi return "$rc" } diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 74075422649..6768f6622b6 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -135,6 +135,49 @@ with_vm_lock prepare_caller_mounts || fail "root could not harden setgid VM sour $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "setgid hardening did not leave caller-owned 0700 anchors" pass "setgid VM source directories harden to exactly 700" +# Stop-time restore must cover every caller shape. Root walks the mounts tree +# for a direct `sudo ... stop`; a sudoless-Docker stop is unelevated, where the +# 0711 tree is not listable, and must restore the caller's own anchor +# owner-side instead. A second account's anchor is never another user's to +# chmod, and nothing runs through a non-standard privileged runtime path. +mkdir -p "$USERS_DIR/1001/shared" "$test_tmp/mounts/users/1000/shared" +chmod 2777 "$USERS_DIR/1001/shared" "$EXPECTED_SHARED" "$test_tmp/mounts/users/1000/shared" +RUNTIME_DIR=$test_tmp restore_all_shared_privacy +[[ $(command stat -Lc '%a' "$EXPECTED_SHARED") == 2777 && + $(command stat -Lc '%a' "$USERS_DIR/1001/shared") == 2777 && + $(command stat -Lc '%a' "$test_tmp/mounts/users/1000/shared") == 2777 ]] || + fail "root restored anchors through a non-standard runtime path" +install -d -m 0755 /var/vm-test-bin +install -m 0644 "$test_tmp/omarchy-windows-vm" /var/vm-test-bin/omarchy-windows-vm +child_rc=0 +setpriv --reuid=1000 --regid=1000 --clear-groups bash -c ' + # The namespace has no passwd entry for uid 1000, so mirror the stub above. + getent() { + if [[ $1 == passwd && $2 == 1000 ]]; then + printf "alice:x:1000:1000::/home/alice:/bin/bash\n" + return 0 + fi + return 2 + } + set -- help + source /var/vm-test-bin/omarchy-windows-vm >/dev/null 2>&1 + resolve_caller || exit 10 + restore_all_shared_privacy || exit 11 + [[ $(command stat -Lc "%a" "$USERS_DIR/1000/shared") == 2777 ]] || exit 12 + [[ $(command stat -Lc "%a" "$USERS_DIR/1001/shared") == 2777 ]] || exit 13 + restore_shared_privacy || exit 14 + [[ $(command stat -Lc "%a" "$USERS_DIR/1000/shared") == 700 ]] || exit 15 + [[ $(command stat -Lc "%a" "$USERS_DIR/1001/shared") == 2777 ]] || exit 16 +' || child_rc=$? +[[ $child_rc == 0 ]] || fail "sudoless stop restore misbehaved (rc=$child_rc)" +chmod 2777 "$EXPECTED_SHARED" +restore_all_shared_privacy +[[ $(command stat -Lc '%a' "$EXPECTED_SHARED") == 700 && + $(command stat -Lc '%a' "$USERS_DIR/1001/shared") == 700 ]] || + fail "root walk did not restore every anchor to 700" +rm -rf /var/vm-test-bin +pass "stop-time restore walks the tree as root and restores only the caller's anchor unprivileged" + # Existing production boundary components are never repaired in place when # their ownership or write permissions are unsafe. Both the preparation path # and the final pre-Docker guard must fail closed without disturbing the binds. diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 1ecaefe29ec..e1e998d828f 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -60,11 +60,20 @@ pass "Windows VM stays fully opaque" mkdir -p "$test_home/runtime/mounts/users/1000/shared" "$test_home/runtime/mounts/users/1001/shared" chmod 2777 "$test_home/runtime/mounts/users/1000/shared" "$test_home/runtime/mounts/users/1001/shared" chmod 2777 "$HOME/Windows" + # The mounts tree is root-owned and not listable unprivileged, so the walk is + # root-only: here it must do nothing at all, not look like a restore. RUNTIME_DIR=$test_home/runtime restore_all_shared_privacy + [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared") == 2777 ]] || + fail "unprivileged restore_all_shared_privacy touched uid 1000 share: $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared")" + [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared") == 2777 ]] || + fail "unprivileged restore_all_shared_privacy touched uid 1001 share: $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared")" + # A sudoless-Docker stop instead restores the caller's own anchor owner-side, + # and never another user's. + EXPECTED_SHARED=$test_home/runtime/mounts/users/1000/shared restore_shared_privacy [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared") == 700 ]] || - fail "restore_all_shared_privacy left uid 1000 share at $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared")" - [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared") == 700 ]] || - fail "restore_all_shared_privacy left uid 1001 share at $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared")" + fail "owner-side restore left uid 1000 share at $(stat -Lc '%a' "$test_home/runtime/mounts/users/1000/shared")" + [[ $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared") == 2777 ]] || + fail "owner-side restore touched uid 1001 share: $(stat -Lc '%a' "$test_home/runtime/mounts/users/1001/shared")" [[ $(stat -Lc '%a' "$HOME/Windows") == 2777 ]] || fail "restore_all_shared_privacy chmodded the caller home share" ) From e9d5d53596b9fcc5f731e8c33ef7132ccb9fe839 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Sun, 6 Sep 2026 20:04:55 +0200 Subject: [PATCH 09/10] Refuse to elevate a mismatched packaged copy --- bin/omarchy-windows-vm | 22 ++++++++++++++++++++++ test/shell.d/windows-vm-test.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index f38d2504493..9240595ea2e 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -77,6 +77,24 @@ priv_target() { printf '%s\n' "$candidate" } +# pkexec can only run the packaged copy at its fixed root-owned path, which a +# dev-link checkout never shadows: the unprivileged half then runs the checkout +# while the elevated half runs the package. All privileged mount work happens in +# the elevated half, so a stale packaged copy would re-apply whatever chmod +# semantics it shipped with and fail closed with no diagnostic (observed as a +# launch refusing a 2777 share and leaving it at 2700). Refuse unless both +# halves are the same build. +privileged_copy_matches() { + local target="$1" target_hash self_hash + [[ -r ${BASH_SOURCE[0]} && -r $target ]] || return 1 + # The common case runs both halves from the same file; only a dev checkout + # needs the content comparison. + [[ ${BASH_SOURCE[0]} -ef $target ]] && return 0 + target_hash=$(sha256sum -- "$target") || return 1 + self_hash=$(sha256sum -- "${BASH_SOURCE[0]}") || return 1 + [[ ${target_hash%% *} == "${self_hash%% *}" ]] +} + # Run a privileged VM action. write_compose always elevates (the compose is # root-owned); the daemon operations run directly when sudoless Docker is on and # otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec @@ -104,6 +122,10 @@ priv() { echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2 return 1 } + privileged_copy_matches "$target" || { + echo "omarchy-windows-vm: refusing to elevate: $target is not this command; refresh the installed omarchy package so root runs the same build" >&2 + return 1 + } pkexec "$target" __priv "$action" "$@" } diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index e1e998d828f..1f8a586bae0 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -103,3 +103,30 @@ EOF fail "the install share watcher left the share at $(stat -Lc '%a' "$test_home/Windows")" ) pass "the install share watcher outlives the terminal install ran in" + +# pkexec runs the packaged copy, which a dev link cannot shadow. A stale +# packaged copy used to re-apply the pre-fix chmod semantics with no diagnostic, +# failing a launch and leaving the share at 2700. The skew check must refuse +# before pkexec and say why, and must accept an identical copy. +( + test_home=$(mktemp -d) + trap 'rm -rf "$test_home"' EXIT + set -- help + source "$windows_vm_command" >/dev/null + cp "$windows_vm_command" "$test_home/copy" + privileged_copy_matches "$test_home/copy" || fail "the skew check rejected an identical packaged copy" + printf drift >>"$test_home/copy" + privileged_copy_matches "$test_home/copy" && fail "the skew check accepted a drifted packaged copy" + docker_needs_sudo() { return 0; } + printf '#!/bin/bash\n' >"$test_home/packaged" + chmod 755 "$test_home/packaged" + priv_target() { printf '%s\n' "$test_home/packaged"; } + pkexec() { : >"$test_home/elevated"; } + privileged_copy_matches() { return 1; } + priv status >/dev/null 2>&1 && fail "priv elevated despite a mismatched privileged copy" + [[ ! -e $test_home/elevated ]] || fail "priv reached pkexec with a mismatched privileged copy" + privileged_copy_matches() { return 0; } + priv status >/dev/null 2>&1 || fail "priv refused a matching privileged copy" + [[ -e $test_home/elevated ]] || fail "priv did not reach pkexec with a matching privileged copy" +) +pass "elevation refuses a mismatched privileged copy and accepts an identical one" From 7a5fa2eef48589009ce74d3cf8422b17070756d9 Mon Sep 17 00:00:00 2001 From: Emiel Kollof Date: Sun, 6 Sep 2026 20:04:55 +0200 Subject: [PATCH 10/10] Pick boundary-test fixture uids that cannot collide with host VM anchors --- .../shell.d/windows-vm-mount-boundary-test.sh | 84 +++++++++++-------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 6768f6622b6..dc6c089b572 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -19,6 +19,22 @@ trap 'rm -rf "$test_tmp"' EXIT # mount-safe copy of the helper before the mounts land. cp "$ROOT/bin/omarchy-windows-vm" "$test_tmp/omarchy-windows-vm" +# A host VM leaves its anchors mounted at exactly the production anchor paths +# this test re-creates for its fixture uid. The tmpfs below hides them from the +# filesystem, but the user-ns copy of the mount table keeps them listed in +# /proc/self/mountinfo — and they are MNT_LOCKED, so they cannot be detached — +# while mountpoint(1) matches entries by path: the fresh anchors then look like +# existing mounts and prepare_mount_anchor short-circuits without creating +# them. Pick fixture uids whose anchor paths cannot collide with whatever the +# host VM left behind. +TEST_UID=4242 +TEST_UID_OTHER=$((TEST_UID + 1)) +while grep -qE "mounts/users/(${TEST_UID}|${TEST_UID_OTHER})/(storage|shared) " /proc/self/mountinfo; do + TEST_UID=$((TEST_UID + 2)) + TEST_UID_OTHER=$((TEST_UID + 1)) +done +export TEST_UID TEST_UID_OTHER + # Hide host state before creating the production paths used by the root helper. mount -t tmpfs -o mode=0755,size=8m run-test /run mkdir -p /run/lock @@ -42,8 +58,8 @@ stat() { TEST_PASSWD_HOME=/home/alice getent() { - if [[ $1 == passwd && ${2:-} == 1000 ]]; then - printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME" + if [[ $1 == passwd && ${2:-} == ${TEST_UID} ]]; then + printf "alice:x:${TEST_UID}:${TEST_UID}::%s:/bin/bash\n" "$TEST_PASSWD_HOME" return 0 fi return 2 @@ -63,14 +79,14 @@ assert_no_runtime_mutation "zero PKEXEC_UID" PKEXEC_UID=not-a-number resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID" assert_no_runtime_mutation "nonnumeric PKEXEC_UID" -PKEXEC_UID=1001 +PKEXEC_UID=${TEST_UID_OTHER} resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd" assert_no_runtime_mutation "missing passwd entry" -PKEXEC_UID=1000 +PKEXEC_UID=${TEST_UID} resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller" assert_no_runtime_mutation "wrong-owned home" -chown 1000:1000 /home/alice +chown ${TEST_UID}:${TEST_UID} /home/alice chmod 0777 /home resolve_caller 2>/dev/null && fail "root accepted writable home parent" @@ -78,7 +94,7 @@ assert_no_runtime_mutation "writable parent" chmod 0755 /home mkdir /home/real-alice -chown 1000:1000 /home/real-alice +chown ${TEST_UID}:${TEST_UID} /home/real-alice ln -s /home/real-alice /home/link-alice TEST_PASSWD_HOME=/home/link-alice resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home" @@ -90,14 +106,14 @@ pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and wri # Put each familiar source on its own filesystem. Both start with legacy 0755 # permissions and world-readable payloads to prove migration hardens the leaves. mkdir /home/storage-target /home/shared-target -mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target -mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target +mount -t tmpfs -o uid=${TEST_UID},gid=${TEST_UID},mode=0755,size=3g storage-test /home/storage-target +mount -t tmpfs -o uid=${TEST_UID},gid=${TEST_UID},mode=0755,size=64m shared-test /home/shared-target ln -s /home/storage-target /home/alice/.windows ln -s /home/shared-target /home/alice/Windows -chown -h 1000:1000 /home/alice/.windows /home/alice/Windows +chown -h ${TEST_UID}:${TEST_UID} /home/alice/.windows /home/alice/Windows printf disk >/home/storage-target/disk.img printf shared >/home/shared-target/shared.txt -chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt +chown ${TEST_UID}:${TEST_UID} /home/storage-target/disk.img /home/shared-target/shared.txt chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt home_dev=$(command stat -Lc '%d' /home/alice) @@ -113,12 +129,12 @@ resolve_caller [[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem" [[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 && $(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes" -[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 && - $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700" -if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then +[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == ${TEST_UID}:700 && + $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == ${TEST_UID}:700 ]] || fail "migrated leaves are not caller-owned 0700" +if setpriv --reuid=${TEST_UID_OTHER} --regid=${TEST_UID_OTHER} --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then fail "another local account read the VM disk through its anchor" fi -if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then +if setpriv --reuid=${TEST_UID_OTHER} --regid=${TEST_UID_OTHER} --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then fail "another local account read shared files through their anchor" fi pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" @@ -127,12 +143,12 @@ pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves # exact-700 check and block every privileged action (omacom/omarchy#9698). chmod 2700 /home/storage-target chmod 2777 /home/shared-target -chown 1000:1000 /home/storage-target /home/shared-target +chown ${TEST_UID}:${TEST_UID} /home/storage-target /home/shared-target with_vm_lock prepare_caller_mounts || fail "root could not harden setgid VM source directories" [[ $(command stat -Lc '%a' /home/storage-target) == 700 ]] || fail "storage still had special bits after hardening" [[ $(command stat -Lc '%a' /home/shared-target) == 700 ]] || fail "shared still had special bits after hardening" -[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 && - $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "setgid hardening did not leave caller-owned 0700 anchors" +[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == ${TEST_UID}:700 && + $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == ${TEST_UID}:700 ]] || fail "setgid hardening did not leave caller-owned 0700 anchors" pass "setgid VM source directories harden to exactly 700" # Stop-time restore must cover every caller shape. Root walks the mounts tree @@ -140,21 +156,21 @@ pass "setgid VM source directories harden to exactly 700" # 0711 tree is not listable, and must restore the caller's own anchor # owner-side instead. A second account's anchor is never another user's to # chmod, and nothing runs through a non-standard privileged runtime path. -mkdir -p "$USERS_DIR/1001/shared" "$test_tmp/mounts/users/1000/shared" -chmod 2777 "$USERS_DIR/1001/shared" "$EXPECTED_SHARED" "$test_tmp/mounts/users/1000/shared" +mkdir -p "$USERS_DIR/${TEST_UID_OTHER}/shared" "$test_tmp/mounts/users/${TEST_UID}/shared" +chmod 2777 "$USERS_DIR/${TEST_UID_OTHER}/shared" "$EXPECTED_SHARED" "$test_tmp/mounts/users/${TEST_UID}/shared" RUNTIME_DIR=$test_tmp restore_all_shared_privacy [[ $(command stat -Lc '%a' "$EXPECTED_SHARED") == 2777 && - $(command stat -Lc '%a' "$USERS_DIR/1001/shared") == 2777 && - $(command stat -Lc '%a' "$test_tmp/mounts/users/1000/shared") == 2777 ]] || + $(command stat -Lc '%a' "$USERS_DIR/${TEST_UID_OTHER}/shared") == 2777 && + $(command stat -Lc '%a' "$test_tmp/mounts/users/${TEST_UID}/shared") == 2777 ]] || fail "root restored anchors through a non-standard runtime path" install -d -m 0755 /var/vm-test-bin install -m 0644 "$test_tmp/omarchy-windows-vm" /var/vm-test-bin/omarchy-windows-vm child_rc=0 -setpriv --reuid=1000 --regid=1000 --clear-groups bash -c ' - # The namespace has no passwd entry for uid 1000, so mirror the stub above. +setpriv --reuid=${TEST_UID} --regid=${TEST_UID} --clear-groups bash -c ' + # The namespace has no passwd entry for uid ${TEST_UID}, so mirror the stub above. getent() { - if [[ $1 == passwd && $2 == 1000 ]]; then - printf "alice:x:1000:1000::/home/alice:/bin/bash\n" + if [[ $1 == passwd && $2 == ${TEST_UID} ]]; then + printf "alice:x:${TEST_UID}:${TEST_UID}::/home/alice:/bin/bash\n" return 0 fi return 2 @@ -163,17 +179,17 @@ setpriv --reuid=1000 --regid=1000 --clear-groups bash -c ' source /var/vm-test-bin/omarchy-windows-vm >/dev/null 2>&1 resolve_caller || exit 10 restore_all_shared_privacy || exit 11 - [[ $(command stat -Lc "%a" "$USERS_DIR/1000/shared") == 2777 ]] || exit 12 - [[ $(command stat -Lc "%a" "$USERS_DIR/1001/shared") == 2777 ]] || exit 13 + [[ $(command stat -Lc "%a" "$USERS_DIR/${TEST_UID}/shared") == 2777 ]] || exit 12 + [[ $(command stat -Lc "%a" "$USERS_DIR/${TEST_UID_OTHER}/shared") == 2777 ]] || exit 13 restore_shared_privacy || exit 14 - [[ $(command stat -Lc "%a" "$USERS_DIR/1000/shared") == 700 ]] || exit 15 - [[ $(command stat -Lc "%a" "$USERS_DIR/1001/shared") == 2777 ]] || exit 16 + [[ $(command stat -Lc "%a" "$USERS_DIR/${TEST_UID}/shared") == 700 ]] || exit 15 + [[ $(command stat -Lc "%a" "$USERS_DIR/${TEST_UID_OTHER}/shared") == 2777 ]] || exit 16 ' || child_rc=$? [[ $child_rc == 0 ]] || fail "sudoless stop restore misbehaved (rc=$child_rc)" chmod 2777 "$EXPECTED_SHARED" restore_all_shared_privacy [[ $(command stat -Lc '%a' "$EXPECTED_SHARED") == 700 && - $(command stat -Lc '%a' "$USERS_DIR/1001/shared") == 700 ]] || + $(command stat -Lc '%a' "$USERS_DIR/${TEST_UID_OTHER}/shared") == 700 ]] || fail "root walk did not restore every anchor to 700" rm -rf /var/vm-test-bin pass "stop-time restore walks the tree as root and restores only the caller's anchor unprivileged" @@ -187,10 +203,10 @@ mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount bo [[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary" chmod 0711 "$MOUNT_ROOT" -chown 1000:1000 "$USERS_DIR" +chown ${TEST_UID}:${TEST_UID} "$USERS_DIR" with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it" mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary" -[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner" +[[ $(command stat -Lc '%u' "$USERS_DIR") == ${TEST_UID} ]] || fail "rejection unexpectedly changed the boundary owner" chown root:root "$USERS_DIR" [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && @@ -237,7 +253,7 @@ umount "$EXPECTED_SHARED" umount "$EXPECTED_STORAGE" rm /home/alice/Windows ln -s / /home/alice/Windows -chown -h 1000:1000 /home/alice/Windows +chown -h ${TEST_UID}:${TEST_UID} /home/alice/Windows with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source" [[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind" [[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink" @@ -247,7 +263,7 @@ pass "root preflights both sources before mounting either and preserves rejectio # root-planted anchor symlink to the expected mounted source is rejected. rm /home/alice/Windows ln -s /home/shared-target /home/alice/Windows -chown -h 1000:1000 /home/alice/Windows +chown -h ${TEST_UID}:${TEST_UID} /home/alice/Windows rmdir "$EXPECTED_STORAGE" ln -s /home/storage-target "$EXPECTED_STORAGE" storage_id=$(command stat -Lc '%d:%i' /home/storage-target)