From bfe89639a4f6f996e3ad61cf1b4b1cf7260af04f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 24 Jun 2026 14:24:20 -0500 Subject: [PATCH 01/18] Add per-machine data store (appdata:MachineFolder) to MSIX manifest Opt into the WindowsAppSDK per-machine ApplicationData store as an alternative to mutable package directories. Adds the appdata namespace (ignorable) plus a Properties/ApplicationData/MachineFolder element with an SDDL granting Administrators write, and bumps MaxVersionTested to 10.0.26100.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- assets/AppxManifest.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/AppxManifest.xml b/assets/AppxManifest.xml index dfcd95935d9..a2a3de45114 100644 --- a/assets/AppxManifest.xml +++ b/assets/AppxManifest.xml @@ -1,12 +1,13 @@ - @@ -17,10 +18,13 @@ assets\StoreLogo.png disabled disabled + + + - + From 63d9e69d5580485cf19bcb5514ba6ff4cb86a250 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 24 Jun 2026 15:51:12 -0500 Subject: [PATCH 02/18] Relocate AllUsers paths to per-machine data store when packaged When running as a packaged MSIX app that opts into the per-machine ApplicationData store (appdata:MachineFolder), relocate the writable AllUsers locations from the read-only \C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.3.0_x64__8wekyb3d8bbwe install dir into that store: module path, system-wide powershell.config.json, AllUsers profiles, and AllUsers updatable help. A central helper, Utils.GetPackagedMachineDataStorePath(), detects package identity (GetCurrentPackageFamilyName) and the provisioned store folder, returning null when not packaged so all behavior is unchanged for normal installs. The shipped \C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.3.0_x64__8wekyb3d8bbwe powershell.config.json is preserved by seeding the store copy on first write and falling back to it for reads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../engine/Modules/ModuleIntrinsics.cs | 32 ++++---- .../engine/PSConfiguration.cs | 73 +++++++++++++++++- .../engine/Utils.cs | 74 +++++++++++++++++++ .../engine/hostifaces/HostUtilities.cs | 4 +- .../help/CommandHelpProvider.cs | 8 ++ .../help/HelpFileHelpProvider.cs | 7 ++ .../help/HelpUtils.cs | 54 +++++++++++--- .../help/UpdateHelpCommand.cs | 6 ++ 8 files changed, 232 insertions(+), 26 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 538c4775f0a..9adece3a95c 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -1030,6 +1030,16 @@ internal static string GetSharedModulePath() #endif } + /// + /// When running as a packaged MSIX app with the per-machine data store provisioned, gets the + /// writable AllUsers module path located in that store; otherwise null. + /// + internal static string GetMachineStoreModulePath() + { + string store = Utils.GetPackagedMachineDataStorePath(); + return string.IsNullOrEmpty(store) ? null : Path.Combine(store, "Modules"); + } + #if !UNIX /// /// Get the path to the Windows PowerShell module directory under the @@ -1054,23 +1064,17 @@ internal static string GetWindowsPowerShellPSHomeModulePath() /// private static string CombineSystemModulePaths() { + string machineStoreModulePath = GetMachineStoreModulePath(); string psHomeModulePath = GetPSHomeModulePath(); string sharedModulePath = GetSharedModulePath(); - bool isPSHomePathNullOrEmpty = string.IsNullOrEmpty(psHomeModulePath); - bool isSharedPathNullOrEmpty = string.IsNullOrEmpty(sharedModulePath); - - if (!isPSHomePathNullOrEmpty && !isSharedPathNullOrEmpty) - { - return (sharedModulePath + Path.PathSeparator + psHomeModulePath); - } - - if (!isPSHomePathNullOrEmpty || !isSharedPathNullOrEmpty) - { - return isPSHomePathNullOrEmpty ? sharedModulePath : psHomeModulePath; - } + // Order (lowest precedence last): shared (Program Files), per-machine data store, $PSHOME (in-box). + var paths = new List(3); + if (!string.IsNullOrEmpty(sharedModulePath)) { paths.Add(sharedModulePath); } + if (!string.IsNullOrEmpty(machineStoreModulePath)) { paths.Add(machineStoreModulePath); } + if (!string.IsNullOrEmpty(psHomeModulePath)) { paths.Add(psHomeModulePath); } - return null; + return paths.Count == 0 ? null : string.Join(Path.PathSeparator, paths); } internal static string GetExpandedEnvironmentVariable(string name, EnvironmentVariableTarget target) @@ -1255,6 +1259,7 @@ public static string GetModulePath(string currentProcessModulePath, string hklmM currentProcessModulePath = UpdatePath(currentProcessModulePath, personalModulePathToUse, ref insertIndex); currentProcessModulePath = UpdatePath(currentProcessModulePath, sharedModulePath, ref insertIndex); + currentProcessModulePath = UpdatePath(currentProcessModulePath, GetMachineStoreModulePath() ?? string.Empty, ref insertIndex); currentProcessModulePath = UpdatePath(currentProcessModulePath, systemModulePathToUse, ref insertIndex); } @@ -1294,6 +1299,7 @@ internal static string GetWindowsPowerShellModulePath() GetPersonalModulePath(), GetSharedModulePath(), GetPSHomeModulePath(), + GetMachineStoreModulePath(), PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers), PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) }; diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 419a4cae95f..df5bd12b93b 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -61,6 +61,11 @@ internal sealed class PowerShellConfig private string systemWideConfigFile; private string systemWideConfigDirectory; + // When the system-wide configuration is redirected to the per-machine data store (packaged MSIX), + // this holds the shipped $PSHOME config, used to seed the store on first write and as a read + // fallback until the store copy exists. Null when not redirected. + private string systemWideConfigReadFallbackFile; + // The json file containing the per-user configuration settings. private readonly string perUserConfigFile; private readonly string perUserConfigDirectory; @@ -85,6 +90,18 @@ private PowerShellConfig() systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; systemWideConfigFile = Path.Combine(systemWideConfigDirectory, ConfigFileName); + // When running as a packaged MSIX app that opts into the per-machine data store, redirect the + // system-wide configuration to that writable location so settings such as ExecutionPolicy and + // experimental features can be changed after install. The shipped $PSHOME config is preserved: + // it seeds the store on first write and is used as a read fallback until the store copy exists. + string machineStore = Utils.GetPackagedMachineDataStorePath(); + if (!string.IsNullOrEmpty(machineStore)) + { + systemWideConfigReadFallbackFile = systemWideConfigFile; + systemWideConfigDirectory = machineStore; + systemWideConfigFile = Path.Combine(machineStore, ConfigFileName); + } + // Sets the per-user configuration directory // Note: This directory may or may not exist depending upon the execution scenario. // Writes will attempt to create the directory if it does not already exist. @@ -101,9 +118,53 @@ private PowerShellConfig() fileLock = new ReaderWriterLockSlim(); } - private string GetConfigFilePath(ConfigScope scope) + private string GetConfigFilePath(ConfigScope scope, bool forWrite = false) { - return (scope == ConfigScope.CurrentUser) ? perUserConfigFile : systemWideConfigFile; + if (scope == ConfigScope.CurrentUser) + { + return perUserConfigFile; + } + + // AllUsers (system-wide). When the config has been redirected to the per-machine data store, + // reads fall back to the shipped $PSHOME config until the store copy exists, so the shipped + // defaults (e.g. ExecutionPolicy, experimental features) are honored. Writes always target the + // store copy (seeded from the shipped config by EnsureSystemConfigSeeded). + if (!forWrite + && systemWideConfigReadFallbackFile != null + && !File.Exists(systemWideConfigFile) + && File.Exists(systemWideConfigReadFallbackFile)) + { + return systemWideConfigReadFallbackFile; + } + + return systemWideConfigFile; + } + + /// + /// When the system-wide configuration has been redirected to the per-machine data store, seed + /// that store with the shipped $PSHOME configuration the first time it is written so the shipped + /// defaults are preserved. Best-effort: requires write access to the store (e.g. an elevated process). + /// + private void EnsureSystemConfigSeeded(ConfigScope scope, string targetFile) + { + if (scope != ConfigScope.AllUsers + || systemWideConfigReadFallbackFile == null + || File.Exists(targetFile) + || !File.Exists(systemWideConfigReadFallbackFile)) + { + return; + } + + try + { + Directory.CreateDirectory(systemWideConfigDirectory); + File.Copy(systemWideConfigReadFallbackFile, targetFile); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Best-effort: if we lack permission to write to the store (e.g. non-elevated), the write + // below surfaces the access error to the caller, matching the pre-existing $PSHOME behavior. + } } /// @@ -124,6 +185,9 @@ internal void SetSystemConfigFilePath(string value) FileInfo info = new FileInfo(value); systemWideConfigFile = info.FullName; systemWideConfigDirectory = info.Directory.FullName; + + // An explicit settings file overrides any per-machine data store redirection. + systemWideConfigReadFallbackFile = null; } /// @@ -476,9 +540,12 @@ private void UpdateValueInFile(ConfigScope scope, string key, T value, bool a { try { - string fileName = GetConfigFilePath(scope); + string fileName = GetConfigFilePath(scope, forWrite: true); fileLock.EnterWriteLock(); + // Preserve shipped defaults by seeding the per-machine store from $PSHOME on first write. + EnsureSystemConfigSeeded(scope, fileName); + // Since multiple properties can be in a single file, replacement is required instead of overwrite if a file already exists. // Handling the read and write operations within a single FileStream prevents other processes from reading or writing the file while // the update is in progress. It also locks out readers during write operations. diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 0de9fe0d5cc..0a35bd9ddcc 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -475,6 +475,28 @@ internal static string GetWindowsPowerShellVersionFromRegistry() return string.Empty; } + + private const int AppModelErrorNoPackage = 15700; + + [DllImport("kernel32.dll", EntryPoint = "GetCurrentPackageFamilyName", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyNameNative(ref uint packageFamilyNameLength, [Out] StringBuilder packageFamilyName); + + /// + /// Returns the package family name of the current process when it has package (MSIX) identity; otherwise null. + /// + private static string TryGetCurrentPackageFamilyName() + { + uint length = 0; + int rc = GetCurrentPackageFamilyNameNative(ref length, packageFamilyName: null); + if (rc == AppModelErrorNoPackage || length == 0) + { + return null; + } + + var buffer = new StringBuilder((int)length); + rc = GetCurrentPackageFamilyNameNative(ref length, buffer); + return rc == 0 ? buffer.ToString() : null; + } #endif internal static string DefaultPowerShellAppBase => GetApplicationBase(DefaultPowerShellShellID); @@ -493,6 +515,58 @@ internal static string GetApplicationBase(string shellId) return baseDirectory; } + private static string s_packagedMachineDataStorePath; + private static bool s_packagedMachineDataStorePathInitialized; + + /// + /// When running as a packaged MSIX app that opts into the per-machine ApplicationData store + /// (via the 'appdata:MachineFolder' manifest extension), returns the path to that writable + /// per-machine folder. Returns null when the process has no package identity, when the store + /// has not been provisioned by the OS, or on non-Windows platforms. The result is cached so the + /// probe happens at most once per process. + /// + internal static string GetPackagedMachineDataStorePath() + { +#if UNIX + return null; +#else + if (s_packagedMachineDataStorePathInitialized) + { + return s_packagedMachineDataStorePath; + } + + string result = null; + try + { + string packageFamilyName = TryGetCurrentPackageFamilyName(); + if (!string.IsNullOrEmpty(packageFamilyName)) + { + using RegistryKey key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx"); + if (key?.GetValue("PackageRepositoryRoot") is string repositoryRoot && !string.IsNullOrEmpty(repositoryRoot)) + { + // Mirrors how the Windows App SDK locates the per-machine data store. + string path = Path.Combine(repositoryRoot, "Families", "ApplicationData", packageFamilyName, "Machine"); + + // The folder only exists if the OS provisioned the per-machine data store for this package family. + if (Directory.Exists(path)) + { + result = path; + } + } + } + } + catch (Exception ex) when (ex is SecurityException or UnauthorizedAccessException or IOException) + { + // Any failure to read the registry means the store is unavailable; callers fall back to $PSHOME. + result = null; + } + + s_packagedMachineDataStorePath = result; + s_packagedMachineDataStorePathInitialized = true; + return result; +#endif + } + private static string[] s_productFolderDirectories; private static string[] GetProductFolderDirectories() diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 8e5d30be71f..ad8c6a4718e 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -177,7 +177,9 @@ private static string GetAllUsersFolderPath(string shellId) string folderPath = string.Empty; try { - folderPath = Utils.GetApplicationBase(shellId); + // When running as a packaged MSIX app with the per-machine data store provisioned, the + // AllUsers profiles live in that writable store; otherwise they live under $PSHOME. + folderPath = Utils.GetPackagedMachineDataStorePath() ?? Utils.GetApplicationBase(shellId); } catch (System.Security.SecurityException) { diff --git a/src/System.Management.Automation/help/CommandHelpProvider.cs b/src/System.Management.Automation/help/CommandHelpProvider.cs index 15af80745db..5f40860a81a 100644 --- a/src/System.Management.Automation/help/CommandHelpProvider.cs +++ b/src/System.Management.Automation/help/CommandHelpProvider.cs @@ -524,6 +524,14 @@ private string GetHelpFile(string helpFile, CmdletInfo cmdletInfo) else if (cmdletInfo.Module != null && !string.IsNullOrEmpty(cmdletInfo.Module.Path) && !string.IsNullOrEmpty(cmdletInfo.Module.ModuleBase)) { searchPaths.Add(HelpUtils.GetModuleBaseForUserHelp(cmdletInfo.Module.ModuleBase, cmdletInfo.Module.Name)); + + // When running as a packaged app, also search the writable AllUsers help location in the per-machine data store. + string allUsersHelpPath = HelpUtils.GetModuleBaseForAllUsersHelp(cmdletInfo.Module.ModuleBase, cmdletInfo.Module.Name); + if (!string.IsNullOrEmpty(allUsersHelpPath) && !string.Equals(allUsersHelpPath, cmdletInfo.Module.ModuleBase, StringComparison.OrdinalIgnoreCase)) + { + searchPaths.Add(allUsersHelpPath); + } + searchPaths.Add(cmdletInfo.Module.ModuleBase); } else diff --git a/src/System.Management.Automation/help/HelpFileHelpProvider.cs b/src/System.Management.Automation/help/HelpFileHelpProvider.cs index a6c21a3bacc..876fed66438 100644 --- a/src/System.Management.Automation/help/HelpFileHelpProvider.cs +++ b/src/System.Management.Automation/help/HelpFileHelpProvider.cs @@ -353,6 +353,13 @@ internal Collection GetExtendedSearchPaths() // Add the CurrentUser help path. searchPaths.Add(HelpUtils.GetUserHomeHelpSearchPath()); + // Add the AllUsers help path (per-machine data store) when running as a packaged app. + string allUsersHelpPath = HelpUtils.GetAllUsersHomeHelpSearchPath(); + if (!string.IsNullOrEmpty(allUsersHelpPath)) + { + searchPaths.Add(allUsersHelpPath); + } + // Add modules that are not loaded. Since using 'get-module -listavailable' is very expensive, // we load all the directories (which are not empty) under the module path. foreach (string psModulePath in ModuleIntrinsics.GetModulePath(false, this.HelpSystem.ExecutionContext)) diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs index ab4a39f362a..98459a73bef 100644 --- a/src/System.Management.Automation/help/HelpUtils.cs +++ b/src/System.Management.Automation/help/HelpUtils.cs @@ -34,33 +34,69 @@ internal static string GetUserHomeHelpSearchPath() internal static string GetModuleBaseForUserHelp(string moduleBase, string moduleName) { - string newModuleBase = moduleBase; + return GetModuleBaseForHelp(moduleBase, moduleName, GetUserHomeHelpSearchPath()); + } + + private static string allUsersHelpPath = null; + + /// + /// When running as a packaged MSIX app with the per-machine data store provisioned, returns the + /// writable AllUsers help root (under the store); otherwise null. Cached. + /// + internal static string GetAllUsersHomeHelpSearchPath() + { + if (allUsersHelpPath == null) + { + string store = Utils.GetPackagedMachineDataStorePath(); + allUsersHelpPath = string.IsNullOrEmpty(store) ? string.Empty : Path.Combine(store, "Help"); + } + + return string.IsNullOrEmpty(allUsersHelpPath) ? null : allUsersHelpPath; + } + + /// + /// Gets the AllUsers help destination for a module. When running as a packaged app this is under + /// the per-machine data store (writable); otherwise it is the module base (historical behavior). + /// + internal static string GetModuleBaseForAllUsersHelp(string moduleBase, string moduleName) + { + string allUsersRoot = GetAllUsersHomeHelpSearchPath(); + if (string.IsNullOrEmpty(allUsersRoot)) + { + // Not packaged / no store: keep the historical behavior (help goes under moduleBase). + return moduleBase; + } + + return GetModuleBaseForHelp(moduleBase, moduleName, allUsersRoot); + } + + private static string GetModuleBaseForHelp(string moduleBase, string moduleName, string helpRoot) + { + string newModuleBase; // In case of inbox modules, the help is put under $PSHOME/, // since the dlls are not published under individual module folders, but under $PSHome. // In case of other modules, the help is under moduleBase/ or // under moduleBase//. - // The code below creates a similar layout for CurrentUser scope. - // If the scope is AllUsers, then the help goes under moduleBase. + // The code below creates a similar layout under the supplied help root. - var userHelpPath = GetUserHomeHelpSearchPath(); string moduleBaseParent = Directory.GetParent(moduleBase).Name; if (moduleBase.EndsWith(moduleName, StringComparison.OrdinalIgnoreCase)) { - // This module is not an inbox module, so help goes under / - newModuleBase = Path.Combine(userHelpPath, moduleName); + // This module is not an inbox module, so help goes under / + newModuleBase = Path.Combine(helpRoot, moduleName); } else if (string.Equals(moduleBaseParent, moduleName, StringComparison.OrdinalIgnoreCase)) { // This module has version folder. var moduleVersion = Path.GetFileName(moduleBase); - newModuleBase = Path.Combine(userHelpPath, moduleName, moduleVersion); + newModuleBase = Path.Combine(helpRoot, moduleName, moduleVersion); } else { - // This module is inbox module, help should be under - newModuleBase = userHelpPath; + // This module is inbox module, help should be under + newModuleBase = helpRoot; } return newModuleBase; diff --git a/src/System.Management.Automation/help/UpdateHelpCommand.cs b/src/System.Management.Automation/help/UpdateHelpCommand.cs index 9df82699a32..e3f936c4762 100644 --- a/src/System.Management.Automation/help/UpdateHelpCommand.cs +++ b/src/System.Management.Automation/help/UpdateHelpCommand.cs @@ -243,6 +243,12 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module, { moduleBase = HelpUtils.GetModuleBaseForUserHelp(moduleBase, module.ModuleName); } + else + { + // AllUsers: when running as a packaged app, redirect to the writable per-machine data + // store; otherwise this returns the module base (historical behavior). + moduleBase = HelpUtils.GetModuleBaseForAllUsersHelp(moduleBase, module.ModuleName); + } // reading the xml file even if force is specified // Reason: we need the current version for ShouldProcess From 1dcbef379f32b98015e25cb643bc30831a9741d7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 29 Jun 2026 10:58:11 -0500 Subject: [PATCH 03/18] Guard per-machine data store fields with #if !UNIX to fix CS0169 build break The s_packagedMachineDataStorePath and s_packagedMachineDataStorePathInitialized fields are only referenced in the non-UNIX branch of GetPackagedMachineDataStorePath, so they were reported as unused (CS0169) on Linux/macOS where warnings are treated as errors. Wrap their declarations in #if !UNIX to match their usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/System.Management.Automation/engine/Utils.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 0a35bd9ddcc..d81d6ea20bd 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -515,8 +515,10 @@ internal static string GetApplicationBase(string shellId) return baseDirectory; } +#if !UNIX private static string s_packagedMachineDataStorePath; private static bool s_packagedMachineDataStorePathInitialized; +#endif /// /// When running as a packaged MSIX app that opts into the per-machine ApplicationData store From 079e7463d9b99e1b8d94a853e7ac539851631daf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 9 Jul 2026 14:28:48 -0500 Subject: [PATCH 04/18] Remove redundant per-machine store Modules path from PSModulePath AllUsers module installs via PowerShellGet/PSResourceGet already target %ProgramFiles%\PowerShell\Modules (SpecialFolder.ProgramFiles + 'PowerShell'), which is writable under MSIX and independent of $PSHOME. The per-machine data store Modules path (\Modules) was only ever added to PSModulePath for discovery -- nothing installs there, so it stayed an empty, redundant entry. Removing it restores the canonical module path, matching non-packaged and stable builds. Config, profile, and updatable-help redirection to the per-machine store are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../engine/Modules/ModuleIntrinsics.cs | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 9adece3a95c..538c4775f0a 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -1030,16 +1030,6 @@ internal static string GetSharedModulePath() #endif } - /// - /// When running as a packaged MSIX app with the per-machine data store provisioned, gets the - /// writable AllUsers module path located in that store; otherwise null. - /// - internal static string GetMachineStoreModulePath() - { - string store = Utils.GetPackagedMachineDataStorePath(); - return string.IsNullOrEmpty(store) ? null : Path.Combine(store, "Modules"); - } - #if !UNIX /// /// Get the path to the Windows PowerShell module directory under the @@ -1064,17 +1054,23 @@ internal static string GetWindowsPowerShellPSHomeModulePath() /// private static string CombineSystemModulePaths() { - string machineStoreModulePath = GetMachineStoreModulePath(); string psHomeModulePath = GetPSHomeModulePath(); string sharedModulePath = GetSharedModulePath(); - // Order (lowest precedence last): shared (Program Files), per-machine data store, $PSHOME (in-box). - var paths = new List(3); - if (!string.IsNullOrEmpty(sharedModulePath)) { paths.Add(sharedModulePath); } - if (!string.IsNullOrEmpty(machineStoreModulePath)) { paths.Add(machineStoreModulePath); } - if (!string.IsNullOrEmpty(psHomeModulePath)) { paths.Add(psHomeModulePath); } + bool isPSHomePathNullOrEmpty = string.IsNullOrEmpty(psHomeModulePath); + bool isSharedPathNullOrEmpty = string.IsNullOrEmpty(sharedModulePath); + + if (!isPSHomePathNullOrEmpty && !isSharedPathNullOrEmpty) + { + return (sharedModulePath + Path.PathSeparator + psHomeModulePath); + } + + if (!isPSHomePathNullOrEmpty || !isSharedPathNullOrEmpty) + { + return isPSHomePathNullOrEmpty ? sharedModulePath : psHomeModulePath; + } - return paths.Count == 0 ? null : string.Join(Path.PathSeparator, paths); + return null; } internal static string GetExpandedEnvironmentVariable(string name, EnvironmentVariableTarget target) @@ -1259,7 +1255,6 @@ public static string GetModulePath(string currentProcessModulePath, string hklmM currentProcessModulePath = UpdatePath(currentProcessModulePath, personalModulePathToUse, ref insertIndex); currentProcessModulePath = UpdatePath(currentProcessModulePath, sharedModulePath, ref insertIndex); - currentProcessModulePath = UpdatePath(currentProcessModulePath, GetMachineStoreModulePath() ?? string.Empty, ref insertIndex); currentProcessModulePath = UpdatePath(currentProcessModulePath, systemModulePathToUse, ref insertIndex); } @@ -1299,7 +1294,6 @@ internal static string GetWindowsPowerShellModulePath() GetPersonalModulePath(), GetSharedModulePath(), GetPSHomeModulePath(), - GetMachineStoreModulePath(), PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers), PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) }; From 6a1e674c570f1fb7155962dbdecf9bf733fc0788 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 9 Jul 2026 15:53:45 -0500 Subject: [PATCH 05/18] Defer AllUsers updatable-help redirection to a separate PR Reverts the help search/write redirection (HelpUtils, CommandHelpProvider, HelpFileHelpProvider, UpdateHelpCommand) to master, scoping this PR to the config and AllUsers-profile relocation. AllUsers updatable-help redirection to the per-machine store will be handled in a follow-up PR, alongside the WinRM session-config relocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../help/CommandHelpProvider.cs | 8 --- .../help/HelpFileHelpProvider.cs | 7 --- .../help/HelpUtils.cs | 54 ++++--------------- .../help/UpdateHelpCommand.cs | 6 --- 4 files changed, 9 insertions(+), 66 deletions(-) diff --git a/src/System.Management.Automation/help/CommandHelpProvider.cs b/src/System.Management.Automation/help/CommandHelpProvider.cs index 5f40860a81a..15af80745db 100644 --- a/src/System.Management.Automation/help/CommandHelpProvider.cs +++ b/src/System.Management.Automation/help/CommandHelpProvider.cs @@ -524,14 +524,6 @@ private string GetHelpFile(string helpFile, CmdletInfo cmdletInfo) else if (cmdletInfo.Module != null && !string.IsNullOrEmpty(cmdletInfo.Module.Path) && !string.IsNullOrEmpty(cmdletInfo.Module.ModuleBase)) { searchPaths.Add(HelpUtils.GetModuleBaseForUserHelp(cmdletInfo.Module.ModuleBase, cmdletInfo.Module.Name)); - - // When running as a packaged app, also search the writable AllUsers help location in the per-machine data store. - string allUsersHelpPath = HelpUtils.GetModuleBaseForAllUsersHelp(cmdletInfo.Module.ModuleBase, cmdletInfo.Module.Name); - if (!string.IsNullOrEmpty(allUsersHelpPath) && !string.Equals(allUsersHelpPath, cmdletInfo.Module.ModuleBase, StringComparison.OrdinalIgnoreCase)) - { - searchPaths.Add(allUsersHelpPath); - } - searchPaths.Add(cmdletInfo.Module.ModuleBase); } else diff --git a/src/System.Management.Automation/help/HelpFileHelpProvider.cs b/src/System.Management.Automation/help/HelpFileHelpProvider.cs index 876fed66438..a6c21a3bacc 100644 --- a/src/System.Management.Automation/help/HelpFileHelpProvider.cs +++ b/src/System.Management.Automation/help/HelpFileHelpProvider.cs @@ -353,13 +353,6 @@ internal Collection GetExtendedSearchPaths() // Add the CurrentUser help path. searchPaths.Add(HelpUtils.GetUserHomeHelpSearchPath()); - // Add the AllUsers help path (per-machine data store) when running as a packaged app. - string allUsersHelpPath = HelpUtils.GetAllUsersHomeHelpSearchPath(); - if (!string.IsNullOrEmpty(allUsersHelpPath)) - { - searchPaths.Add(allUsersHelpPath); - } - // Add modules that are not loaded. Since using 'get-module -listavailable' is very expensive, // we load all the directories (which are not empty) under the module path. foreach (string psModulePath in ModuleIntrinsics.GetModulePath(false, this.HelpSystem.ExecutionContext)) diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs index 98459a73bef..ab4a39f362a 100644 --- a/src/System.Management.Automation/help/HelpUtils.cs +++ b/src/System.Management.Automation/help/HelpUtils.cs @@ -34,69 +34,33 @@ internal static string GetUserHomeHelpSearchPath() internal static string GetModuleBaseForUserHelp(string moduleBase, string moduleName) { - return GetModuleBaseForHelp(moduleBase, moduleName, GetUserHomeHelpSearchPath()); - } - - private static string allUsersHelpPath = null; - - /// - /// When running as a packaged MSIX app with the per-machine data store provisioned, returns the - /// writable AllUsers help root (under the store); otherwise null. Cached. - /// - internal static string GetAllUsersHomeHelpSearchPath() - { - if (allUsersHelpPath == null) - { - string store = Utils.GetPackagedMachineDataStorePath(); - allUsersHelpPath = string.IsNullOrEmpty(store) ? string.Empty : Path.Combine(store, "Help"); - } - - return string.IsNullOrEmpty(allUsersHelpPath) ? null : allUsersHelpPath; - } - - /// - /// Gets the AllUsers help destination for a module. When running as a packaged app this is under - /// the per-machine data store (writable); otherwise it is the module base (historical behavior). - /// - internal static string GetModuleBaseForAllUsersHelp(string moduleBase, string moduleName) - { - string allUsersRoot = GetAllUsersHomeHelpSearchPath(); - if (string.IsNullOrEmpty(allUsersRoot)) - { - // Not packaged / no store: keep the historical behavior (help goes under moduleBase). - return moduleBase; - } - - return GetModuleBaseForHelp(moduleBase, moduleName, allUsersRoot); - } - - private static string GetModuleBaseForHelp(string moduleBase, string moduleName, string helpRoot) - { - string newModuleBase; + string newModuleBase = moduleBase; // In case of inbox modules, the help is put under $PSHOME/, // since the dlls are not published under individual module folders, but under $PSHome. // In case of other modules, the help is under moduleBase/ or // under moduleBase//. - // The code below creates a similar layout under the supplied help root. + // The code below creates a similar layout for CurrentUser scope. + // If the scope is AllUsers, then the help goes under moduleBase. + var userHelpPath = GetUserHomeHelpSearchPath(); string moduleBaseParent = Directory.GetParent(moduleBase).Name; if (moduleBase.EndsWith(moduleName, StringComparison.OrdinalIgnoreCase)) { - // This module is not an inbox module, so help goes under / - newModuleBase = Path.Combine(helpRoot, moduleName); + // This module is not an inbox module, so help goes under / + newModuleBase = Path.Combine(userHelpPath, moduleName); } else if (string.Equals(moduleBaseParent, moduleName, StringComparison.OrdinalIgnoreCase)) { // This module has version folder. var moduleVersion = Path.GetFileName(moduleBase); - newModuleBase = Path.Combine(helpRoot, moduleName, moduleVersion); + newModuleBase = Path.Combine(userHelpPath, moduleName, moduleVersion); } else { - // This module is inbox module, help should be under - newModuleBase = helpRoot; + // This module is inbox module, help should be under + newModuleBase = userHelpPath; } return newModuleBase; diff --git a/src/System.Management.Automation/help/UpdateHelpCommand.cs b/src/System.Management.Automation/help/UpdateHelpCommand.cs index e3f936c4762..9df82699a32 100644 --- a/src/System.Management.Automation/help/UpdateHelpCommand.cs +++ b/src/System.Management.Automation/help/UpdateHelpCommand.cs @@ -243,12 +243,6 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module, { moduleBase = HelpUtils.GetModuleBaseForUserHelp(moduleBase, module.ModuleName); } - else - { - // AllUsers: when running as a packaged app, redirect to the writable per-machine data - // store; otherwise this returns the module base (historical behavior). - moduleBase = HelpUtils.GetModuleBaseForAllUsersHelp(moduleBase, module.ModuleName); - } // reading the xml file even if force is specified // Reason: we need the current version for ShouldProcess From f0540498a702d80c58e85f7c5cd3ab2bf47dbe29 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 13 Jul 2026 12:55:38 -0500 Subject: [PATCH 06/18] Rework MSIX system-wide config into a distinct machine-folder scope Replaces the redirect-and-seed approach with a distinct ConfigScope.MachineFolder (the writable per-machine data store of a packaged app; absent otherwise). The shipped PSHOME config stays the read-only product defaults; system-wide (AllUsers) writes are redirected to the machine folder and store only the changed keys -- no full seed, so product defaults never go stale. Merge precedence (collapses to the legacy AllUsers/CurrentUser behavior when unpackaged): Policy settings = MachineFolder (admin) > PSHOME (product) > CurrentUser (user), with Group Policy still above all; the GPO/registry lookup skips MachineFolder since it has no registry representation. Preference settings = CurrentUser > MachineFolder > PSHOME. The Microsoft.PowerShell:ExecutionPolicy key is treated as a policy when packaged: LocalMachine resolves MachineFolder then PSHOME and is ordered ahead of CurrentUser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../engine/PSConfiguration.cs | 107 +++++++++--------- .../engine/Utils.cs | 19 +++- .../security/SecuritySupport.cs | 35 ++++-- 3 files changed, 94 insertions(+), 67 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index df5bd12b93b..685e85681f7 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -26,7 +26,14 @@ public enum ConfigScope /// /// CurrentUser configuration applies to the current user. /// - CurrentUser = 1 + CurrentUser = 1, + + /// + /// MachineFolder configuration applies to all users and is stored in the writable per-machine + /// data store of a packaged (MSIX) install. It is only present when running as such a package; + /// otherwise this scope has no backing file. + /// + MachineFolder = 2 } /// @@ -61,10 +68,10 @@ internal sealed class PowerShellConfig private string systemWideConfigFile; private string systemWideConfigDirectory; - // When the system-wide configuration is redirected to the per-machine data store (packaged MSIX), - // this holds the shipped $PSHOME config, used to seed the store on first write and as a read - // fallback until the store copy exists. Null when not redirected. - private string systemWideConfigReadFallbackFile; + // When running as a packaged MSIX app with the per-machine data store provisioned, the path to the + // writable per-machine (admin) 'MachineFolder' config file. Null when not packaged, in which case + // the MachineFolder scope has no backing file and merge orders collapse to the legacy behavior. + private string machineFolderConfigFile; // The json file containing the per-user configuration settings. private readonly string perUserConfigFile; @@ -90,16 +97,15 @@ private PowerShellConfig() systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; systemWideConfigFile = Path.Combine(systemWideConfigDirectory, ConfigFileName); - // When running as a packaged MSIX app that opts into the per-machine data store, redirect the - // system-wide configuration to that writable location so settings such as ExecutionPolicy and - // experimental features can be changed after install. The shipped $PSHOME config is preserved: - // it seeds the store on first write and is used as a read fallback until the store copy exists. + // When running as a packaged MSIX app that opts into the per-machine data store, expose that + // writable location as an additional 'MachineFolder' (admin) configuration scope. The shipped + // $PSHOME config remains the read-only product defaults; system-wide writes are redirected to + // the machine folder and only the changed keys are stored there (no full seed). Policy and + // preference settings merge these scopes with different precedence (see the Utils merge orders). string machineStore = Utils.GetPackagedMachineDataStorePath(); if (!string.IsNullOrEmpty(machineStore)) { - systemWideConfigReadFallbackFile = systemWideConfigFile; - systemWideConfigDirectory = machineStore; - systemWideConfigFile = Path.Combine(machineStore, ConfigFileName); + machineFolderConfigFile = Path.Combine(machineStore, ConfigFileName); } // Sets the per-user configuration directory @@ -112,59 +118,41 @@ private PowerShellConfig() } emptyConfig = new JObject(); - configRoots = new JObject[2]; + configRoots = new JObject[3]; serializer = JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None, MaxDepth = 10 }); fileLock = new ReaderWriterLockSlim(); } - private string GetConfigFilePath(ConfigScope scope, bool forWrite = false) + private string GetConfigFilePath(ConfigScope scope) { - if (scope == ConfigScope.CurrentUser) + switch (scope) { - return perUserConfigFile; + case ConfigScope.CurrentUser: + return perUserConfigFile; + case ConfigScope.MachineFolder: + // Null when not running as a packaged app with the per-machine data store provisioned. + return machineFolderConfigFile; + default: + // AllUsers -> the read-only $PSHOME (product) config. + return systemWideConfigFile; } - - // AllUsers (system-wide). When the config has been redirected to the per-machine data store, - // reads fall back to the shipped $PSHOME config until the store copy exists, so the shipped - // defaults (e.g. ExecutionPolicy, experimental features) are honored. Writes always target the - // store copy (seeded from the shipped config by EnsureSystemConfigSeeded). - if (!forWrite - && systemWideConfigReadFallbackFile != null - && !File.Exists(systemWideConfigFile) - && File.Exists(systemWideConfigReadFallbackFile)) - { - return systemWideConfigReadFallbackFile; - } - - return systemWideConfigFile; } /// - /// When the system-wide configuration has been redirected to the per-machine data store, seed - /// that store with the shipped $PSHOME configuration the first time it is written so the shipped - /// defaults are preserved. Best-effort: requires write access to the store (e.g. an elevated process). + /// Maps a logical write scope to the physical config scope that receives the write. System-wide + /// (AllUsers) writes are redirected to the writable per-machine data store (MachineFolder) when + /// running as a packaged app, so only the changed keys are stored there and the read-only $PSHOME + /// product config is left untouched. All other scopes write in place. /// - private void EnsureSystemConfigSeeded(ConfigScope scope, string targetFile) + private ConfigScope ResolveWriteScope(ConfigScope scope) { - if (scope != ConfigScope.AllUsers - || systemWideConfigReadFallbackFile == null - || File.Exists(targetFile) - || !File.Exists(systemWideConfigReadFallbackFile)) + if (scope == ConfigScope.AllUsers && !string.IsNullOrEmpty(machineFolderConfigFile)) { - return; + return ConfigScope.MachineFolder; } - try - { - Directory.CreateDirectory(systemWideConfigDirectory); - File.Copy(systemWideConfigReadFallbackFile, targetFile); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) - { - // Best-effort: if we lack permission to write to the store (e.g. non-elevated), the write - // below surfaces the access error to the caller, matching the pre-existing $PSHOME behavior. - } + return scope; } /// @@ -186,8 +174,8 @@ internal void SetSystemConfigFilePath(string value) systemWideConfigFile = info.FullName; systemWideConfigDirectory = info.Directory.FullName; - // An explicit settings file overrides any per-machine data store redirection. - systemWideConfigReadFallbackFile = null; + // An explicit settings file overrides the per-machine data store; disable the MachineFolder scope. + machineFolderConfigFile = null; } /// @@ -253,8 +241,14 @@ private static string GetExecutionPolicySettingKey(string shellId) /// internal string[] GetExperimentalFeatures() { + // Preference precedence: CurrentUser > MachineFolder (admin) > AllUsers ($PSHOME product). string[] features = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + if (features.Length == 0) + { + features = ReadValueFromFile(ConfigScope.MachineFolder, "ExperimentalFeatures", Array.Empty()); + } + if (features.Length == 0) { features = ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); @@ -288,6 +282,7 @@ internal void SetExperimentalFeatures(ConfigScope scope, string featureName, boo internal bool IsImplicitWinCompatEnabled() { bool settingValue = ReadValueFromFile(ConfigScope.CurrentUser, DisableImplicitWinCompatKey) + ?? ReadValueFromFile(ConfigScope.MachineFolder, DisableImplicitWinCompatKey) ?? ReadValueFromFile(ConfigScope.AllUsers, DisableImplicitWinCompatKey) ?? false; @@ -297,12 +292,14 @@ internal bool IsImplicitWinCompatEnabled() internal string[] GetWindowsPowerShellCompatibilityModuleDenyList() { return ReadValueFromFile(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityModuleDenyListKey) + ?? ReadValueFromFile(ConfigScope.MachineFolder, WindowsPowerShellCompatibilityModuleDenyListKey) ?? ReadValueFromFile(ConfigScope.AllUsers, WindowsPowerShellCompatibilityModuleDenyListKey); } internal string[] GetWindowsPowerShellCompatibilityNoClobberModuleList() { return ReadValueFromFile(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityNoClobberModuleListKey) + ?? ReadValueFromFile(ConfigScope.MachineFolder, WindowsPowerShellCompatibilityNoClobberModuleListKey) ?? ReadValueFromFile(ConfigScope.AllUsers, WindowsPowerShellCompatibilityNoClobberModuleListKey); } @@ -540,12 +537,12 @@ private void UpdateValueInFile(ConfigScope scope, string key, T value, bool a { try { - string fileName = GetConfigFilePath(scope, forWrite: true); + // Redirect system-wide writes to the writable per-machine data store when packaged, so only + // the changed keys are stored there and the read-only $PSHOME product config is untouched. + scope = ResolveWriteScope(scope); + string fileName = GetConfigFilePath(scope); fileLock.EnterWriteLock(); - // Preserve shipped defaults by seeding the per-machine store from $PSHOME on first write. - EnsureSystemConfigSeeded(scope, fileName); - // Since multiple properties can be in a single file, replacement is required instead of overwrite if a file already exists. // Handling the read and write operations within a single FileStream prevents other processes from reading or writing the file while // the update is in progress. It also locks out readers during write operations. diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index d81d6ea20bd..2320bdd95f2 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -781,10 +781,16 @@ internal static bool IsValidPSEditionValue(string editionValue) /// internal static readonly string ModuleDirectory = Path.Combine(ProductNameForDirectory, "Modules"); - internal static readonly ConfigScope[] SystemWideOnlyConfig = new[] { ConfigScope.AllUsers }; + // Merge orders across the configuration scopes. ConfigScope.MachineFolder is the writable per-machine + // (admin) store of a packaged (MSIX) install; it has no backing file otherwise, so including it here + // is a no-op for non-packaged installs and these orders then collapse to the legacy behavior. + // Policy settings: admin (MachineFolder) > product ($PSHOME / AllUsers) > user (CurrentUser). + // Preference settings: user (CurrentUser) > admin (MachineFolder) > product (AllUsers). + // Note: Group Policy (registry) still takes precedence over all of these; see GetPolicySettingFromGPO. + internal static readonly ConfigScope[] SystemWideOnlyConfig = new[] { ConfigScope.MachineFolder, ConfigScope.AllUsers }; internal static readonly ConfigScope[] CurrentUserOnlyConfig = new[] { ConfigScope.CurrentUser }; - internal static readonly ConfigScope[] SystemWideThenCurrentUserConfig = new[] { ConfigScope.AllUsers, ConfigScope.CurrentUser }; - internal static readonly ConfigScope[] CurrentUserThenSystemWideConfig = new[] { ConfigScope.CurrentUser, ConfigScope.AllUsers }; + internal static readonly ConfigScope[] SystemWideThenCurrentUserConfig = new[] { ConfigScope.MachineFolder, ConfigScope.AllUsers, ConfigScope.CurrentUser }; + internal static readonly ConfigScope[] CurrentUserThenSystemWideConfig = new[] { ConfigScope.CurrentUser, ConfigScope.MachineFolder, ConfigScope.AllUsers }; internal static T GetPolicySetting(ConfigScope[] preferenceOrder) where T : PolicyBase, new() { @@ -1044,6 +1050,13 @@ private static bool TrySetPolicySettingsFromRegistryKey(object instance, Type in foreach (ConfigScope scope in preferenceOrder) { + // MachineFolder is a config-file-only scope (the packaged per-machine data store); it has no + // Group Policy / registry representation, so skip it here and let the config-file lookup handle it. + if (scope == ConfigScope.MachineFolder) + { + continue; + } + if (InternalTestHooks.BypassGroupPolicyCaching) { policy = GetPolicySettingFromGPOImpl(scope); diff --git a/src/System.Management.Automation/security/SecuritySupport.cs b/src/System.Management.Automation/security/SecuritySupport.cs index dc6d048c5b1..f4ab71540b2 100644 --- a/src/System.Management.Automation/security/SecuritySupport.cs +++ b/src/System.Management.Automation/security/SecuritySupport.cs @@ -123,13 +123,27 @@ internal static ExecutionPolicyScope[] ExecutionPolicyScopePreferences { get { - return new ExecutionPolicyScope[] { - ExecutionPolicyScope.MachinePolicy, - ExecutionPolicyScope.UserPolicy, - ExecutionPolicyScope.Process, - ExecutionPolicyScope.CurrentUser, - ExecutionPolicyScope.LocalMachine - }; + // When running as a packaged app with the writable per-machine data store, the system-wide + // execution policy (the machine-folder admin override, then the $PSHOME product default, + // both resolved by the LocalMachine scope) is treated as a policy and takes precedence over + // the current user's preference: MachineFolder > $PSHOME > user. Otherwise the legacy order + // is used, where the current user's preference wins over the system-wide default. + bool packaged = !string.IsNullOrEmpty(Utils.GetPackagedMachineDataStorePath()); + return packaged + ? new ExecutionPolicyScope[] { + ExecutionPolicyScope.MachinePolicy, + ExecutionPolicyScope.UserPolicy, + ExecutionPolicyScope.Process, + ExecutionPolicyScope.LocalMachine, + ExecutionPolicyScope.CurrentUser + } + : new ExecutionPolicyScope[] { + ExecutionPolicyScope.MachinePolicy, + ExecutionPolicyScope.UserPolicy, + ExecutionPolicyScope.Process, + ExecutionPolicyScope.CurrentUser, + ExecutionPolicyScope.LocalMachine + }; } } @@ -478,9 +492,12 @@ private static string GetLocalPreferenceValue(string shellId, ExecutionPolicySco case ExecutionPolicyScope.CurrentUser: return PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.CurrentUser, shellId); - // 2: Look up the system-wide preference + // 2: Look up the system-wide preference. When packaged, the admin machine-folder override + // takes precedence over the $PSHOME product default. When not packaged, MachineFolder has + // no backing file and this falls back to the AllUsers ($PSHOME) config. case ExecutionPolicyScope.LocalMachine: - return PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.AllUsers, shellId); + return PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.MachineFolder, shellId) + ?? PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.AllUsers, shellId); } return null; From 1939cfbbd9feca57079fe29262e2cafeca21fb0c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 13 Jul 2026 12:55:39 -0500 Subject: [PATCH 07/18] Grant Built-in Users read on the MSIX machine-folder data store The appdata:MachineFolder SDDL previously granted only Administrators (A;OICI;GA;;;BA), which also blocked non-admin/non-elevated users from READING the store, so admin-set system-wide config (execution policy, experimental features) never reached standard users. Add an inheritable Built-in Users read ACE (A;OICI;GR;;;BU) so all users can read the config while writes remain admin-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- assets/AppxManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/AppxManifest.xml b/assets/AppxManifest.xml index a2a3de45114..0a9ab629502 100644 --- a/assets/AppxManifest.xml +++ b/assets/AppxManifest.xml @@ -19,7 +19,7 @@ disabled disabled - + From 2fd862feb38e2145c645acd984ee11c00c382321 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 13 Jul 2026 13:32:27 -0500 Subject: [PATCH 08/18] Fix experimental-feature overrides with explicit enable/disable sets The enabled-list approach was fragile under the multi-scope merge: an all-disabled state produced an empty list indistinguishable from 'unset' that fell back to the PSHOME product list (re-enabling everything), and SetExperimentalFeatures recomputed and rewrote the whole merged list into the target scope, which could re-enable a previously disabled feature when disabling another. Store explicit per-feature overrides instead: each scope has an ExperimentalFeatures (enabled) and a DisabledExperimentalFeatures set. GetExperimentalFeatures resolves each feature across scopes (CurrentUser > MachineFolder > AllUsers/PSHOME), defaulting features with no explicit override to the product enabled list, so a newly shipped feature stays on until explicitly disabled. SetExperimentalFeatures reads and writes the same write-resolved scope so disables accumulate and never re-enable an earlier one; enabling a feature clears its disable override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../engine/PSConfiguration.cs | 93 +++++++++++++++---- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 685e85681f7..cf4b7000590 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -56,6 +56,8 @@ internal sealed class PowerShellConfig { private const string ConfigFileName = "powershell.config.json"; private const string ExecutionPolicyDefaultShellKey = "Microsoft.PowerShell:ExecutionPolicy"; + private const string ExperimentalFeaturesKey = "ExperimentalFeatures"; + private const string DisabledExperimentalFeaturesKey = "DisabledExperimentalFeatures"; private const string DisableImplicitWinCompatKey = "DisableImplicitWinCompat"; private const string WindowsPowerShellCompatibilityModuleDenyListKey = "WindowsPowerShellCompatibilityModuleDenyList"; private const string WindowsPowerShellCompatibilityNoClobberModuleListKey = "WindowsPowerShellCompatibilityNoClobberModuleList"; @@ -241,41 +243,92 @@ private static string GetExecutionPolicySettingKey(string shellId) /// internal string[] GetExperimentalFeatures() { - // Preference precedence: CurrentUser > MachineFolder (admin) > AllUsers ($PSHOME product). - string[] features = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); - - if (features.Length == 0) + // Resolve each experimental feature's state across the config scopes using explicit per-feature + // overrides, so that: + // - disables accumulate and never re-enable an earlier one, + // - a newly-shipped ($PSHOME) feature stays at its product default until explicitly overridden, and + // - an all-disabled state is not mistaken for "unset" (which previously fell back to $PSHOME). + // Explicit enable/disable precedence: CurrentUser > MachineFolder (admin) > AllUsers ($PSHOME product). + // A feature with no explicit setting defaults to the product ($PSHOME) enabled list (preview = on). + HashSet userEnabled = ReadFeatureSet(ConfigScope.CurrentUser, ExperimentalFeaturesKey); + HashSet userDisabled = ReadFeatureSet(ConfigScope.CurrentUser, DisabledExperimentalFeaturesKey); + HashSet machineEnabled = ReadFeatureSet(ConfigScope.MachineFolder, ExperimentalFeaturesKey); + HashSet machineDisabled = ReadFeatureSet(ConfigScope.MachineFolder, DisabledExperimentalFeaturesKey); + HashSet productEnabled = ReadFeatureSet(ConfigScope.AllUsers, ExperimentalFeaturesKey); + + var universe = new HashSet(StringComparer.OrdinalIgnoreCase); + universe.UnionWith(userEnabled); + universe.UnionWith(userDisabled); + universe.UnionWith(machineEnabled); + universe.UnionWith(machineDisabled); + universe.UnionWith(productEnabled); + + var effective = new List(universe.Count); + foreach (string feature in universe) { - features = ReadValueFromFile(ConfigScope.MachineFolder, "ExperimentalFeatures", Array.Empty()); + bool enabled; + if (userEnabled.Contains(feature)) { enabled = true; } + else if (userDisabled.Contains(feature)) { enabled = false; } + else if (machineEnabled.Contains(feature)) { enabled = true; } + else if (machineDisabled.Contains(feature)) { enabled = false; } + else { enabled = productEnabled.Contains(feature); } + + if (enabled) { effective.Add(feature); } } - if (features.Length == 0) - { - features = ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); - } + return effective.ToArray(); + } - return features; + private HashSet ReadFeatureSet(ConfigScope scope, string key) + { + return new HashSet( + ReadValueFromFile(scope, key, Array.Empty()), + StringComparer.OrdinalIgnoreCase); } /// - /// Set the enabled list of experimental features in the config file. + /// Record an explicit per-feature override (enable or disable) for an experimental feature. /// /// The ConfigScope of the configuration file to update. /// The name of the experimental feature to change in the configuration. - /// If true, add to configuration; otherwise, remove from configuration. + /// If true, explicitly enable the feature; otherwise explicitly disable it. internal void SetExperimentalFeatures(ConfigScope scope, string featureName, bool setEnabled) { - var features = new List(GetExperimentalFeatures()); - bool containsFeature = features.Contains(featureName); - if (setEnabled && !containsFeature) + // Read and write the same (write-resolved) scope so overrides accumulate. On a packaged app, + // AllUsers writes and reads both resolve to the per-machine data store, so disabling several + // features in a row never re-enables an earlier one, and the shipped $PSHOME product config is + // never rewritten. Enabling a feature clears any disable override for it (and vice versa). + scope = ResolveWriteScope(scope); + + var enabled = new List(ReadValueFromFile(scope, ExperimentalFeaturesKey, Array.Empty())); + var disabled = new List(ReadValueFromFile(scope, DisabledExperimentalFeaturesKey, Array.Empty())); + + List addTo = setEnabled ? enabled : disabled; + List removeFrom = setEnabled ? disabled : enabled; + + bool changed = removeFrom.RemoveAll(f => string.Equals(f, featureName, StringComparison.OrdinalIgnoreCase)) > 0; + if (!addTo.Exists(f => string.Equals(f, featureName, StringComparison.OrdinalIgnoreCase))) + { + addTo.Add(featureName); + changed = true; + } + + if (changed) + { + WriteOrRemoveFeatureSet(scope, ExperimentalFeaturesKey, enabled); + WriteOrRemoveFeatureSet(scope, DisabledExperimentalFeaturesKey, disabled); + } + } + + private void WriteOrRemoveFeatureSet(ConfigScope scope, string key, List features) + { + if (features.Count > 0) { - features.Add(featureName); - WriteValueToFile(scope, "ExperimentalFeatures", features.ToArray()); + WriteValueToFile(scope, key, features.ToArray()); } - else if (!setEnabled && containsFeature) + else { - features.Remove(featureName); - WriteValueToFile(scope, "ExperimentalFeatures", features.ToArray()); + RemoveValueFromFile(scope, key); } } From a6108ccf5917e703979946f59b1a3e2c8e0c7a4e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 12:21:53 -0500 Subject: [PATCH 09/18] Defer experimental features from MachineFolder; restore legacy scope semantics Revert the experimental-feature enable/disable-set redesign and keep experimental features on the legacy config scopes so they behave exactly as on non-packaged installs. Reads use CurrentUser, falling back to AllUsers ($PSHOME); writes no longer redirect to the per-machine data store (allowMachineFolderRedirect: false), so -Scope AllUsers under MSIX fails against the read-only $PSHOME as it does today. This fixes the Get-ExperimentalFeature "user config takes precedence over system config" CI failure, where the redesign merged the system list even when a user config existed. MachineFolder support for experimental features is tracked in #27702. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../engine/PSConfiguration.cs | 110 +++++------------- 1 file changed, 32 insertions(+), 78 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index cf4b7000590..397e95bda28 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -56,8 +56,6 @@ internal sealed class PowerShellConfig { private const string ConfigFileName = "powershell.config.json"; private const string ExecutionPolicyDefaultShellKey = "Microsoft.PowerShell:ExecutionPolicy"; - private const string ExperimentalFeaturesKey = "ExperimentalFeatures"; - private const string DisabledExperimentalFeaturesKey = "DisabledExperimentalFeatures"; private const string DisableImplicitWinCompatKey = "DisableImplicitWinCompat"; private const string WindowsPowerShellCompatibilityModuleDenyListKey = "WindowsPowerShellCompatibilityModuleDenyList"; private const string WindowsPowerShellCompatibilityNoClobberModuleListKey = "WindowsPowerShellCompatibilityNoClobberModuleList"; @@ -243,92 +241,40 @@ private static string GetExecutionPolicySettingKey(string shellId) /// internal string[] GetExperimentalFeatures() { - // Resolve each experimental feature's state across the config scopes using explicit per-feature - // overrides, so that: - // - disables accumulate and never re-enable an earlier one, - // - a newly-shipped ($PSHOME) feature stays at its product default until explicitly overridden, and - // - an all-disabled state is not mistaken for "unset" (which previously fell back to $PSHOME). - // Explicit enable/disable precedence: CurrentUser > MachineFolder (admin) > AllUsers ($PSHOME product). - // A feature with no explicit setting defaults to the product ($PSHOME) enabled list (preview = on). - HashSet userEnabled = ReadFeatureSet(ConfigScope.CurrentUser, ExperimentalFeaturesKey); - HashSet userDisabled = ReadFeatureSet(ConfigScope.CurrentUser, DisabledExperimentalFeaturesKey); - HashSet machineEnabled = ReadFeatureSet(ConfigScope.MachineFolder, ExperimentalFeaturesKey); - HashSet machineDisabled = ReadFeatureSet(ConfigScope.MachineFolder, DisabledExperimentalFeaturesKey); - HashSet productEnabled = ReadFeatureSet(ConfigScope.AllUsers, ExperimentalFeaturesKey); - - var universe = new HashSet(StringComparer.OrdinalIgnoreCase); - universe.UnionWith(userEnabled); - universe.UnionWith(userDisabled); - universe.UnionWith(machineEnabled); - universe.UnionWith(machineDisabled); - universe.UnionWith(productEnabled); - - var effective = new List(universe.Count); - foreach (string feature in universe) + string[] features = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + + if (features.Length == 0) { - bool enabled; - if (userEnabled.Contains(feature)) { enabled = true; } - else if (userDisabled.Contains(feature)) { enabled = false; } - else if (machineEnabled.Contains(feature)) { enabled = true; } - else if (machineDisabled.Contains(feature)) { enabled = false; } - else { enabled = productEnabled.Contains(feature); } - - if (enabled) { effective.Add(feature); } + features = ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); } - return effective.ToArray(); - } - - private HashSet ReadFeatureSet(ConfigScope scope, string key) - { - return new HashSet( - ReadValueFromFile(scope, key, Array.Empty()), - StringComparer.OrdinalIgnoreCase); + return features; } /// - /// Record an explicit per-feature override (enable or disable) for an experimental feature. + /// Set the enabled list of experimental features in the config file. /// /// The ConfigScope of the configuration file to update. /// The name of the experimental feature to change in the configuration. - /// If true, explicitly enable the feature; otherwise explicitly disable it. + /// If true, add to configuration; otherwise, remove from configuration. internal void SetExperimentalFeatures(ConfigScope scope, string featureName, bool setEnabled) { - // Read and write the same (write-resolved) scope so overrides accumulate. On a packaged app, - // AllUsers writes and reads both resolve to the per-machine data store, so disabling several - // features in a row never re-enables an earlier one, and the shipped $PSHOME product config is - // never rewritten. Enabling a feature clears any disable override for it (and vice versa). - scope = ResolveWriteScope(scope); - - var enabled = new List(ReadValueFromFile(scope, ExperimentalFeaturesKey, Array.Empty())); - var disabled = new List(ReadValueFromFile(scope, DisabledExperimentalFeaturesKey, Array.Empty())); - - List addTo = setEnabled ? enabled : disabled; - List removeFrom = setEnabled ? disabled : enabled; - - bool changed = removeFrom.RemoveAll(f => string.Equals(f, featureName, StringComparison.OrdinalIgnoreCase)) > 0; - if (!addTo.Exists(f => string.Equals(f, featureName, StringComparison.OrdinalIgnoreCase))) - { - addTo.Add(featureName); - changed = true; - } - - if (changed) - { - WriteOrRemoveFeatureSet(scope, ExperimentalFeaturesKey, enabled); - WriteOrRemoveFeatureSet(scope, DisabledExperimentalFeaturesKey, disabled); - } - } - - private void WriteOrRemoveFeatureSet(ConfigScope scope, string key, List features) - { - if (features.Count > 0) + // Experimental features intentionally stay on the legacy scopes ($PSHOME for AllUsers) and are not + // redirected to the packaged per-machine data store; MachineFolder support for experimental features + // is deferred to a separate change (see https://github.com/PowerShell/PowerShell/issues/27702). + // This keeps behavior identical to non-packaged installs, so an AllUsers write under MSIX fails + // against the read-only $PSHOME just as it does today. + var features = new List(GetExperimentalFeatures()); + bool containsFeature = features.Contains(featureName); + if (setEnabled && !containsFeature) { - WriteValueToFile(scope, key, features.ToArray()); + features.Add(featureName); + WriteValueToFile(scope, "ExperimentalFeatures", features.ToArray(), allowMachineFolderRedirect: false); } - else + else if (!setEnabled && containsFeature) { - RemoveValueFromFile(scope, key); + features.Remove(featureName); + WriteValueToFile(scope, "ExperimentalFeatures", features.ToArray(), allowMachineFolderRedirect: false); } } @@ -586,13 +532,20 @@ private static FileStream OpenFileStreamWithRetry(string fullPath, FileMode mode /// The string key of the value. /// The value to set. /// Whether the key-value pair should be added to or removed from the file. - private void UpdateValueInFile(ConfigScope scope, string key, T value, bool addValue) + /// When true (default), system-wide (AllUsers) writes are redirected to the writable per-machine data store on a packaged install; pass false to write to the literal scope. + private void UpdateValueInFile(ConfigScope scope, string key, T value, bool addValue, bool allowMachineFolderRedirect = true) { try { // Redirect system-wide writes to the writable per-machine data store when packaged, so only // the changed keys are stored there and the read-only $PSHOME product config is untouched. - scope = ResolveWriteScope(scope); + // Callers that must target the literal scope (e.g. experimental features, whose MachineFolder + // support is deferred) pass allowMachineFolderRedirect: false. + if (allowMachineFolderRedirect) + { + scope = ResolveWriteScope(scope); + } + string fileName = GetConfigFilePath(scope); fileLock.EnterWriteLock(); @@ -690,14 +643,15 @@ private void UpdateValueInFile(ConfigScope scope, string key, T value, bool a /// The ConfigScope of the file to update. /// The string key of the value. /// The value to write. - private void WriteValueToFile(ConfigScope scope, string key, T value) + /// When true (default), system-wide (AllUsers) writes are redirected to the writable per-machine data store on a packaged install; pass false to write to the literal scope. + private void WriteValueToFile(ConfigScope scope, string key, T value, bool allowMachineFolderRedirect = true) { if (scope == ConfigScope.CurrentUser && !Directory.Exists(perUserConfigDirectory)) { Directory.CreateDirectory(perUserConfigDirectory); } - UpdateValueInFile(scope, key, value, true); + UpdateValueInFile(scope, key, value, true, allowMachineFolderRedirect); } /// From 76b45ed53105d8075fb0e61a6a71ff11aa8aebec Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 12:59:13 -0500 Subject: [PATCH 10/18] Scope PR to config merging: drop profile relocation; union WinCompat deny list Per the MSIX machine-folder meeting decisions, narrow this PR to configuration-file merging. - Remove the AllUsers profile relocation to the packaged machine-folder data store. GetAllUsersFolderPath() again resolves to $PSHOME via Utils.GetApplicationBase(shellId) on every install type. Profile relocation is deferred to a dedicated follow-up tracked by #27564. - Merge the Windows PowerShell compatibility module deny list as a policy: the effective list is the union of the CurrentUser, MachineFolder, and AllUsers ($PSHOME) scopes, so deny entries added by a product update in $PSHOME are still honored when a machine or user config also sets the key. The no-clobber list stays a preference (highest-precedence scope wins outright) and is documented as such. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../engine/PSConfiguration.cs | 30 +++++++++++++++++-- .../engine/hostifaces/HostUtilities.cs | 4 +-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 397e95bda28..cdc7a9cd3b2 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -290,13 +290,37 @@ internal bool IsImplicitWinCompatEnabled() internal string[] GetWindowsPowerShellCompatibilityModuleDenyList() { - return ReadValueFromFile(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityModuleDenyListKey) - ?? ReadValueFromFile(ConfigScope.MachineFolder, WindowsPowerShellCompatibilityModuleDenyListKey) - ?? ReadValueFromFile(ConfigScope.AllUsers, WindowsPowerShellCompatibilityModuleDenyListKey); + // The compatibility module deny list is a *policy*: the effective list is the union of the + // entries from every scope (AllUsers/$PSHOME product defaults, the admin MachineFolder, and + // CurrentUser). Unioning - rather than letting the highest-precedence scope that defines the + // key win outright - ensures that new modules added to the deny list by a product update in + // $PSHOME are still honored when a MachineFolder or user config also sets the key. Any scope + // can only add to the deny list; a higher-precedence scope never removes a lower scope's deny. + var denyList = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ConfigScope scope in new[] { ConfigScope.CurrentUser, ConfigScope.MachineFolder, ConfigScope.AllUsers }) + { + string[] values = ReadValueFromFile(scope, WindowsPowerShellCompatibilityModuleDenyListKey); + if (values is not null) + { + denyList.UnionWith(values); + } + } + + if (denyList.Count == 0) + { + return null; + } + + var result = new string[denyList.Count]; + denyList.CopyTo(result); + return result; } internal string[] GetWindowsPowerShellCompatibilityNoClobberModuleList() { + // Unlike the deny list (a policy that unions every scope), the no-clobber list is treated as a + // *preference*: the highest-precedence scope that defines it wins outright, following the + // preference merge order CurrentUser > MachineFolder > AllUsers ($PSHOME). return ReadValueFromFile(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityNoClobberModuleListKey) ?? ReadValueFromFile(ConfigScope.MachineFolder, WindowsPowerShellCompatibilityNoClobberModuleListKey) ?? ReadValueFromFile(ConfigScope.AllUsers, WindowsPowerShellCompatibilityNoClobberModuleListKey); diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index ad8c6a4718e..8e5d30be71f 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -177,9 +177,7 @@ private static string GetAllUsersFolderPath(string shellId) string folderPath = string.Empty; try { - // When running as a packaged MSIX app with the per-machine data store provisioned, the - // AllUsers profiles live in that writable store; otherwise they live under $PSHOME. - folderPath = Utils.GetPackagedMachineDataStorePath() ?? Utils.GetApplicationBase(shellId); + folderPath = Utils.GetApplicationBase(shellId); } catch (System.Security.SecurityException) { From d038853cf5beb419f4d4dced85b5758b69ba13c8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 13:06:01 -0500 Subject: [PATCH 11/18] Keep legacy execution-policy scope order; document policy vs preference Per the machine-folder meeting decision, the execution-policy scope precedence must stay unchanged for backward compatibility and public API stability: the current user's preference continues to win over the system-wide (LocalMachine) preference. Remove the packaged-app reorder that had LocalMachine trump CurrentUser and restore the single legacy order. Add comments distinguishing the policy scopes (MachinePolicy, UserPolicy - Group Policy) from the preference scopes (Process, CurrentUser, LocalMachine). The machine-folder admin override is still honored within the system-wide LocalMachine preference (MachineFolder before $PSHOME) via GetLocalPreferenceValue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../security/SecuritySupport.cs | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/System.Management.Automation/security/SecuritySupport.cs b/src/System.Management.Automation/security/SecuritySupport.cs index f4ab71540b2..2d7f624ee50 100644 --- a/src/System.Management.Automation/security/SecuritySupport.cs +++ b/src/System.Management.Automation/security/SecuritySupport.cs @@ -123,27 +123,33 @@ internal static ExecutionPolicyScope[] ExecutionPolicyScopePreferences { get { - // When running as a packaged app with the writable per-machine data store, the system-wide - // execution policy (the machine-folder admin override, then the $PSHOME product default, - // both resolved by the LocalMachine scope) is treated as a policy and takes precedence over - // the current user's preference: MachineFolder > $PSHOME > user. Otherwise the legacy order - // is used, where the current user's preference wins over the system-wide default. - bool packaged = !string.IsNullOrEmpty(Utils.GetPackagedMachineDataStorePath()); - return packaged - ? new ExecutionPolicyScope[] { - ExecutionPolicyScope.MachinePolicy, - ExecutionPolicyScope.UserPolicy, - ExecutionPolicyScope.Process, - ExecutionPolicyScope.LocalMachine, - ExecutionPolicyScope.CurrentUser - } - : new ExecutionPolicyScope[] { - ExecutionPolicyScope.MachinePolicy, - ExecutionPolicyScope.UserPolicy, - ExecutionPolicyScope.Process, - ExecutionPolicyScope.CurrentUser, - ExecutionPolicyScope.LocalMachine - }; + // Scopes are evaluated in this order and the first one that resolves to a value other + // than Undefined wins. The list intentionally combines policy and preference scopes: + // + // Policy scopes (Group Policy, from the registry) are evaluated first so that an + // administrator's Group Policy always takes precedence: + // - MachinePolicy: machine-wide Group Policy. + // - UserPolicy: per-user Group Policy. + // + // Preference scopes (the PSExecutionPolicyPreference environment variable and the + // per-user / system-wide powershell.config.json files) are evaluated afterwards, and + // the current user's preference intentionally wins over the system-wide preference: + // - Process: the PSExecutionPolicyPreference environment variable. + // - CurrentUser: the current user's config. + // - LocalMachine: the system-wide config. + // + // This order is public behavior and must not change. For a packaged (MSIX) install the + // system-wide execution policy is still read through the LocalMachine scope - it resolves + // from the admin-writable machine-folder data store first and then the $PSHOME default - + // but it remains a preference, so the current user's preference continues to win, keeping + // legacy behavior unchanged. + return new ExecutionPolicyScope[] { + ExecutionPolicyScope.MachinePolicy, + ExecutionPolicyScope.UserPolicy, + ExecutionPolicyScope.Process, + ExecutionPolicyScope.CurrentUser, + ExecutionPolicyScope.LocalMachine + }; } } From cdfe9b742e8872564cb44aea9e9c0f1074ab6f36 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 13:24:09 -0500 Subject: [PATCH 12/18] Clarify that config-based execution policy is a preference, not a policy Per reviewer feedback: despite the "ExecutionPolicy" name, an execution policy set through configuration (the Process, CurrentUser and LocalMachine scopes - the PSExecutionPolicyPreference environment variable or a powershell.config.json file) is a preference. Only Group Policy (MachinePolicy / UserPolicy, from the registry) is a true policy. Make the ExecutionPolicyScopePreferences comment state this explicitly so future maintainers do not reintroduce a machine-trumps-user reorder for the config-backed scopes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../security/SecuritySupport.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/System.Management.Automation/security/SecuritySupport.cs b/src/System.Management.Automation/security/SecuritySupport.cs index 2d7f624ee50..8281a7954ab 100644 --- a/src/System.Management.Automation/security/SecuritySupport.cs +++ b/src/System.Management.Automation/security/SecuritySupport.cs @@ -124,16 +124,23 @@ internal static ExecutionPolicyScope[] ExecutionPolicyScopePreferences get { // Scopes are evaluated in this order and the first one that resolves to a value other - // than Undefined wins. The list intentionally combines policy and preference scopes: + // than Undefined wins. The list intentionally combines policy and preference scopes. + // + // Important: despite the "ExecutionPolicy" name, an execution policy that comes from + // *configuration* (the Process, CurrentUser and LocalMachine scopes below - the + // PSExecutionPolicyPreference environment variable or a powershell.config.json file) is a + // PREFERENCE, not a policy. Only Group Policy (the MachinePolicy and UserPolicy scopes, + // backed by the registry) is a true policy. That distinction is why the policy scopes are + // evaluated first and the config-based preference scopes follow, with the current user's + // preference winning over the system-wide one. // // Policy scopes (Group Policy, from the registry) are evaluated first so that an // administrator's Group Policy always takes precedence: // - MachinePolicy: machine-wide Group Policy. // - UserPolicy: per-user Group Policy. // - // Preference scopes (the PSExecutionPolicyPreference environment variable and the - // per-user / system-wide powershell.config.json files) are evaluated afterwards, and - // the current user's preference intentionally wins over the system-wide preference: + // Preference scopes are evaluated afterwards, and the current user's preference + // intentionally wins over the system-wide preference: // - Process: the PSExecutionPolicyPreference environment variable. // - CurrentUser: the current user's config. // - LocalMachine: the system-wide config. From 844ee343ae7e12d3bb3ee75fedc7d67b6b700b59 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 14:11:16 -0500 Subject: [PATCH 13/18] Extract config merge helpers: MergePolicyList and MergePreferenceValue Factor the two cross-scope config merge strategies out of the individual getters into reusable, documented helpers so future settings can opt into the same behavior without re-implementing it: - MergePolicyList(key): case-insensitive union of a list setting across every scope (policy semantics - entries accumulate, nothing is dropped). - MergePreferenceValue(key): value from the highest-precedence scope that defines the key (preference semantics - a more specific scope wins). Both share s_configScopePreferenceOrder (CurrentUser > MachineFolder > AllUsers/$PSHOME). Route the three existing call sites through them: GetWindowsPowerShellCompatibilityModuleDenyList (policy), GetWindowsPowerShellCompatibilityNoClobberModuleList (preference) and IsImplicitWinCompatEnabled (preference). Behavior is unchanged and was verified at runtime (deny=union, no-clobber=highest-precedence, implicit-WinCompat bool). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../engine/PSConfiguration.cs | 111 ++++++++++++------ 1 file changed, 78 insertions(+), 33 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index cdc7a9cd3b2..11226814594 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -280,50 +280,28 @@ internal void SetExperimentalFeatures(ConfigScope scope, string featureName, boo internal bool IsImplicitWinCompatEnabled() { - bool settingValue = ReadValueFromFile(ConfigScope.CurrentUser, DisableImplicitWinCompatKey) - ?? ReadValueFromFile(ConfigScope.MachineFolder, DisableImplicitWinCompatKey) - ?? ReadValueFromFile(ConfigScope.AllUsers, DisableImplicitWinCompatKey) - ?? false; + // DisableImplicitWinCompat is a preference: the highest-precedence scope that sets it wins. + bool settingValue = MergePreferenceValue(DisableImplicitWinCompatKey) ?? false; return !settingValue; } internal string[] GetWindowsPowerShellCompatibilityModuleDenyList() { - // The compatibility module deny list is a *policy*: the effective list is the union of the - // entries from every scope (AllUsers/$PSHOME product defaults, the admin MachineFolder, and + // The compatibility module deny list is a *policy*: the effective list is the union of every + // scope's entries (the admin MachineFolder, the $PSHOME/AllUsers product defaults, and // CurrentUser). Unioning - rather than letting the highest-precedence scope that defines the - // key win outright - ensures that new modules added to the deny list by a product update in - // $PSHOME are still honored when a MachineFolder or user config also sets the key. Any scope - // can only add to the deny list; a higher-precedence scope never removes a lower scope's deny. - var denyList = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (ConfigScope scope in new[] { ConfigScope.CurrentUser, ConfigScope.MachineFolder, ConfigScope.AllUsers }) - { - string[] values = ReadValueFromFile(scope, WindowsPowerShellCompatibilityModuleDenyListKey); - if (values is not null) - { - denyList.UnionWith(values); - } - } - - if (denyList.Count == 0) - { - return null; - } - - var result = new string[denyList.Count]; - denyList.CopyTo(result); - return result; + // key win outright - ensures that a module added to the deny list by a product update in + // $PSHOME is still honored when a MachineFolder or user config also sets the key. A scope can + // only add to the deny list; a higher-precedence scope never removes a lower scope's deny. + return MergePolicyList(WindowsPowerShellCompatibilityModuleDenyListKey); } internal string[] GetWindowsPowerShellCompatibilityNoClobberModuleList() { - // Unlike the deny list (a policy that unions every scope), the no-clobber list is treated as a - // *preference*: the highest-precedence scope that defines it wins outright, following the - // preference merge order CurrentUser > MachineFolder > AllUsers ($PSHOME). - return ReadValueFromFile(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityNoClobberModuleListKey) - ?? ReadValueFromFile(ConfigScope.MachineFolder, WindowsPowerShellCompatibilityNoClobberModuleListKey) - ?? ReadValueFromFile(ConfigScope.AllUsers, WindowsPowerShellCompatibilityNoClobberModuleListKey); + // Unlike the deny list (a policy that unions every scope), the no-clobber list is a + // *preference*: the highest-precedence scope that defines it wins outright. + return MergePreferenceValue(WindowsPowerShellCompatibilityNoClobberModuleListKey); } /// @@ -464,6 +442,73 @@ internal PSKeyword GetLogKeywords() } #endif // UNIX + /// + /// The order in which configuration scopes are consulted when resolving a *preference* setting: + /// the current user's value wins, then the admin-writable MachineFolder override, then the + /// $PSHOME (AllUsers) product default. Policy lists resolved by + /// union every scope, so the iteration order does not affect them. + /// + private static readonly ConfigScope[] s_configScopePreferenceOrder = new[] + { + ConfigScope.CurrentUser, + ConfigScope.MachineFolder, + ConfigScope.AllUsers + }; + + /// + /// Resolves a *preference* setting: returns the value from the highest-precedence scope that + /// defines (see ), so that a + /// more specific scope overrides a broader one. Returns when no + /// scope defines the key. must be a reference type or a nullable value + /// type so that an unset scope is observable as null. + /// + /// The type of the value. + /// The configuration key to resolve. + /// The value to return when no scope defines the key. + private T MergePreferenceValue(string key, T defaultValue = default) + { + foreach (ConfigScope scope in s_configScopePreferenceOrder) + { + T value = ReadValueFromFile(scope, key); + if (value is not null) + { + return value; + } + } + + return defaultValue; + } + + /// + /// Resolves a *policy* list: returns the case-insensitive union of the string entries defined in + /// every configuration scope for . Use this for list settings that must + /// accumulate across scopes, so that an entry contributed by one scope (for example a product + /// update in $PSHOME) is never dropped because a higher-precedence scope also defines the key. + /// Returns null when no scope contributes an entry. + /// + /// The configuration key to resolve. + private string[] MergePolicyList(string key) + { + var merged = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ConfigScope scope in s_configScopePreferenceOrder) + { + string[] values = ReadValueFromFile(scope, key); + if (values is not null) + { + merged.UnionWith(values); + } + } + + if (merged.Count == 0) + { + return null; + } + + var result = new string[merged.Count]; + merged.CopyTo(result); + return result; + } + /// /// Read a value from the configuration file. /// From a0088ce5e6efdd0b3114ab84338a99727898df43 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 14:17:33 -0500 Subject: [PATCH 14/18] Move config merge rationale into the merge helpers Trim the deny-list and no-clobber getters to a one-line classification and move the "why union" / "why highest-precedence wins" explanations onto MergePolicyList and MergePreferenceValue, where the general merge behavior is defined. Documentation-only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- .../engine/PSConfiguration.cs | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 11226814594..687d499295a 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -288,19 +288,13 @@ internal bool IsImplicitWinCompatEnabled() internal string[] GetWindowsPowerShellCompatibilityModuleDenyList() { - // The compatibility module deny list is a *policy*: the effective list is the union of every - // scope's entries (the admin MachineFolder, the $PSHOME/AllUsers product defaults, and - // CurrentUser). Unioning - rather than letting the highest-precedence scope that defines the - // key win outright - ensures that a module added to the deny list by a product update in - // $PSHOME is still honored when a MachineFolder or user config also sets the key. A scope can - // only add to the deny list; a higher-precedence scope never removes a lower scope's deny. + // The compatibility module deny list is a policy: its entries are unioned across every scope. return MergePolicyList(WindowsPowerShellCompatibilityModuleDenyListKey); } internal string[] GetWindowsPowerShellCompatibilityNoClobberModuleList() { - // Unlike the deny list (a policy that unions every scope), the no-clobber list is a - // *preference*: the highest-precedence scope that defines it wins outright. + // The no-clobber list is a preference: the highest-precedence scope that defines it wins. return MergePreferenceValue(WindowsPowerShellCompatibilityNoClobberModuleListKey); } @@ -458,9 +452,12 @@ internal PSKeyword GetLogKeywords() /// /// Resolves a *preference* setting: returns the value from the highest-precedence scope that /// defines (see ), so that a - /// more specific scope overrides a broader one. Returns when no - /// scope defines the key. must be a reference type or a nullable value - /// type so that an unset scope is observable as null. + /// more specific scope overrides a broader one. Use this for settings that behave like a + /// preference - the current user's choice should take precedence over a system-wide value. + /// In contrast to (which unions every scope), only the winning + /// scope's value is used; lower scopes are ignored once a higher one defines the key. Returns + /// when no scope defines the key. must + /// be a reference type or a nullable value type so that an unset scope is observable as null. /// /// The type of the value. /// The configuration key to resolve. @@ -481,10 +478,12 @@ private T MergePreferenceValue(string key, T defaultValue = default) /// /// Resolves a *policy* list: returns the case-insensitive union of the string entries defined in - /// every configuration scope for . Use this for list settings that must - /// accumulate across scopes, so that an entry contributed by one scope (for example a product - /// update in $PSHOME) is never dropped because a higher-precedence scope also defines the key. - /// Returns null when no scope contributes an entry. + /// every configuration scope for . Use this for list settings that behave + /// like a policy and must accumulate across scopes, so that an entry contributed by one scope + /// (for example a module added to the list by a product update in $PSHOME) is never dropped + /// because a higher-precedence scope also defines the key. Every scope can only add entries; a + /// higher-precedence scope never removes an entry contributed by a lower scope. Returns null when + /// no scope contributes an entry. /// /// The configuration key to resolve. private string[] MergePolicyList(string key) From 401609d9df22f42fcb75d4d4b49bbd8272bd5c15 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 20 Jul 2026 15:12:22 -0500 Subject: [PATCH 15/18] Add xUnit tests for MachineFolder WinCompat config merge Cover the policy-union and preference-precedence merge behavior added for the per-machine data store (MachineFolder) config scope: - WindowsPowerShellCompatibilityModuleDenyList unions across CurrentUser, MachineFolder, and AllUsers scopes (policy), dedups case-insensitively, and returns null when undefined. - WindowsPowerShellCompatibilityNoClobberModuleList resolves to the highest-precedence scope that defines it (preference), including MachineFolder overriding AllUsers. - IsImplicitWinCompatEnabled honors the highest-precedence DisableImplicitWinCompat value. The fixture injects a temp MachineFolder config file via the mutable machineFolderConfigFile field so the 3-scope merge (otherwise null on non-packaged installs) is exercised, and restores it on cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- test/xUnit/csharp/test_PSConfiguration.cs | 223 +++++++++++++++++++++- 1 file changed, 220 insertions(+), 3 deletions(-) diff --git a/test/xUnit/csharp/test_PSConfiguration.cs b/test/xUnit/csharp/test_PSConfiguration.cs index de94107fc6b..940fc1cb5e0 100644 --- a/test/xUnit/csharp/test_PSConfiguration.cs +++ b/test/xUnit/csharp/test_PSConfiguration.cs @@ -36,6 +36,15 @@ public class PowerShellPolicyFixture : IDisposable private readonly bool originalTestHookValue; + // Reflection handle to the private, mutable 'machineFolderConfigFile' field of the PowerShellConfig + // singleton. On non-packaged installs this field is null, so the MachineFolder scope has no backing + // file. Tests point it at a temp file to exercise the 3-scope (CurrentUser/MachineFolder/AllUsers) + // merge behavior used by MSIX installs, and it is restored to its original value on cleanup. + private readonly FieldInfo machineFolderConfigFileField; + private readonly string originalMachineFolderConfigFile; + private readonly string machineFolderTestConfigDirectory; + private readonly string machineFolderTestConfigFile; + public PowerShellPolicyFixture() { systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; @@ -71,6 +80,11 @@ public PowerShellPolicyFixture() }; serializer = JsonSerializer.Create(settings); + machineFolderConfigFileField = typeof(PowerShellConfig).GetField("machineFolderConfigFile", BindingFlags.NonPublic | BindingFlags.Instance); + originalMachineFolderConfigFile = (string)machineFolderConfigFileField.GetValue(PowerShellConfig.Instance); + machineFolderTestConfigDirectory = Path.Combine(Path.GetTempPath(), "PSMachineFolderTest_" + Guid.NewGuid().ToString("N")); + machineFolderTestConfigFile = Path.Combine(machineFolderTestConfigDirectory, ConfigFileName); + systemWidePolicies = new PowerShellPolicies() { ScriptExecution = new ScriptExecution() { ExecutionPolicy = "RemoteSigned", EnableScripts = true }, @@ -106,6 +120,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { CleanupConfigFiles(); + CleanupMachineFolderConfig(); if (systemWideConfigBackupFile != null) { File.Move(systemWideConfigBackupFile, systemWideConfigFile); @@ -387,13 +402,61 @@ private static void CreateBrokenConfigFile(string fileName) File.WriteAllText(fileName, "[abbra"); } + /// + /// Writes powershell.config.json content for the AllUsers ($PSHOME) and CurrentUser scopes and, + /// optionally, provisions a MachineFolder scope backed by a temp file (as happens for MSIX installs + /// where system-wide settings live in an admin-writable per-machine data store). Pass null for a + /// scope to leave it without a config file. The MachineFolder scope is disabled again whenever + /// is null so tests do not leak state to one another. + /// + public void SetupWinCompatConfig(object allUsersConfig, object currentUserConfig, object machineFolderConfig = null) + { + CleanupConfigFiles(); + CleanupMachineFolderConfig(); + + if (allUsersConfig != null) + { + WriteConfigFile(systemWideConfigFile, allUsersConfig); + } + + if (currentUserConfig != null) + { + WriteConfigFile(currentUserConfigFile, currentUserConfig); + } + + if (machineFolderConfig != null) + { + Directory.CreateDirectory(machineFolderTestConfigDirectory); + WriteConfigFile(machineFolderTestConfigFile, machineFolderConfig); + machineFolderConfigFileField.SetValue(PowerShellConfig.Instance, machineFolderTestConfigFile); + } + } + + private void WriteConfigFile(string path, object config) + { + using var streamWriter = new StreamWriter(path); + serializer.Serialize(streamWriter, config); + } + + private void CleanupMachineFolderConfig() + { + // Restore the process-wide singleton field and remove any temp files created for MachineFolder tests. + machineFolderConfigFileField.SetValue(PowerShellConfig.Instance, originalMachineFolderConfigFile); + if (Directory.Exists(machineFolderTestConfigDirectory)) + { + Directory.Delete(machineFolderTestConfigDirectory, recursive: true); + } + } + internal void ForceReadingFromFile() { - // Reset the cached roots. + // Reset the cached roots for every scope (AllUsers, CurrentUser, MachineFolder). FieldInfo roots = typeof(PowerShellConfig).GetField("configRoots", BindingFlags.NonPublic | BindingFlags.Instance); JObject[] value = (JObject[])roots.GetValue(PowerShellConfig.Instance); - value[0] = null; - value[1] = null; + for (int i = 0; i < value.Length; i++) + { + value[i] = null; + } } #endregion @@ -987,5 +1050,159 @@ public void PowerShellConfig_GetPowerShellPolicies_BrokenSystemConfig() Assert.Throws(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.AllUsers)); Assert.Throws(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser)); } + + #region WindowsPowerShellCompatibility_Merge + + // The Windows PowerShell compatibility module deny list is a *policy*: its entries are unioned across + // every config scope so a module added by a product update in $PSHOME (AllUsers) is never dropped + // because a higher-precedence scope (MachineFolder or CurrentUser) also defines the key. + + [Fact, Priority(12)] + public void PowerShellConfig_GetWinCompatModuleDenyList_UnionsAcrossScopes() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "SystemModule" } }, + currentUserConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "UserModule" } }); + fixture.ForceReadingFromFile(); + + string[] denyList = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityModuleDenyList(); + + Assert.NotNull(denyList); + Assert.Equal(2, denyList.Length); + Assert.Contains("SystemModule", denyList); + Assert.Contains("UserModule", denyList); + } + + [Fact, Priority(13)] + public void PowerShellConfig_GetWinCompatModuleDenyList_DeduplicatesCaseInsensitively() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "DuplicateModule" } }, + currentUserConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "duplicatemodule" } }); + fixture.ForceReadingFromFile(); + + string[] denyList = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityModuleDenyList(); + + Assert.NotNull(denyList); + Assert.Single(denyList); + } + + [Fact, Priority(14)] + public void PowerShellConfig_GetWinCompatModuleDenyList_IncludesMachineFolderScope() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "SystemModule" } }, + currentUserConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "UserModule" } }, + machineFolderConfig: new { WindowsPowerShellCompatibilityModuleDenyList = new[] { "MachineModule" } }); + fixture.ForceReadingFromFile(); + + string[] denyList = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityModuleDenyList(); + + Assert.NotNull(denyList); + Assert.Equal(3, denyList.Length); + Assert.Contains("SystemModule", denyList); + Assert.Contains("UserModule", denyList); + Assert.Contains("MachineModule", denyList); + } + + [Fact, Priority(15)] + public void PowerShellConfig_GetWinCompatModuleDenyList_ReturnsNullWhenUndefined() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { ConsolePrompting = true }, + currentUserConfig: new { ConsolePrompting = true }); + fixture.ForceReadingFromFile(); + + string[] denyList = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityModuleDenyList(); + + Assert.Null(denyList); + } + + // The no-clobber module list is a *preference*: only the highest-precedence scope that defines it + // wins (CurrentUser > MachineFolder > AllUsers); lower scopes are ignored once a higher one sets it. + + [Fact, Priority(16)] + public void PowerShellConfig_GetWinCompatNoClobberList_CurrentUserWins() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityNoClobberModuleList = new[] { "SystemModule" } }, + currentUserConfig: new { WindowsPowerShellCompatibilityNoClobberModuleList = new[] { "UserModule" } }); + fixture.ForceReadingFromFile(); + + string[] noClobber = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityNoClobberModuleList(); + + Assert.NotNull(noClobber); + Assert.Single(noClobber); + Assert.Equal("UserModule", noClobber[0]); + } + + [Fact, Priority(17)] + public void PowerShellConfig_GetWinCompatNoClobberList_FallsBackToAllUsers() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityNoClobberModuleList = new[] { "SystemModule" } }, + currentUserConfig: new { ConsolePrompting = true }); + fixture.ForceReadingFromFile(); + + string[] noClobber = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityNoClobberModuleList(); + + Assert.NotNull(noClobber); + Assert.Single(noClobber); + Assert.Equal("SystemModule", noClobber[0]); + } + + [Fact, Priority(18)] + public void PowerShellConfig_GetWinCompatNoClobberList_MachineFolderOverridesAllUsers() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { WindowsPowerShellCompatibilityNoClobberModuleList = new[] { "SystemModule" } }, + currentUserConfig: new { ConsolePrompting = true }, + machineFolderConfig: new { WindowsPowerShellCompatibilityNoClobberModuleList = new[] { "MachineModule" } }); + fixture.ForceReadingFromFile(); + + string[] noClobber = PowerShellConfig.Instance.GetWindowsPowerShellCompatibilityNoClobberModuleList(); + + Assert.NotNull(noClobber); + Assert.Single(noClobber); + Assert.Equal("MachineModule", noClobber[0]); + } + + // DisableImplicitWinCompat is a *preference* bool: IsImplicitWinCompatEnabled() returns the negation + // of the value from the highest-precedence scope that sets it, defaulting to enabled when unset. + + [Fact, Priority(19)] + public void PowerShellConfig_IsImplicitWinCompatEnabled_TrueWhenUndefined() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { ConsolePrompting = true }, + currentUserConfig: new { ConsolePrompting = true }); + fixture.ForceReadingFromFile(); + + Assert.True(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); + } + + [Fact, Priority(20)] + public void PowerShellConfig_IsImplicitWinCompatEnabled_FalseWhenAllUsersDisables() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { DisableImplicitWinCompat = true }, + currentUserConfig: new { ConsolePrompting = true }); + fixture.ForceReadingFromFile(); + + Assert.False(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); + } + + [Fact, Priority(21)] + public void PowerShellConfig_IsImplicitWinCompatEnabled_CurrentUserReEnables() + { + fixture.SetupWinCompatConfig( + allUsersConfig: new { DisableImplicitWinCompat = true }, + currentUserConfig: new { DisableImplicitWinCompat = false }); + fixture.ForceReadingFromFile(); + + Assert.True(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); + } + + #endregion } } From f1f6c6c07fcbc6e459c1a6782eefabe3bf3be4b2 Mon Sep 17 00:00:00 2001 From: PowerShell GitHub Bot Date: Tue, 21 Jul 2026 15:46:10 -0500 Subject: [PATCH 16/18] Fix CodeFactor style issues in MachineFolder WinCompat tests Address 18 CodeFactor findings in the new WinCompat merge tests: - Remove the #region/#endregion wrapper (SA1124). - Split combined [Fact, Priority(N)] attributes onto separate lines (SA1133). - Add missing docs to SetupWinCompatConfig (SA1611). - Make SetupWinCompatConfig internal so a public member no longer follows private members (SA1202). - Drop blank lines between explanatory comments and the following test attribute (SA1512). No behavioral change; all 21 PowerShellPolicyTests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- test/xUnit/csharp/test_PSConfiguration.cs | 42 +++++++++++++---------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/test/xUnit/csharp/test_PSConfiguration.cs b/test/xUnit/csharp/test_PSConfiguration.cs index 940fc1cb5e0..4a1f47b48d4 100644 --- a/test/xUnit/csharp/test_PSConfiguration.cs +++ b/test/xUnit/csharp/test_PSConfiguration.cs @@ -409,7 +409,10 @@ private static void CreateBrokenConfigFile(string fileName) /// scope to leave it without a config file. The MachineFolder scope is disabled again whenever /// is null so tests do not leak state to one another. /// - public void SetupWinCompatConfig(object allUsersConfig, object currentUserConfig, object machineFolderConfig = null) + /// Object serialized to the AllUsers ($PSHOME) config file, or null to leave that scope unconfigured. + /// Object serialized to the CurrentUser config file, or null to leave that scope unconfigured. + /// Object serialized to a temp MachineFolder config file injected into the singleton, or null to leave the MachineFolder scope disabled. + internal void SetupWinCompatConfig(object allUsersConfig, object currentUserConfig, object machineFolderConfig = null) { CleanupConfigFiles(); CleanupMachineFolderConfig(); @@ -1051,13 +1054,11 @@ public void PowerShellConfig_GetPowerShellPolicies_BrokenSystemConfig() Assert.Throws(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser)); } - #region WindowsPowerShellCompatibility_Merge - // The Windows PowerShell compatibility module deny list is a *policy*: its entries are unioned across // every config scope so a module added by a product update in $PSHOME (AllUsers) is never dropped // because a higher-precedence scope (MachineFolder or CurrentUser) also defines the key. - - [Fact, Priority(12)] + [Fact] + [Priority(12)] public void PowerShellConfig_GetWinCompatModuleDenyList_UnionsAcrossScopes() { fixture.SetupWinCompatConfig( @@ -1073,7 +1074,8 @@ public void PowerShellConfig_GetWinCompatModuleDenyList_UnionsAcrossScopes() Assert.Contains("UserModule", denyList); } - [Fact, Priority(13)] + [Fact] + [Priority(13)] public void PowerShellConfig_GetWinCompatModuleDenyList_DeduplicatesCaseInsensitively() { fixture.SetupWinCompatConfig( @@ -1087,7 +1089,8 @@ public void PowerShellConfig_GetWinCompatModuleDenyList_DeduplicatesCaseInsensit Assert.Single(denyList); } - [Fact, Priority(14)] + [Fact] + [Priority(14)] public void PowerShellConfig_GetWinCompatModuleDenyList_IncludesMachineFolderScope() { fixture.SetupWinCompatConfig( @@ -1105,7 +1108,8 @@ public void PowerShellConfig_GetWinCompatModuleDenyList_IncludesMachineFolderSco Assert.Contains("MachineModule", denyList); } - [Fact, Priority(15)] + [Fact] + [Priority(15)] public void PowerShellConfig_GetWinCompatModuleDenyList_ReturnsNullWhenUndefined() { fixture.SetupWinCompatConfig( @@ -1120,8 +1124,8 @@ public void PowerShellConfig_GetWinCompatModuleDenyList_ReturnsNullWhenUndefined // The no-clobber module list is a *preference*: only the highest-precedence scope that defines it // wins (CurrentUser > MachineFolder > AllUsers); lower scopes are ignored once a higher one sets it. - - [Fact, Priority(16)] + [Fact] + [Priority(16)] public void PowerShellConfig_GetWinCompatNoClobberList_CurrentUserWins() { fixture.SetupWinCompatConfig( @@ -1136,7 +1140,8 @@ public void PowerShellConfig_GetWinCompatNoClobberList_CurrentUserWins() Assert.Equal("UserModule", noClobber[0]); } - [Fact, Priority(17)] + [Fact] + [Priority(17)] public void PowerShellConfig_GetWinCompatNoClobberList_FallsBackToAllUsers() { fixture.SetupWinCompatConfig( @@ -1151,7 +1156,8 @@ public void PowerShellConfig_GetWinCompatNoClobberList_FallsBackToAllUsers() Assert.Equal("SystemModule", noClobber[0]); } - [Fact, Priority(18)] + [Fact] + [Priority(18)] public void PowerShellConfig_GetWinCompatNoClobberList_MachineFolderOverridesAllUsers() { fixture.SetupWinCompatConfig( @@ -1169,8 +1175,8 @@ public void PowerShellConfig_GetWinCompatNoClobberList_MachineFolderOverridesAll // DisableImplicitWinCompat is a *preference* bool: IsImplicitWinCompatEnabled() returns the negation // of the value from the highest-precedence scope that sets it, defaulting to enabled when unset. - - [Fact, Priority(19)] + [Fact] + [Priority(19)] public void PowerShellConfig_IsImplicitWinCompatEnabled_TrueWhenUndefined() { fixture.SetupWinCompatConfig( @@ -1181,7 +1187,8 @@ public void PowerShellConfig_IsImplicitWinCompatEnabled_TrueWhenUndefined() Assert.True(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); } - [Fact, Priority(20)] + [Fact] + [Priority(20)] public void PowerShellConfig_IsImplicitWinCompatEnabled_FalseWhenAllUsersDisables() { fixture.SetupWinCompatConfig( @@ -1192,7 +1199,8 @@ public void PowerShellConfig_IsImplicitWinCompatEnabled_FalseWhenAllUsersDisable Assert.False(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); } - [Fact, Priority(21)] + [Fact] + [Priority(21)] public void PowerShellConfig_IsImplicitWinCompatEnabled_CurrentUserReEnables() { fixture.SetupWinCompatConfig( @@ -1202,7 +1210,5 @@ public void PowerShellConfig_IsImplicitWinCompatEnabled_CurrentUserReEnables() Assert.True(PowerShellConfig.Instance.IsImplicitWinCompatEnabled()); } - - #endregion } } From bfd0cf913277578b5645e2b9c8ce30b7026164be Mon Sep 17 00:00:00 2001 From: PowerShell GitHub Bot Date: Tue, 21 Jul 2026 15:53:31 -0500 Subject: [PATCH 17/18] Reorder test helpers so internal member precedes private members CodeFactor still flagged SetupWinCompatConfig (internal) as following a private member (SA1202). Move it and its private helpers below the existing internal ForceReadingFromFile so an internal member no longer comes after a private one. Pure reordering; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- test/xUnit/csharp/test_PSConfiguration.cs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/xUnit/csharp/test_PSConfiguration.cs b/test/xUnit/csharp/test_PSConfiguration.cs index 4a1f47b48d4..c7ae166a421 100644 --- a/test/xUnit/csharp/test_PSConfiguration.cs +++ b/test/xUnit/csharp/test_PSConfiguration.cs @@ -402,6 +402,17 @@ private static void CreateBrokenConfigFile(string fileName) File.WriteAllText(fileName, "[abbra"); } + internal void ForceReadingFromFile() + { + // Reset the cached roots for every scope (AllUsers, CurrentUser, MachineFolder). + FieldInfo roots = typeof(PowerShellConfig).GetField("configRoots", BindingFlags.NonPublic | BindingFlags.Instance); + JObject[] value = (JObject[])roots.GetValue(PowerShellConfig.Instance); + for (int i = 0; i < value.Length; i++) + { + value[i] = null; + } + } + /// /// Writes powershell.config.json content for the AllUsers ($PSHOME) and CurrentUser scopes and, /// optionally, provisions a MachineFolder scope backed by a temp file (as happens for MSIX installs @@ -451,17 +462,6 @@ private void CleanupMachineFolderConfig() } } - internal void ForceReadingFromFile() - { - // Reset the cached roots for every scope (AllUsers, CurrentUser, MachineFolder). - FieldInfo roots = typeof(PowerShellConfig).GetField("configRoots", BindingFlags.NonPublic | BindingFlags.Instance); - JObject[] value = (JObject[])roots.GetValue(PowerShellConfig.Instance); - for (int i = 0; i < value.Length; i++) - { - value[i] = null; - } - } - #endregion } From eb4046899ba3b0b946efa04be0aec26bcaeb8997 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 22 Jul 2026 09:46:44 -0500 Subject: [PATCH 18/18] Read PackageRepositoryRoot from 64-bit registry view Address PR review: Registry.LocalMachine.OpenSubKey is subject to WOW64 redirection, so a 32-bit pwsh on 64-bit Windows would read WOW6432Node and miss PackageRepositoryRoot. Open the LocalMachine base key with RegistryView.Registry64 on 64-bit systems (Default on 32-bit), matching the existing pattern in RemoteSessionHyperVSocket. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b --- src/System.Management.Automation/engine/Utils.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 2320bdd95f2..520e86c2edb 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -543,7 +543,13 @@ internal static string GetPackagedMachineDataStorePath() string packageFamilyName = TryGetCurrentPackageFamilyName(); if (!string.IsNullOrEmpty(packageFamilyName)) { - using RegistryKey key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx"); + // Use the 64-bit registry view on 64-bit systems so 'PackageRepositoryRoot' is found + // regardless of process architecture; otherwise a 32-bit pwsh on 64-bit Windows would be + // redirected by WOW64 to the WOW6432Node view where the OS does not write this value. + using RegistryKey baseKey = RegistryKey.OpenBaseKey( + RegistryHive.LocalMachine, + Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Default); + using RegistryKey key = baseKey.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx"); if (key?.GetValue("PackageRepositoryRoot") is string repositoryRoot && !string.IsNullOrEmpty(repositoryRoot)) { // Mirrors how the Windows App SDK locates the per-machine data store.