MSIX.AppIsolation.ps1

# =============================================================================
# Win32 App Isolation
# -----------------------------------------------------------------------------
# Adds the rescap capabilities + iso namespace that turn a regular MSIX-packaged
# Win32 app into an "isolated" one. The isolation feature provides an OS-level
# sandbox with broker-mediated access to filesystem, devices, and protected APIs.
#
# Reference:
# https://learn.microsoft.com/windows/win32/secauthz/app-isolation-overview
# https://learn.microsoft.com/windows/win32/secauthz/app-isolation-supported-capabilities
#
# Important: this is OPT-IN. Most MSIX packages should NOT enable isolation —
# many legacy apps will break because they rely on broad filesystem/registry
# access. Use this only after validating the app under isolation manually.
#
# Minimum runtime: Windows 11 24H2 (build 26100) or later.
# =============================================================================

# Documented isolated-app capabilities.
# Source: https://learn.microsoft.com/windows/win32/secauthz/app-isolation-supported-capabilities
# Key = capability name; Value = short description for Get-MsixIsolationCapability output.
$script:KnownIsolationCapabilities = [ordered]@{
    # ── On the MS Learn page (documented and validated) ───────────────────
    'isolatedWin32-print'                      = 'Print via the Win32 printing infrastructure'
    'isolatedWin32-sysTrayIcon'                = 'Display notifications from the system tray'
    'isolatedWin32-shellExtensionContextMenu'  = 'Display COM-based context menu entries'
    'isolatedWin32-promptForAccess'            = 'Prompt users for file access at runtime'
    'isolatedWin32-accessToPublisherDirectory' = 'Access directories ending with the publisher ID'
    # Minimal-access group (for apps that cannot use prompting):
    'isolatedWin32-dotNetBreadcrumbStore'      = 'Minimal access to the .NET breadcrumb store'
    'isolatedWin32-profilesRootMinimal'        = 'Minimal access to the profiles root'
    'isolatedWin32-userProfileMinimal'         = 'Minimal access to the user profile'
    'isolatedWin32-volumeRootMinimal'          = 'Minimal access to the volume root'
    # ── Extended capabilities used in practice (pre-dating the MS Learn page) ─
    'isolatedWin32-accessFromLowIntegrityLevel' = 'Allow access from low-integrity-level processes'
    'isolatedWin32-userProfile'                = 'Full user profile access'
    'isolatedWin32-printDocumentsFolder'       = 'Access to the print documents folder'
    'isolatedWin32-printDocumentsContents'     = 'Access to print document contents'
    'isolatedWin32-fullFileSystemAccess'       = 'Full file system access'
    'isolatedWin32-allowElevation'             = 'Allow elevation'
    'isolatedWin32-attachToHostInterop'        = 'Attach to the host process for interop'
    'isolatedWin32-internetClient'             = 'Outbound internet access'
    'isolatedWin32-internetClientServer'       = 'Inbound and outbound internet access'
    'isolatedWin32-privateNetworkClientServer' = 'Home/work network access'
    'isolatedWin32-bluetooth'                  = 'Bluetooth access'
    'isolatedWin32-networking'                 = 'General networking access'
    'isolatedWin32-removableStorage'           = 'Removable storage access'
}

# Device capabilities supported under Win32 app isolation.
# These use <DeviceCapability Name="…"/> in the default namespace — NOT rescap:Capability.
# Source: UWP capabilities section of the MS Learn page above.
$script:KnownIsolationDeviceCapabilities = [ordered]@{
    'microphone' = 'Access to the microphone audio feed'
    'webcam'     = 'Access to built-in camera or external webcam video feed'
}

function Get-MsixIsolationCapability {
    <#
    .SYNOPSIS
        Returns the set of well-known Win32-app-isolation capabilities the module
        is aware of. Use this list to decide what to pass into Add-MsixAppIsolation.
 
    .DESCRIPTION
        Returns one object per capability with the following properties:
          Name — the string to pass to Add-MsixAppIsolation -Capabilities.
          ElementType — 'rescap:Capability' (isolatedWin32-*) or 'DeviceCapability'
                        (microphone, webcam). Add-MsixAppIsolation picks the correct
                        XML element automatically.
          Description — short human-readable summary from the MS Learn page.
 
    .OUTPUTS
        [pscustomobject] with Name, ElementType, Description.
    #>

    foreach ($entry in $script:KnownIsolationCapabilities.GetEnumerator()) {
        [pscustomobject]@{
            Name        = $entry.Key
            ElementType = 'rescap:Capability'
            Description = $entry.Value
        }
    }
    foreach ($entry in $script:KnownIsolationDeviceCapabilities.GetEnumerator()) {
        [pscustomobject]@{
            Name        = $entry.Key
            ElementType = 'DeviceCapability'
            Description = $entry.Value
        }
    }
}


function Add-MsixAppIsolation {
    <#
    .SYNOPSIS
        Enables Win32 App Isolation on an MSIX package: sets the uap18 isolation
        attributes on each <Application>, declares the requested isolated-Win32
        capabilities, and reconciles the runFullTrust capability.
 
    .DESCRIPTION
        Adding an isolatedWin32-* capability alone does NOT isolate an app — the
        isolation is switched on by the uap18 attributes on <Application>. This
        cmdlet writes the full set the MS Learn guidance requires:
 
          - Declares the `uap18` and `rescap` namespaces (and adds them to
            IgnorableNamespaces) if absent.
          - On every <Application> (or just -AppId), sets:
                EntryPoint="Windows.FullTrustApplication"
                uap18:EntryPoint="Isolated.App"
                uap18:TrustLevel="appContainer"
                uap18:RuntimeBehavior="appSilo"
          - Adds one <rescap:Capability>/<DeviceCapability> per -Capabilities.
          - If the package has a COM context-menu (windows.comServer /
            FileExplorerContextMenus), auto-adds
            `isolatedWin32-shellExtensionContextMenu` so the menu runs under
            isolation.
          - Ensures runFullTrust (see below).
          - Bumps MaxVersionTested to 10.0.26100.0, and RAISES the Windows.Desktop
            TargetDeviceFamily MinVersion to 10.0.26100.0. The MinVersion bump is
            mandatory: isolation only engages when the package targets 24H2, so
            this also means the package will no longer install on older Windows.
 
        runFullTrust: an isolated app keeps EntryPoint="Windows.FullTrustApplication"
        (the down-level entry point), and the AppxManifest schema REQUIRES the
        runFullTrust capability for that entry point — MakeAppx rejects the
        package without it (error 80080204). runFullTrust and isolation are
        therefore NOT mutually exclusive; they are required together. Isolation
        is enforced by the uap18 appContainer/appSilo attributes, not by the
        absence of runFullTrust.
          - Default: ENSURE runFullTrust is present (add if missing) and log why.
          - -RemoveRunFullTrust: force-remove it. Warns that the repack will fail
            on toolchains that still require it for the FullTrust entry point.
          - -KeepRunFullTrust: retain it silently (the default already keeps it;
            this just suppresses the explanatory note).
 
        Repacks and re-signs the package.
 
        WARNING: this is opt-in. Many existing MSIX packages will break under
        isolation because the app expects broad filesystem/registry access.
        Validate with the Application Capability Profiler (ACP) first:
        https://github.com/microsoft/win32-app-isolation/releases
 
        NOTE: Packages built with the Package Support Framework (PSF) — i.e. whose
        Application Executable is PsfLauncher*.exe — CANNOT be isolated. PSF
        injects fixup DLLs into the target process, which AppContainer blocks, and
        PSF/isolation have opposite goals. This cmdlet warns when it detects a PSF
        launcher; the produced package will not actually run isolated. Re-package
        without PSF first.
 
    .PARAMETER PackagePath
        .msix file to modify.
 
    .PARAMETER Capabilities
        Capabilities to add. Defaults to a conservative starter set:
        promptForAccess + accessFromLowIntegrityLevel.
 
    .PARAMETER AppId
        Restrict the uap18 isolation attributes to the Application with this Id.
        Default: every <Application> in the package.
 
    .PARAMETER RemoveRunFullTrust
        Force-remove the runFullTrust capability even when a blocking extension
        (e.g. firewallRules) is present.
 
    .PARAMETER KeepRunFullTrust
        Never remove the runFullTrust capability.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package.
 
    .PARAMETER Pfx / PfxPassword
        Signing certificate.
 
    .EXAMPLE
        Add-MsixAppIsolation -PackagePath app.msix `
            -Capabilities 'isolatedWin32-promptForAccess','isolatedWin32-userProfileMinimal' `
            -Pfx cert.pfx -PfxPassword 'P@ss'
 
    .EXAMPLE
        # A packaged Win32 app whose only full-trust reason is its shell
        # context menu: isolation keeps the menu via the isolation capability
        # and drops runFullTrust automatically.
        Add-MsixAppIsolation -PackagePath npp.msix -SkipSigning
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AppId',
        Justification = 'Captured by the -Mutate scriptblock passed to _MsixMutateManifest.')]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [string[]]$Capabilities = @(
            'isolatedWin32-promptForAccess',
            'isolatedWin32-accessFromLowIntegrityLevel'
        ),
        [string]$AppId,
        [switch]$RemoveRunFullTrust,
        [switch]$KeepRunFullTrust,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if ($RemoveRunFullTrust -and $KeepRunFullTrust) {
        throw '-RemoveRunFullTrust and -KeepRunFullTrust are mutually exclusive.'
    }

    foreach ($c in $Capabilities) {
        $knownIsolated = $script:KnownIsolationCapabilities.Contains($c)
        $knownDevice   = $script:KnownIsolationDeviceCapabilities.Contains($c)
        if (-not $knownIsolated -and -not $knownDevice) {
            Write-MsixLog -Level Warning -Message "'$c' is not in the documented capability set. Verify against MS Learn before publishing."
        }
    }

    $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Add App Isolation')

    _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
        -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
        -WhatIfPreview:$isWhatIf `
        -Activity 'Add App Isolation' -Mutate {
        param([xml]$manifest)

        Add-MsixManifestNamespace -Manifest $manifest -Prefix 'uap18'
        Add-MsixManifestNamespace -Manifest $manifest -Prefix 'rescap'
        # Win32 App Isolation requires Win11 24H2 (build 26100)
        Set-MsixManifestMaxVersionTested -Manifest $manifest -MinBuild 26100

        # The Windows.Desktop TargetDeviceFamily MinVersion MUST be raised to
        # 10.0.26100.0. Isolation only engages when the package *targets* 24H2:
        # uap18 lives in IgnorableNamespaces, so with a down-level MinVersion the
        # deployment stack treats the package as a legacy full-trust app and
        # ignores the appContainer/appSilo attributes even on a 26100 host. This
        # raises the minimum OS — the package will no longer install before 24H2.
        $isoMin = [version]'10.0.26100.0'
        $desktopTdf = @($manifest.Package.Dependencies.TargetDeviceFamily) |
            Where-Object { $_.GetAttribute('Name') -eq 'Windows.Desktop' }
        if (-not $desktopTdf) {
            Write-MsixLog -Level Warning -Message 'No Windows.Desktop TargetDeviceFamily found; cannot raise MinVersion. Win32 App Isolation requires a Windows.Desktop target at MinVersion 10.0.26100.0.'
        }
        foreach ($tdf in $desktopTdf) {
            $cur = $null
            $parsed = [version]::TryParse($tdf.GetAttribute('MinVersion'), [ref]$cur)
            if (-not $parsed -or $cur -lt $isoMin) {
                $tdf.SetAttribute('MinVersion', '10.0.26100.0')
                Write-MsixLog -Level Warning -Message "TargetDeviceFamily 'Windows.Desktop' MinVersion raised to 10.0.26100.0 (required for Win32 App Isolation). The package will no longer install on Windows older than 24H2."
            }
        }

        $uap18Uri  = Get-MsixManifestNamespaceUri -Prefix 'uap18'
        $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap'

        # ── Application isolation attributes ──────────────────────────────────
        # These are what actually enable isolation; the capability alone does not.
        $apps = @($manifest.Package.Applications.Application)
        if ($AppId) {
            $apps = @($apps | Where-Object { $_.GetAttribute('Id') -eq $AppId })
            if (-not $apps) { throw "Application '$AppId' not found in the manifest." }
        }
        if (-not $apps) { throw 'No <Application> element found in the manifest.' }

        foreach ($app in $apps) {
            # Package Support Framework is fundamentally incompatible with Win32
            # App Isolation. PSF's launcher injects fixup DLLs into the target
            # process (cross-process injection), which AppContainer/appSilo
            # blocks — and the two have opposite goals (PSF widens access for
            # compat; isolation restricts it). A PSF package will NOT isolate:
            # Windows runs it as a normal full-trust Win32 app. Warn loudly.
            $exe = $app.GetAttribute('Executable')
            if ($exe -match 'PsfLauncher\d*\.exe$') {
                Write-MsixLog -Level Warning -Message "Application '$($app.GetAttribute('Id'))' launches via the Package Support Framework (Executable '$exe'). PSF and Win32 App Isolation are mutually exclusive: PSF injects fixup DLLs into the target process, which AppContainer/appSilo blocks — this package will NOT run isolated (Windows runs it as a normal full-trust Win32 app). Re-package without PSF (point Executable at the real .exe and drop the fixups: PsfLauncher*/PsfRuntime*/FileRedirectionFixup*/config.json) before isolating."
            }

            # Base entry point stays Windows.FullTrustApplication (down-level OS
            # ignores the uap18 attrs and runs the app as a normal Win32 app).
            $app.SetAttribute('EntryPoint', 'Windows.FullTrustApplication')

            foreach ($pair in @(
                @{ Name = 'EntryPoint';      Value = 'Isolated.App'  },
                @{ Name = 'TrustLevel';      Value = 'appContainer'  },
                @{ Name = 'RuntimeBehavior'; Value = 'appSilo'       }
            )) {
                # Idempotent: drop any existing uap18:<name> before re-adding so
                # re-runs don't duplicate, and a CreateAttribute(prefix,...) keeps
                # the serialized prefix deterministic (uap18:).
                $old = $app.GetAttributeNode($pair.Name, $uap18Uri)
                if ($old) { $null = $app.Attributes.Remove($old) }
                $attr = $manifest.CreateAttribute('uap18', $pair.Name, $uap18Uri)
                $attr.Value = $pair.Value
                $null = $app.Attributes.Append($attr)
            }
            $idForLog = $app.GetAttribute('Id')
            Write-MsixLog -Level Info -Message "Isolation attributes set on Application '$idForLog' (TrustLevel=appContainer, RuntimeBehavior=appSilo)."
        }

        # ── Capabilities block ────────────────────────────────────────────────
        $capsNode = $manifest.Package.Capabilities
        if (-not $capsNode) {
            $capsNode = $manifest.CreateElement('Capabilities', $manifest.Package.NamespaceURI)
            $null     = $manifest.Package.AppendChild($capsNode)
        }

        # If the package surfaces a COM-based shell context menu, it needs the
        # isolation-native capability (the replacement for runFullTrust for that
        # extension). Auto-include it so the menu survives isolation.
        $wantedCaps = [System.Collections.Generic.List[string]]::new()
        foreach ($c in $Capabilities) { $wantedCaps.Add($c) }
        $hasComServer = $null -ne $manifest.SelectSingleNode("//*[local-name()='Extension' and @Category='windows.comServer']")
        $hasCtxMenu   = $null -ne $manifest.SelectSingleNode("//*[local-name()='FileExplorerContextMenus']")
        if (($hasComServer -or $hasCtxMenu) -and -not $wantedCaps.Contains('isolatedWin32-shellExtensionContextMenu')) {
            $wantedCaps.Add('isolatedWin32-shellExtensionContextMenu')
            Write-MsixLog -Level Info -Message 'COM context-menu detected: auto-adding isolatedWin32-shellExtensionContextMenu (isolation-native replacement for runFullTrust).'
        }

        foreach ($cap in $wantedCaps) {
            # Device capabilities use <DeviceCapability> (default namespace);
            # isolatedWin32-* capabilities use <rescap:Capability>.
            $isDeviceCap = $script:KnownIsolationDeviceCapabilities.Contains($cap)
            $targetLocal = if ($isDeviceCap) { 'DeviceCapability' } else { 'Capability' }

            $alreadyThere = $false
            foreach ($child in $capsNode.ChildNodes) {
                if ($child.LocalName -eq $targetLocal -and $child.GetAttribute('Name') -eq $cap) {
                    $alreadyThere = $true
                    break
                }
            }
            if ($alreadyThere) {
                Write-MsixLog -Level Info -Message "Capability already present: $cap"
                continue
            }

            if ($isDeviceCap) {
                $node = $manifest.CreateElement('DeviceCapability', $manifest.Package.NamespaceURI)
            } else {
                $node = $manifest.CreateElement('rescap:Capability', $rescapUri)
            }
            $node.SetAttribute('Name', $cap)
            $null = $capsNode.AppendChild($node)
            Write-MsixLog -Level Info -Message "Capability added: $cap"
        }

        # ── runFullTrust reconciliation ───────────────────────────────────────
        # The isolated app retains EntryPoint="Windows.FullTrustApplication" (the
        # down-level entry point that lets it still run as a normal Win32 app on
        # OSes without isolation support). The AppxManifest schema REQUIRES the
        # runFullTrust capability for that entry point — MakeAppx rejects the
        # package without it (error 80080204). So by default we ENSURE
        # runFullTrust is present and explain why; the app is still isolated via
        # the uap18 appContainer/appSilo attributes, and a COM context menu runs
        # via isolatedWin32-shellExtensionContextMenu. -RemoveRunFullTrust forces
        # removal for toolchains/runtimes that accept the isolated entry point
        # without it (the repack will fail on toolchains that don't).
        $rftNode = $null
        foreach ($child in $capsNode.ChildNodes) {
            if ($child.LocalName -eq 'Capability' -and $child.GetAttribute('Name') -eq 'runFullTrust') {
                $rftNode = $child
                break
            }
        }
        if ($RemoveRunFullTrust) {
            if ($rftNode) { $null = $capsNode.RemoveChild($rftNode) }
            Write-MsixLog -Level Warning -Message 'Removed runFullTrust (-RemoveRunFullTrust). NOTE: EntryPoint="Windows.FullTrustApplication" normally requires it and MakeAppx may reject the repack (error 80080204) unless your packaging toolchain/runtime supports the isolated entry point without runFullTrust.'
        }
        else {
            if (-not $rftNode) {
                $rftNode = $manifest.CreateElement('rescap:Capability', $rescapUri)
                $rftNode.SetAttribute('Name', 'runFullTrust')
                $null = $capsNode.AppendChild($rftNode)
            }
            if ($KeepRunFullTrust) {
                Write-MsixLog -Level Info -Message 'runFullTrust retained (-KeepRunFullTrust).'
            } else {
                Write-MsixLog -Level Info -Message 'runFullTrust retained: required by EntryPoint="Windows.FullTrustApplication" (the down-level entry point the isolated app keeps). Isolation is enforced by uap18 appContainer/appSilo; a COM context menu runs via isolatedWin32-shellExtensionContextMenu. Pass -RemoveRunFullTrust only if your toolchain supports dropping it.'
            }
        }
    }
}


function Remove-MsixAppIsolation {
    <#
    .SYNOPSIS
        Reverses Add-MsixAppIsolation: strips every `isolatedWin32-*` capability
        and the uap18 isolation attributes (TrustLevel / RuntimeBehavior /
        uap18:EntryPoint) from each <Application>.
 
    .DESCRIPTION
        Removes the uap18 isolation attributes so the app no longer runs in an
        AppContainer silo, and deletes the isolatedWin32-* capabilities. The base
        EntryPoint="Windows.FullTrustApplication" is left intact. runFullTrust is
        NOT re-added — its original presence can't be inferred, so re-add it
        explicitly with Add-MsixCapability if the app needs it.
 
    .PARAMETER PackagePath
        .msix file to modify.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package.
 
    .PARAMETER Pfx / PfxPassword
        Signing certificate.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    PROCESS {
        # Quick pre-check: does the package have isolation caps OR uap18 attrs?
        $preCheck  = Get-MsixManifest -Path $PackagePath
        $uap18Uri  = Get-MsixManifestNamespaceUri -Prefix 'uap18'
        $hasCaps = @($preCheck.Package.Capabilities.ChildNodes) |
            Where-Object { $_.LocalName -eq 'Capability' -and $_.Name -like 'isolatedWin32-*' }
        $hasAttrs = @($preCheck.Package.Applications.Application) |
            Where-Object { $_.GetAttributeNode('TrustLevel', $uap18Uri) -or $_.GetAttributeNode('RuntimeBehavior', $uap18Uri) }
        if (-not $hasCaps -and -not $hasAttrs) {
            Write-MsixLog -Level Info -Message 'No isolation capabilities or attributes found; nothing to do.'
            return
        }

        $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Remove App Isolation')

        _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
            -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
            -WhatIfPreview:$isWhatIf `
            -Activity 'Remove App Isolation' -Mutate {
            param([xml]$manifest)

            $u18 = Get-MsixManifestNamespaceUri -Prefix 'uap18'

            # Strip the uap18 isolation attributes from every Application.
            foreach ($app in @($manifest.Package.Applications.Application)) {
                foreach ($local in 'EntryPoint', 'TrustLevel', 'RuntimeBehavior') {
                    $node = $app.GetAttributeNode($local, $u18)
                    if ($node) {
                        $null = $app.Attributes.Remove($node)
                        Write-MsixLog -Level Info -Message "Removed uap18:$local from Application '$($app.GetAttribute('Id'))'."
                    }
                }
            }

            # Strip isolatedWin32-* capabilities.
            $capsNode = $manifest.Package.Capabilities
            if ($capsNode) {
                foreach ($n in @($capsNode.ChildNodes)) {
                    if ($n.LocalName -eq 'Capability' -and $n.Name -like 'isolatedWin32-*') {
                        $null = $capsNode.RemoveChild($n)
                        Write-MsixLog -Level Info -Message "Removed: $($n.Name)"
                    }
                }
            }
        }
    }
}


# Backward-compatible plural aliases
Set-Alias Get-MsixIsolationCapabilities Get-MsixIsolationCapability