From 19c2b6a925edd189f438b75ecb614dbab649e3eb Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:31:41 +0100 Subject: [PATCH] Fix portable root ownership for installer lifecycle state Issue #1890 reports macOS agent updates stopping because the root group does not exist. Use numeric superuser ownership in the two shared lifecycle writes without relaxing failure handling or the least-privilege group boundary. Add a regression fixture that rejects named root ownership and checks that chown failures still prevent replacement. Change-source: pulse-maintainer --- .../v6/internal/subsystems/agent-lifecycle.md | 15 ++++++++++ .../subsystems/deployment-installability.md | 15 ++++++++++ scripts/install.sh | 5 ++-- scripts/installtests/install_sh_test.go | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e74911e30..b08ed3b51 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,21 @@ ## Purpose +### Portable installer lifecycle ownership + +The shared shell installer lifecycle directory (outside the least-privilege +profile) and atomic lifecycle-file staging use numeric UID/GID `0:0` when +running as root. They must not depend on a group named `root`, which is absent +on macOS. The least-privilege directory retains its dedicated collector group +and 0750 mode; ordinary root lifecycle directories retain 0700 mode. +Ownership failure aborts directory preparation or file installation before +replacement of the saved installer. The runtime shell fixture +`TestInstallSHLifecyclePortableRootOwnership` in +`scripts/installtests/install_sh_test.go` rejects named ownership and proves +that a failed chown leaves the previously saved installer intact. This fixture +is not native macOS upgrade qualification. + + `models.PBSBackup` may carry monitoring-owned write-activity evidence for an incomplete PBS snapshot. `inProgress` means the snapshot has no completed manifest; `writeActivityObserved` and `writeActive` distinguish a successfully diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ffad17a35..2e19c32e9 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,21 @@ ## Purpose +### Portable installer lifecycle ownership + +The shared shell installer lifecycle directory (outside the least-privilege +profile) and atomic lifecycle-file staging use numeric UID/GID `0:0` when +running as root. They must not depend on a group named `root`, which is absent +on macOS. The least-privilege directory retains its dedicated collector group +and 0750 mode; ordinary root lifecycle directories retain 0700 mode. +Ownership failure aborts directory preparation or file installation before +replacement of the saved installer. The runtime shell fixture +`TestInstallSHLifecyclePortableRootOwnership` in +`scripts/installtests/install_sh_test.go` rejects named ownership and proves +that a failed chown leaves the previously saved installer intact. This fixture +is not native macOS upgrade qualification. + + Own server installation, deployment bootstrap behavior, provider-hosted MSP deployment artifacts, update planning, and server-side update execution surfaces. diff --git a/scripts/install.sh b/scripts/install.sh index 614860ae1..f3bf84410 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3099,7 +3099,8 @@ prepare_installer_lifecycle_dir() { chown "root:${LEAST_PRIVILEGE_USER}" "$lifecycle_dir" || return 1 chmod 0750 "$lifecycle_dir" || return 1 else - chown root:root "$lifecycle_dir" || return 1 + # Numeric IDs also work on macOS, whose group 0 is wheel, not root. + chown 0:0 "$lifecycle_dir" || return 1 chmod 0700 "$lifecycle_dir" || return 1 fi else @@ -3123,7 +3124,7 @@ install_lifecycle_file_atomically() { cp "$source_path" "$target_tmp" || return 1 chmod "$target_mode" "$target_tmp" || return 1 if [[ "$(id -u)" == "0" ]]; then - chown root:root "$target_tmp" || return 1 + chown 0:0 "$target_tmp" || return 1 fi sync_lifecycle_path "$target_tmp" || return 1 mv -f "$target_tmp" "$target_path" || return 1 diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index f9b8f049e..4ce19f0f0 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -7885,3 +7885,32 @@ func TestInstallSHTypedPrivilegedHelperProtectsCredentialsAfterStateChown(t *tes } } } + +// macOS has no root group. Exercise both shared lifecycle writes with a +// chown stub that accepts only portable numeric superuser ownership. +func TestInstallSHLifecyclePortableRootOwnership(t *testing.T) { + script := `set -euo pipefail +LEAST_PRIVILEGE=false +TMP_FILES=() +id() { echo 0; } +chown() { [[ "$1" == "0:0" ]]; } +sync_lifecycle_path() { :; } +` + extractInstallShellFunction(t, "prepare_installer_lifecycle_dir") + "\n" + + extractInstallShellFunction(t, "install_lifecycle_file_atomically") + ` +prepare_installer_lifecycle_dir "$PWD/state" +printf 'saved installer\n' > "$PWD/source" +install_lifecycle_file_atomically "$PWD/source" "$PWD/state/install.sh" 700 +cmp "$PWD/source" "$PWD/state/install.sh" +# A failed ownership operation must still prevent replacement. +chown() { return 1; } +if prepare_installer_lifecycle_dir "$PWD/rejected"; then exit 10; fi +printf 'replacement\n' > "$PWD/source" +if install_lifecycle_file_atomically "$PWD/source" "$PWD/state/install.sh" 700; then exit 11; fi +grep -qx 'saved installer' "$PWD/state/install.sh" +` + cmd := exec.Command("bash", "-c", script) + cmd.Dir = t.TempDir() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("lifecycle ownership: %v\n%s", err, out) + } +}