Skip to content

Store MSIX machine-wide configuration in a secure ProgramData directory - #27535

Open
Justin Chung (jshigetomi) wants to merge 17 commits into
masterfrom
relocSystemConfig
Open

Store MSIX machine-wide configuration in a secure ProgramData directory#27535
Justin Chung (jshigetomi) wants to merge 17 commits into
masterfrom
relocSystemConfig

Conversation

@jshigetomi

@jshigetomi Justin Chung (jshigetomi) commented May 26, 2026

Copy link
Copy Markdown
Collaborator

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:

%ProgramData%\Microsoft\PowerShell\<PackageFamilyName>\powershell.config.json

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.json remains 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

  • Add an internal MachineFolder configuration scope backed by the PFN-isolated ProgramData path.
  • Redirect ordinary AllUsers writes to MachineFolder only when the process has Windows package identity.
  • Keep AllUsers reads backed by $PSHOME so shipped product defaults remain available.
  • Preserve the existing current-user configuration path.
  • Keep MSI, ZIP, unpackaged Windows, Linux, and macOS behavior unchanged.
  • Resolve an explicit MachineFolder write to the legacy AllUsers location 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:MachineFolder manifest 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:

  • Preferences: CurrentUser > MachineFolder > $PSHOME (AllUsers)
    • ExecutionPolicy from JSON configuration
    • DisableImplicitWinCompat
    • WindowsPowerShellCompatibilityNoClobberModuleList
  • Policy lists: case-insensitive union across CurrentUser, MachineFolder, and $PSHOME
    • WindowsPowerShellCompatibilityModuleDenyList

Registry-backed Group Policy remains above JSON configuration. The public execution-policy order is unchanged:

MachinePolicy > UserPolicy > Process > CurrentUser > LocalMachine

For an MSIX installation, Set-ExecutionPolicy -Scope LocalMachine writes 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:

  • Owner: Built-in Administrators
  • SYSTEM: Full Control
  • Built-in Administrators: Full Control
  • Built-in Users: Read and Execute
  • The PowerShell root and PFN directory do not inherit ambient ProgramData permissions
  • Files and subdirectories inside the PFN directory inherit these rules

The 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:

  • Reparse points
  • Inherited ACLs on the PowerShell root or PFN directory
  • A protected ACL on the configuration file instead of inheritance from PFN
  • Non-administrative ownership
  • Write-capable allow rules for identities other than Administrators or SYSTEM

The 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-PowerShellConfiguration cmdlet plan previously described in this PR.

The scope of this PR is the MSIX machine-wide powershell.config.json problem only. Related work remains tracked separately:

PR Checklist

@jshigetomi Justin Chung (jshigetomi) added the CL-Engine Indicates that a PR should be marked as an engine change in the Change Log label May 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-PowerShellConfiguration cmdlet (and PowerShellConfigurationInfo output type) to report config file paths for AllUsers/CurrentUser.
  • Updated configuration path resolution to use /etc/powershell (Unix) or %ProgramData%\Microsoft\PowerShell (Windows), while retaining a legacy $PSHOME fallback 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);
            }

Comment thread test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 Outdated
Comment thread test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Comment thread src/System.Management.Automation/CoreCLR/CorePsPlatform.cs
Comment thread test/xUnit/csharp/test_PSConfiguration.cs Outdated
Comment thread test/xUnit/csharp/test_PSConfiguration.cs Outdated
Comment thread test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 Outdated
Comment thread test/powershell/engine/Help/HelpSystem.Tests.ps1
Comment thread src/System.Management.Automation/engine/PSConfiguration.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Comment thread src/System.Management.Automation/engine/PSConfiguration.cs Outdated
Comment thread test/tools/Modules/HelpersCommon/HelpersCommon.psm1 Outdated
Comment thread test/tools/Modules/HelpersCommon/HelpersCommon.psm1 Outdated
Comment thread test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 Outdated
Comment thread test/xUnit/csharp/test_PSConfiguration.cs Outdated
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

Copy link
Copy Markdown
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
@jshigetomi Justin Chung (jshigetomi) changed the title Move AllUsers config out of $PSHOME and add Get-PowerShellConfiguration cmdlet Store MSIX machine-wide configuration in a secure ProgramData directory Jul 30, 2026
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Justin Chung added 2 commits July 31, 2026 11:35
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() throws new IOException(path) for a reparse point, which loses the security context of the failure (and is inconsistent with ValidateMachineFolderConfigPath throwing UnauthorizedAccessException). Throwing UnauthorizedAccessException (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-volatile s_packagedMachineDataStorePathInitialized flag. Under concurrent calls, another thread can observe Initialized == true before the s_packagedMachineDataStorePath write is visible, causing a transient null return (disabling MachineFolder path resolution in a packaged process). Use Volatile.Read/Write (or Lazy<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 like desktop.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() throws new 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 throwing UnauthorizedAccessException (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);
            }

@jshigetomi
Justin Chung (jshigetomi) marked this pull request as ready for review August 3, 2026 18:40
@jshigetomi
Justin Chung (jshigetomi) requested a review from a team as a code owner August 3, 2026 18:40
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Justin Chung and others added 2 commits August 4, 2026 10:42
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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this API available in Windows Servers? It might only be for desktop experience only.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1773 to +1777
#if UNIX
return null;
#else
return Utils.GetCurrentPackageFamilyName();
#endif

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems Utils.GetCurrentPackageFamilyName() already has #if UNIX.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-Engine Indicates that a PR should be marked as an engine change in the Change Log

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Some cmdlets and config require writing to $PSHOME\SessionConfig folder

3 participants