Store MSIX machine-wide configuration in a secure ProgramData directory - #27535
Store MSIX machine-wide configuration in a secure ProgramData directory#27535Justin Chung (jshigetomi) wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR relocates the AllUsers powershell.config.json from $PSHOME to a platform-specific system configuration directory and introduces a new Get-PowerShellConfiguration cmdlet to surface the resolved config file locations.
Changes:
- Added
Get-PowerShellConfigurationcmdlet (andPowerShellConfigurationInfooutput type) to report config file paths forAllUsers/CurrentUser. - Updated configuration path resolution to use
/etc/powershell(Unix) or%ProgramData%\Microsoft\PowerShell(Windows), while retaining a legacy$PSHOMEfallback for reads. - Updated packaging scripts and tests to reflect new system/user config directory concepts.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/packaging/packaging.strings.psd1 | Ensures /etc/powershell is created with appropriate permissions during package install. |
| test/xUnit/csharp/test_PSConfiguration.cs | Switches test fixture to new system/user config directories. |
| test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 | Adds coverage for the new cmdlet output and path behavior. |
| test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 | Updates system config path assumptions to the new AllUsers location. |
| test/powershell/engine/ExperimentalFeature/EnableDisable-ExperimentalFeature.Tests.ps1 | Updates AllUsers config path assumptions for enable/disable tests. |
| test/powershell/engine/Basic/DefaultCommands.Tests.ps1 | Adds Get-PowerShellConfiguration to the default cmdlet list validation. |
| src/System.Management.Automation/engine/Utils.cs | Adds an internal test hook (TestAllUsersConfigDirectory) to override AllUsers config directory. |
| src/System.Management.Automation/engine/PSConfigurationCommand.cs | Implements Get-PowerShellConfiguration and defines PowerShellConfigurationInfo. |
| src/System.Management.Automation/engine/PSConfiguration.cs | Moves AllUsers config path to system directory; adds legacy $PSHOME fallback for reads; creates system directory on write. |
| src/System.Management.Automation/engine/InitialSessionState.cs | Registers the new cmdlet in the core cmdlet table. |
| src/System.Management.Automation/engine/hostifaces/HostUtilities.cs | Updates profile base path to use Platform.UserConfigDirectory. |
| src/System.Management.Automation/CoreCLR/CorePsPlatform.cs | Introduces UserConfigDirectory and SystemConfigDirectory (and XDG_Type.SYSTEM_CONFIG on Unix). |
Comments suppressed due to low confidence (1)
test/xUnit/csharp/test_PSConfiguration.cs:48
- The fixture now uses Platform.SystemConfigDirectory for the system-wide config path, which resolves to /etc/powershell on Unix. This test then moves/creates powershell.config.json in that location without ensuring the directory exists or that the test process has permissions, which will fail (and can also pollute real system state). Consider setting InternalTestHooks.TestAllUsersConfigDirectory (or calling PowerShellConfig.SetSystemConfigFilePath) to redirect AllUsers config to a temp/test directory for the duration of these tests.
systemWideConfigDirectory = Platform.SystemConfigDirectory;
currentUserConfigDirectory = Platform.UserConfigDirectory;
if (!Directory.Exists(currentUserConfigDirectory))
{
// Create the CurrentUser config directory if it doesn't exist
Directory.CreateDirectory(currentUserConfigDirectory);
}
Keep immutable product defaults in PSHOME while resolving writable machine configuration from ProgramData, isolated by package family for MSIX. Apply secure Windows ACLs, preserve policy and preference merge semantics, and remove Get-PowerShellConfiguration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
Remove the experimental-feature mutation cmdlets while preserving discovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Keep Enable-ExperimentalFeature and Disable-ExperimentalFeature aligned with master while retaining the MSIX configuration changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Keep PSHOME as immutable product defaults while resolving the writable PFN-isolated ProgramData path in Utils. Redirect AllUsers writes, preserve policy and preference merge semantics, and secure the manually provisioned hierarchy without changing non-MSIX or experimental-feature behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Resolve an explicit MachineFolder write to the legacy AllUsers scope on Unix before selecting a path or cache entry. Compile MachineFolder directory provisioning only for Windows and cover the Unix fallback with a configuration test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Store package-family configuration directly beneath the PowerShell ProgramData directory. Keep the PowerShell and PFN directories as protected ACL boundaries while allowing the configuration file and future children to inherit the PFN permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (3)
test/xUnit/csharp/test_PSConfiguration.cs:92
- The test fixture assumes PowerShellConfig has a private instance field named 'machineFolderConfigFile'. If that field is renamed/removed, GetField() returns null and the next line throws a NullReferenceException without context, making failures hard to diagnose. Add an explicit null check and fail with a clear message.
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);
src/System.Management.Automation/engine/PSConfiguration.cs:652
- UpdateValueInFile() can resolve to ConfigScope.MachineFolder even when it has no backing file (machineFolderConfigFile is null), e.g. if an internal caller passes MachineFolder explicitly on a non-packaged Windows install. In that case GetConfigFilePath() returns null and FileStream/OpenOrCreate will throw (and EnsureMachineFolderConfigDirectory will also throw on Path.GetDirectoryName(null)). Guard against a null/empty path and throw a clear InvalidOperationException instead of failing later with ArgumentNullException/NullReferenceException.
// Resolve the physical write scope before selecting a path. On Unix, MachineFolder falls
// back to the legacy AllUsers location. On Windows, callers that must target the literal
// AllUsers scope pass allowMachineFolderRedirect: false.
scope = ResolveWriteScope(
scope,
machineFolderAvailable: !string.IsNullOrEmpty(machineFolderConfigFile),
allowMachineFolderRedirect: allowMachineFolderRedirect);
string fileName = GetConfigFilePath(scope);
fileLock.EnterWriteLock();
#if !UNIX
if (scope == ConfigScope.MachineFolder)
{
EnsureMachineFolderConfigDirectory();
if (useDefaultMachineFolderConfigDirectory && File.Exists(fileName))
src/System.Management.Automation/engine/Utils.cs:537
- The cached MSIX machine data store path uses a non-volatile boolean flag for initialization. Without a memory barrier, another thread can observe 'initialized == true' before the path assignment is visible and return null unexpectedly. Mark the flag as volatile or use Lazy/Interlocked to make the initialization thread-safe.
private static string s_packagedMachineDataStorePath;
private static bool s_packagedMachineDataStorePathInitialized;
#endif
Remove the trivial path-composition overload and unit tests. Expose package identity through InternalTestHooks and add an elevated MSIX-gated Pester test that verifies the real LocalMachine ProgramData write and inherited ACL boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Keep xUnit focused on pure three-scope merge and union behavior. Move the real LocalMachine write, removal, and ACL checks into the existing elevated execution-policy Pester suite, where the test runs only with MSIX package identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/System.Management.Automation/engine/PSConfiguration.cs:853
EnsureMachineFolderConfigFileSecurity()throwsnew IOException(path)for a reparse point, which loses the security context of the failure (and is inconsistent withValidateMachineFolderConfigPaththrowingUnauthorizedAccessException). ThrowingUnauthorizedAccessException(or adding a clear message) would make failures more actionable.
file.Refresh();
if ((file.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new IOException(path);
}
src/System.Management.Automation/engine/Utils.cs:562
GetPackagedMachineDataStorePath()uses a non-volatiles_packagedMachineDataStorePathInitializedflag. Under concurrent calls, another thread can observeInitialized == truebefore thes_packagedMachineDataStorePathwrite is visible, causing a transient null return (disabling MachineFolder path resolution in a packaged process). UseVolatile.Read/Write(orLazy<T>) to publish the computed path safely.
if (s_packagedMachineDataStorePathInitialized)
{
return s_packagedMachineDataStorePath;
}
test/powershell/Modules/Microsoft.PowerShell.Security/ExecutionPolicy.Tests.ps1:1276
- Test cleanup removes directories without
-Recurse, so if the package-family directory (or the root) is non-empty (e.g., OS-created files likedesktop.ini, or future additional machine-folder artifacts), cleanup will fail and the test can leave behind protected ProgramData directories. Remove the PFN directory with-Recurse, and only remove the root when it’s empty.
if (-not $machineConfigDirectoryExisted) {
Remove-Item -LiteralPath $machineConfigDirectory -Force -ErrorAction SilentlyContinue
}
if (-not $powerShellConfigDirectoryExisted) {
src/System.Management.Automation/engine/PSConfiguration.cs:810
EnsureDirectoryWithSecurity()throwsnew IOException(path)when encountering a reparse point, which produces a low-signal exception message (just the path) and makes it harder to diagnose why machine-folder configuration was rejected. Consider throwingUnauthorizedAccessException(as the validation methods do) or providing a descriptive message.
This issue also appears on line 849 of the same file.
directory.Refresh();
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new IOException(path);
}
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab738c1b-4d18-491b-a40f-0515b586857b
| private const int AppModelErrorNoPackage = 15700; | ||
|
|
||
| [DllImport("kernel32.dll", EntryPoint = "GetCurrentPackageFamilyName", CharSet = CharSet.Unicode)] | ||
| private static extern int GetCurrentPackageFamilyNameNative(ref uint packageFamilyNameLength, [Out] StringBuilder packageFamilyName); |
There was a problem hiding this comment.
Is this API available in Windows Servers? It might only be for desktop experience only.
There was a problem hiding this comment.
Docs say "desktop apps only" (starting with Windows 8/Windows Server 2012).
Also please use LibraryImport instead of old DllImport. We have many examples in pwsh code for this.
| // Exclude ambient ProgramData permissions while allowing the explicit rules below | ||
| // to flow to files and subdirectories created inside the PFN directory. | ||
| security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); | ||
| security.SetOwner(administrators); |
There was a problem hiding this comment.
Make sure users who run this are still able to create and write this folder.
| private const int AppModelErrorNoPackage = 15700; | ||
|
|
||
| [DllImport("kernel32.dll", EntryPoint = "GetCurrentPackageFamilyName", CharSet = CharSet.Unicode)] | ||
| private static extern int GetCurrentPackageFamilyNameNative(ref uint packageFamilyNameLength, [Out] StringBuilder packageFamilyName); |
There was a problem hiding this comment.
Docs say "desktop apps only" (starting with Windows 8/Windows Server 2012).
Also please use LibraryImport instead of old DllImport. We have many examples in pwsh code for this.
| return null; | ||
| } | ||
|
|
||
| if (result != ErrorInsufficientBuffer || length == 0) |
There was a problem hiding this comment.
No need to call GetCurrentPackageFamilyNameNative twice.
Docs say that packageFamilyName max length (PACKAGE_FAMILY_NAME_MAX_LENGTH) is 64 without null terminator.
So we can stackalloc buffer for packageFamilyName.
| #if UNIX | ||
| return null; | ||
| #else | ||
| return Utils.GetCurrentPackageFamilyName(); | ||
| #endif |
There was a problem hiding this comment.
It seems Utils.GetCurrentPackageFamilyName() already has #if UNIX.
PR Summary
This pull request gives Windows MSIX-packaged PowerShell a secure, writable machine-wide configuration layer without changing the legacy configuration locations for MSI, ZIP, Linux, or macOS installations.
When PowerShell is running with MSIX package identity, machine-wide configuration changes are stored at:
PowerShell obtains the package family name with
GetCurrentPackageFamilyName(). The package-family directory isolates Stable, Preview, and LTS packages when they use different package identities.The shipped
$PSHOME\powershell.config.jsonremains the immutable product-defaults layer. PowerShell does not copy or seed that complete file into ProgramData; only administrator-modified keys are written to the new file.Main changes
Add an MSIX-only machine configuration layer
MachineFolderconfiguration scope backed by the PFN-isolated ProgramData path.AllUserswrites toMachineFolderonly when the process has Windows package identity.AllUsersreads backed by$PSHOMEso shipped product defaults remain available.MachineFolderwrite to the legacyAllUserslocation on Unix and exclude MachineFolder provisioning code from Unix builds.This implementation composes the ProgramData path directly. It does not depend on the Windows App SDK
appdata:MachineFoldermanifest extension or a WindowsApps-backed machine-data path.Merge product, administrator, and user configuration
Configuration settings updated by this PR use one of two merge models:
CurrentUser > MachineFolder > $PSHOME (AllUsers)ExecutionPolicyfrom JSON configurationDisableImplicitWinCompatWindowsPowerShellCompatibilityNoClobberModuleList$PSHOMEWindowsPowerShellCompatibilityModuleDenyListRegistry-backed Group Policy remains above JSON configuration. The public execution-policy order is unchanged:
For an MSIX installation,
Set-ExecutionPolicy -Scope LocalMachinewrites to the ProgramData override. Reads check the override before falling back to$PSHOME, and removing the LocalMachine value targets the same redirected file.Provision and validate a secure ProgramData path
On the first redirected write, PowerShell creates or secures the PowerShell root and PFN directory with protected, inheritable ACLs:
SYSTEM: Full ControlThe directory boundaries are protected so a broad or attacker-controlled parent ACL cannot flow into the machine configuration path. Protection is not repeated on the configuration file; it inherits from the PFN directory.
PowerShell also validates the path before trusting an existing machine configuration file. It rejects:
SYSTEMThe ProgramData configuration is outside the MSIX package, so it survives package upgrades and ordinary uninstall/reinstall operations.
PR Context
This summary supersedes the original broad cross-platform relocation and
Get-PowerShellConfigurationcmdlet plan previously described in this PR.The scope of this PR is the MSIX machine-wide
powershell.config.jsonproblem only. Related work remains tracked separately:PR Checklist
.h,.cpp,.cs,.ps1and.psm1files have the correct copyright header