MSIX.AppIsolation.ps1

# =============================================================================
# App isolation (AppContainer / Win32 App Isolation)
# -----------------------------------------------------------------------------
# Drops a packaged Win32 app into an AppContainer: the isolation switch is the
# partial-trust entry point (EntryPoint="Windows.PartialTrustApplication") +
# TrustLevel="appContainer" with runFullTrust REMOVED. Two modes:
# AppContainer (default) — GA packagedClassicApp (Win10 2004 / 19041+),
# ungranted access is DENIED.
# AppSilo — preview Win32 App Isolation silo (Win11 24H2 /
# 26100+): uap18 attrs + isolatedWin32-* broker
# capabilities, access is PROMPTED (consent).
#
# Reference:
# https://learn.microsoft.com/windows/msix/msix-container
# 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. Validate with Test-MsixIsolation / Get-MsixIsolationAdvice.
# =============================================================================

# 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. These apply to Add-MsixAppIsolation -Mode AppSilo ONLY.
 
    .DESCRIPTION
        Returns one object per capability with the following properties:
          Name — the string to pass to Add-MsixAppIsolation -Mode AppSilo
                        -Capabilities. (In the default -Mode AppContainer,
                        -Capabilities takes STANDARD package capability names
                        instead — see Get-MsixKnownCapability — and
                        isolatedWin32-* names are ignored with a warning.)
          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.
        .EXAMPLE
        # List the AppSilo capability vocabulary
        Get-MsixIsolationCapability | Format-Table Name, 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
        Makes a packaged Win32 app run isolated (in an AppContainer) by switching
        the Application to the partial-trust entry point, declaring the AppContainer
        trust level, and removing runFullTrust.
 
    .DESCRIPTION
        The isolation boundary is the AppContainer TrustLevel. To actually drop a
        packaged Win32 app into an AppContainer you must (per the MSIX
        AppContainer guidance, https://learn.microsoft.com/windows/msix/msix-container):
          1. set Application EntryPoint to "Windows.PartialTrustApplication"
             (the full-trust entry point hard-requires runFullTrust, which keeps
             the process full-trust — so it can never isolate);
          2. declare TrustLevel="appContainer"; and
          3. REMOVE the runFullTrust capability.
        This cmdlet does all three. (The earlier capability-only / FullTrust
        approach never isolated — the app stayed full-trust.)
 
        Two modes (-Mode), both built on the same AppContainer base
        (PartialTrustApplication + appContainer + no runFullTrust):
 
          AppContainer (default) — the GA "packagedClassicApp" AppContainer:
            uap10:TrustLevel="appContainer", uap10:RuntimeBehavior="packagedClassicApp".
            Ungranted access is denied (no consent prompts). Available since
            Windows 10 2004 (build 19041). -Capabilities here are standard
            package capabilities (e.g. internetClient, broadFileSystemAccess,
            microphone) resolved to their namespace automatically.
 
          AppSilo — the Win32 App Isolation silo (PREVIEW, Win11 24H2 / build
            26100+): adds uap18:RuntimeBehavior="appSilo",
            uap18:EntryPoint="Isolated.App", and the isolatedWin32-* capability
            broker (consent prompts via isolatedWin32-promptForAccess, etc.).
            appSilo is a further specialisation of appContainer. -Capabilities
            here are isolatedWin32-* / device capabilities. MinVersion is raised
            to 10.0.26100.0 (so the package will no longer install before 24H2).
 
        Repacks and re-signs the package.
 
        WARNING: opt-in. Many legacy apps break under isolation because they
        expect 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) — Application
        Executable PsfLauncher*.exe — cannot be isolated. PSF injects fixup DLLs
        into the target process, which AppContainer blocks. This cmdlet warns when
        it detects a PSF launcher.
 
        NOTE: A package that declares a windows.comServer extension (e.g. a COM
        shell context-menu like NppShell) CANNOT be isolated — that extension is
        invalid with a partial-trust entry point and MakeAppx rejects it. This
        cmdlet throws with guidance; strip the COM server + its context-menu first.
 
    .PARAMETER PackagePath
        The .msix file to modify.
 
    .PARAMETER Mode
        AppContainer (default) — GA packagedClassicApp AppContainer.
        AppSilo — preview Win32 App Isolation silo with the broker.
 
    .PARAMETER Capabilities
        Capabilities to declare. In -Mode AppContainer these are standard package
        capability names (default: none — strictest). In -Mode AppSilo these are
        isolatedWin32-* / device capabilities (default: isolatedWin32-promptForAccess).
 
    .PARAMETER AppId
        Restrict the isolation attributes to the Application with this Id.
        Default: every <Application> in the package.
 
    .PARAMETER RemoveComServer
        A windows.comServer extension is invalid with a partial-trust entry
        point, so a package that declares one (e.g. a COM shell context-menu
        like NppShell) cannot be isolated and this cmdlet throws. With this
        switch the COM server AND its Explorer context-menu verbs are stripped
        instead — that functionality is LOST, but the app can then isolate.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package.
 
    .PARAMETER Pfx
        Signing certificate.
 
    .PARAMETER PfxPassword
        Signing certificate.
 
    .EXAMPLE
        # GA AppContainer (strict): the app runs in an AppContainer, ungranted
        # access is denied.
        Add-MsixAppIsolation -PackagePath app.msix -SkipSigning
 
    .EXAMPLE
        # AppContainer with internet + a known folder granted.
        Add-MsixAppIsolation -PackagePath app.msix `
            -Capabilities internetClient -Pfx cert.pfx -PfxPassword $pw
 
    .EXAMPLE
        # Win32 App Isolation silo (preview) with the file-access broker.
        Add-MsixAppIsolation -PackagePath app.msix -Mode AppSilo `
            -Capabilities 'isolatedWin32-promptForAccess' -SkipSigning
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AppId',
        Justification = 'Captured by the -Mutate scriptblock passed to _MsixMutateManifest.')]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [ValidateSet('AppContainer', 'AppSilo')]
        [string]$Mode = 'AppContainer',
        [string[]]$Capabilities,
        [string]$AppId,
        [switch]$RemoveComServer,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    # Per-mode default capability set.
    if (-not $PSBoundParameters.ContainsKey('Capabilities')) {
        $Capabilities = if ($Mode -eq 'AppSilo') { @('isolatedWin32-promptForAccess') } else { @() }
    }

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

    _MsixMutateManifest -PackagePath $PackagePath -OutputPath $OutputPath `
        -SkipSigning:$SkipSigning -Pfx $Pfx -PfxPassword $PfxPassword `
        -WhatIfPreview:$isWhatIf `
        -Activity "Add App Isolation ($Mode)" -Mutate {
        param([xml]$manifest)
        _MsixApplyAppIsolation -Manifest $manifest -Mode $Mode -Capabilities $Capabilities -AppId $AppId -RemoveComServer:$RemoveComServer
    }
}


function _MsixApplyAppIsolation {
    # Module-private core of Add-MsixAppIsolation: applies the isolation model
    # (entry point, trust level, runFullTrust removal, capabilities) to an
    # in-memory manifest. Shared with Invoke-MsixPipeline's AppIsolation stage
    # so the two paths can never diverge again (issue #97).
    param(
        [Parameter(Mandatory)][xml]$Manifest,
        [ValidateSet('AppContainer', 'AppSilo')]
        [string]$Mode = 'AppContainer',
        [string[]]$Capabilities = @(),
        [string]$AppId,
        [switch]$RemoveComServer
    )
        $manifest = $Manifest

        # Validate capability names against the mode (warn only).
        foreach ($c in $Capabilities) {
            if ($Mode -eq 'AppSilo') {
                $known = $script:KnownIsolationCapabilities.Contains($c) -or $script:KnownIsolationDeviceCapabilities.Contains($c)
                if (-not $known) {
                    Write-MsixLog -Level Warning -Message "'$c' is not a documented Win32-isolation capability. Verify against MS Learn before publishing."
                }
            } else {
                if ($c -like 'isolatedWin32-*') {
                    Write-MsixLog -Level Warning -Message "'$c' is an appSilo (Win32 App Isolation) capability and is ignored in -Mode AppContainer. Use -Mode AppSilo for isolatedWin32-* capabilities, or pass a standard package capability name here."
                }
            }
        }

        # ── Namespaces + minimum-version floor ────────────────────────────────
        Add-MsixManifestNamespace -Manifest $manifest -Prefix 'uap10'
        $uap10Uri = Get-MsixManifestNamespaceUri -Prefix 'uap10'
        $uap18Uri = Get-MsixManifestNamespaceUri -Prefix 'uap18'   # for cleanup in both modes
        if ($Mode -eq 'AppSilo') {
            Add-MsixManifestNamespace -Manifest $manifest -Prefix 'uap18'
            Add-MsixManifestNamespace -Manifest $manifest -Prefix 'rescap'
            Set-MsixManifestMaxVersionTested -Manifest $manifest -MinBuild 26100

            # appSilo (preview) only engages when the package targets 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. appSilo 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 the appSilo preview). The package will no longer install on Windows older than 24H2."
                }
            }
        }

        # ── Locate target Application(s) ──────────────────────────────────────
        $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.' }

        # A windows.comServer extension cannot coexist with a partial-trust
        # entry point — MakeAppx rejects it ("The 'windows.comServer' Extension
        # can't be declared with Partial Trust EntryPoint", 0x80080204). A COM
        # shell extension (e.g. NppShell) therefore blocks isolation outright.
        # -RemoveComServer strips it (plus the context-menu verbs that need it);
        # otherwise fail fast with guidance instead of a cryptic repack error.
        $comServers = @($manifest.SelectNodes("//*[local-name()='Extension' and @Category='windows.comServer']"))
        if ($comServers.Count -gt 0) {
            if (-not $RemoveComServer) {
                throw "Cannot isolate this package: it declares a 'windows.comServer' extension, which is incompatible with the partial-trust (AppContainer) entry point that isolation requires. Re-run with -RemoveComServer to strip the COM server and its shell context-menu (losing that functionality), or remove them manually first."
            }
            foreach ($node in $comServers) {
                $null = $node.ParentNode.RemoveChild($node)
            }
            Write-MsixLog -Level Warning -Message "Removed $($comServers.Count) windows.comServer extension(s) (-RemoveComServer): COM activation from this package is gone."

            # The desktop4/desktop9 context-menu verbs reference the removed
            # COM classes; without their server they are dead weight (and the
            # classic handler category is itself full-trust-only). Strip them.
            $menuCats = 'windows.fileExplorerContextMenus', 'windows.fileExplorerClassicContextMenuHandler'
            foreach ($cat in $menuCats) {
                foreach ($node in @($manifest.SelectNodes("//*[local-name()='Extension' and @Category='$cat']"))) {
                    $null = $node.ParentNode.RemoveChild($node)
                    Write-MsixLog -Level Warning -Message "Removed '$cat' extension (-RemoveComServer): its COM server no longer exists. The Explorer context menu is gone."
                }
            }
            # Prune Extensions containers left empty by the removals.
            foreach ($ext in @($manifest.SelectNodes("//*[local-name()='Extensions']"))) {
                if (-not $ext.HasChildNodes) { $null = $ext.ParentNode.RemoveChild($ext) }
            }
        }

        foreach ($app in $apps) {
            # PSF launcher cannot be isolated (cross-process DLL injection is
            # blocked in an AppContainer).
            $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 AppContainer isolation are mutually exclusive: PSF injects fixup DLLs into the target process, which AppContainer blocks. This package will NOT run isolated. Run Remove-MsixPsf first to strip the framework and restore the real executable."
            }

            # The partial-trust entry point is what lets the app drop to
            # AppContainer (and lets runFullTrust be removed — the full-trust
            # entry point hard-requires it, which keeps the process full-trust).
            $app.SetAttribute('EntryPoint', 'Windows.PartialTrustApplication')

            # Idempotent + clean mode switch: remove any prior isolation attrs in
            # BOTH namespaces before setting the mode-specific ones.
            foreach ($ln in 'EntryPoint', 'TrustLevel', 'RuntimeBehavior') {
                foreach ($uri in $uap10Uri, $uap18Uri) {
                    $old = $app.GetAttributeNode($ln, $uri)
                    if ($old) { $null = $app.Attributes.Remove($old) }
                }
            }

            if ($Mode -eq 'AppSilo') {
                $attrs = @(
                    @{ Prefix = 'uap18'; Uri = $uap18Uri; Name = 'EntryPoint';      Value = 'Isolated.App' },
                    @{ Prefix = 'uap18'; Uri = $uap18Uri; Name = 'TrustLevel';      Value = 'appContainer' },
                    @{ Prefix = 'uap18'; Uri = $uap18Uri; Name = 'RuntimeBehavior'; Value = 'appSilo' }
                )
            } else {
                $attrs = @(
                    @{ Prefix = 'uap10'; Uri = $uap10Uri; Name = 'TrustLevel';      Value = 'appContainer' },
                    @{ Prefix = 'uap10'; Uri = $uap10Uri; Name = 'RuntimeBehavior'; Value = 'packagedClassicApp' }
                )
            }
            foreach ($a in $attrs) {
                $attr = $manifest.CreateAttribute($a.Prefix, $a.Name, $a.Uri)
                $attr.Value = $a.Value
                $null = $app.Attributes.Append($attr)
            }
            Write-MsixLog -Level Info -Message "Isolation set on Application '$($app.GetAttribute('Id'))' (Mode=$Mode, EntryPoint=Windows.PartialTrustApplication, TrustLevel=appContainer)."
        }

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

        # runFullTrust is incompatible with AppContainer and is NOT required by
        # the partial-trust entry point. Always remove it — this is the change
        # that actually lets the app fall into the AppContainer.
        foreach ($n in @($capsNode.ChildNodes)) {
            if ($n.LocalName -eq 'Capability' -and $n.GetAttribute('Name') -eq 'runFullTrust') {
                $null = $capsNode.RemoveChild($n)
                Write-MsixLog -Level Info -Message 'Removed runFullTrust (incompatible with AppContainer; the partial-trust entry point does not require it).'
            }
        }

        # Assemble the capability list.
        $wantedCaps = [System.Collections.Generic.List[string]]::new()
        foreach ($c in $Capabilities) { if ($c) { $null = $wantedCaps.Add($c) } }

        $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap'
        foreach ($cap in $wantedCaps) {
            if ($Mode -eq 'AppContainer' -and $cap -like 'isolatedWin32-*') { continue }  # warned earlier

            $isDeviceCap = $script:KnownIsolationDeviceCapabilities.Contains($cap)

            # Pick element + namespace.
            if ($isDeviceCap) {
                $localName = 'DeviceCapability'
            } else {
                $localName = 'Capability'
            }

            # Idempotency: skip if already present (match LocalName + Name).
            $exists = $false
            foreach ($child in $capsNode.ChildNodes) {
                if ($child.LocalName -eq $localName -and $child.GetAttribute('Name') -eq $cap) { $exists = $true; break }
            }
            if ($exists) { Write-MsixLog -Level Info -Message "Capability already present: $cap"; continue }

            if ($isDeviceCap) {
                $node = $manifest.CreateElement('DeviceCapability', $manifest.Package.NamespaceURI)
            }
            elseif ($Mode -eq 'AppSilo') {
                # isolatedWin32-* live in rescap.
                $node = $manifest.CreateElement('rescap:Capability', $rescapUri)
            }
            else {
                # AppContainer: resolve a standard package capability to its namespace.
                $ns = $script:KnownCapabilities[$cap]
                if ($ns -and $ns -ne 'standard') {
                    Add-MsixManifestNamespace -Manifest $manifest -Prefix $ns
                    $nsUri = Get-MsixManifestNamespaceUri -Prefix $ns
                    $node  = $manifest.CreateElement("${ns}:Capability", $nsUri)
                } else {
                    if (-not $ns) {
                        Write-MsixLog -Level Warning -Message "Capability '$cap' is not in the known-capabilities table; emitting a plain <Capability>. Verify it is valid for an AppContainer app."
                    }
                    $node = $manifest.CreateElement('Capability', $manifest.Package.NamespaceURI)
                }
            }
            $node.SetAttribute('Name', $cap)
            $null = $capsNode.AppendChild($node)
            Write-MsixLog -Level Info -Message "Capability added: $cap"
        }
}


function Remove-MsixAppIsolation {
    <#
    .SYNOPSIS
        Reverses Add-MsixAppIsolation: returns the app to a normal full-trust
        packaged app (Windows.FullTrustApplication + runFullTrust) and strips the
        AppContainer / appSilo attributes and isolatedWin32-* capabilities.
 
    .DESCRIPTION
        Removes the uap10/uap18 isolation attributes (TrustLevel / RuntimeBehavior
        / uap18:EntryPoint) from each <Application>, sets EntryPoint back to
        "Windows.FullTrustApplication", re-adds the runFullTrust capability (the
        normal packaged-Win32 default), and deletes any isolatedWin32-* capabilities.
 
    .PARAMETER PackagePath
        The .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
        Signing certificate.
 
    .PARAMETER PfxPassword
        Signing certificate.
        .EXAMPLE
        # Roll isolation back to a normal full-trust packaged app
        Remove-MsixAppIsolation -PackagePath app.msix -Pfx cert.pfx -PfxPassword $pw
 
    #>

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

    PROCESS {
        # Pre-check: any isolation attrs or isolatedWin32-* caps present?
        $preCheck  = Get-MsixManifest -Path $PackagePath
        $uap10Uri  = Get-MsixManifestNamespaceUri -Prefix 'uap10'
        $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', $uap10Uri) -or $_.GetAttributeNode('RuntimeBehavior', $uap10Uri) -or
            $_.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)

            $u10 = Get-MsixManifestNamespaceUri -Prefix 'uap10'
            $u18 = Get-MsixManifestNamespaceUri -Prefix 'uap18'

            foreach ($app in @($manifest.Package.Applications.Application)) {
                # Strip uap10 + uap18 isolation attributes.
                foreach ($ln in 'EntryPoint', 'TrustLevel', 'RuntimeBehavior') {
                    foreach ($uri in $u10, $u18) {
                        $node = $app.GetAttributeNode($ln, $uri)
                        if ($node) {
                            $null = $app.Attributes.Remove($node)
                            Write-MsixLog -Level Info -Message "Removed isolation attribute $ln from Application '$($app.GetAttribute('Id'))'."
                        }
                    }
                }
                # Restore the normal full-trust packaged-Win32 entry point.
                $app.SetAttribute('EntryPoint', 'Windows.FullTrustApplication')
            }

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

            # Strip isolatedWin32-* capabilities.
            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)"
                }
            }

            # Re-add runFullTrust (required by the restored full-trust entry point).
            $hasRft = $false
            foreach ($n in $capsNode.ChildNodes) {
                if ($n.LocalName -eq 'Capability' -and $n.GetAttribute('Name') -eq 'runFullTrust') { $hasRft = $true; break }
            }
            if (-not $hasRft) {
                Add-MsixManifestNamespace -Manifest $manifest -Prefix 'rescap'
                $rescapUri = Get-MsixManifestNamespaceUri -Prefix 'rescap'
                $rft = $manifest.CreateElement('rescap:Capability', $rescapUri)
                $rft.SetAttribute('Name', 'runFullTrust')
                $null = $capsNode.AppendChild($rft)
                Write-MsixLog -Level Info -Message 'Re-added runFullTrust (required by Windows.FullTrustApplication).'
            }
        }
    }
}


function Test-MsixIsolation {
    <#
    .SYNOPSIS
        Verifies whether a package WOULD isolate (static manifest check) or a
        running process actually IS isolated (AppContainer token check).
 
    .DESCRIPTION
        Two modes:
 
        Package (static): parses the manifest and reports EntryPoint, trust
        level, runtime behavior, detected isolation Mode (None / AppContainer /
        AppSilo), runFullTrust presence, blockers (PSF launcher entry point,
        windows.comServer extension) and a bottom-line WouldIsolate verdict
        with reasons. This catches the classic trap: a manifest that LOOKS
        isolated (uap18 attributes present) but keeps the full-trust entry
        point + runFullTrust and therefore never isolates.
 
        Process (runtime): reads the process token and reports IsAppContainer
        (the definitive S-1-15-2-* AppContainer SID check), the SID, the
        integrity level and the executable path — so a same-named desktop
        install can't be mistaken for the packaged app. Prompts alone are NOT
        proof of isolation (ASR rules also prompt); the token is.
 
    .PARAMETER PackagePath
        Static mode: the .msix file to analyse (read-only).
 
    .PARAMETER ProcessId
        Runtime mode: a process id to inspect.
 
    .PARAMETER PackageFamilyName
        Runtime mode: inspect every running process whose executable lives in
        this installed package's InstallLocation.
 
    .EXAMPLE
        # Before install: will this package isolate?
        Test-MsixIsolation -PackagePath .\app-isolated.msix
 
    .EXAMPLE
        # After launch: is the packaged app REALLY in an AppContainer?
        Test-MsixIsolation -PackageFamilyName 'NotepadPP_abcdef123'
 
    .EXAMPLE
        # Spot-check a single process
        Test-MsixIsolation -ProcessId 4242
 
    .OUTPUTS
        Static: [pscustomobject] with Mode, EntryPoint, TrustLevel,
        RuntimeBehavior, HasRunFullTrust, Blockers, WouldIsolate, Reasons.
        Runtime: [pscustomobject] per process with IsAppContainer,
        AppContainerSid, IntegrityLevel, ExecutablePath, Isolated.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Package')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Package')]
        [string]$PackagePath,
        [Parameter(Mandatory, ParameterSetName = 'Process')]
        [int]$ProcessId,
        [Parameter(Mandatory, ParameterSetName = 'Family')]
        [string]$PackageFamilyName
    )

    if ($PSCmdlet.ParameterSetName -eq 'Package') {
        [xml]$manifest = Get-MsixManifest -Path $PackagePath

        $uap10Uri = Get-MsixManifestNamespaceUri -Prefix 'uap10'
        $uap18Uri = Get-MsixManifestNamespaceUri -Prefix 'uap18'

        $capsNode = $manifest.Package.Capabilities
        $hasRft = $false
        if ($capsNode) {
            $hasRft = [bool]($capsNode.ChildNodes | Where-Object {
                $_.LocalName -eq 'Capability' -and $_.GetAttribute('Name') -eq 'runFullTrust' })
        }
        $hasComServer = $null -ne $manifest.SelectSingleNode("//*[local-name()='Extension' and @Category='windows.comServer']")

        foreach ($app in @($manifest.Package.Applications.Application)) {
            $entry   = $app.GetAttribute('EntryPoint')
            $tl10    = $app.GetAttribute('TrustLevel', $uap10Uri)
            $rb10    = $app.GetAttribute('RuntimeBehavior', $uap10Uri)
            $tl18    = $app.GetAttribute('TrustLevel', $uap18Uri)
            $rb18    = $app.GetAttribute('RuntimeBehavior', $uap18Uri)
            $exe     = $app.GetAttribute('Executable')

            $mode = 'None'
            if ($rb18 -eq 'appSilo') { $mode = 'AppSilo' }
            elseif ($rb10 -eq 'packagedClassicApp' -and $tl10 -eq 'appContainer') { $mode = 'AppContainer' }

            $trustLevel = $tl18
            if (-not $trustLevel) { $trustLevel = $tl10 }
            $runtimeBehavior = $rb18
            if (-not $runtimeBehavior) { $runtimeBehavior = $rb10 }

            $blockers = [System.Collections.Generic.List[string]]::new()
            if ($exe -match 'PsfLauncher\d*\.exe$') { $null = $blockers.Add('PSF launcher entry point (fixup injection is blocked in an AppContainer)') }
            if ($hasComServer) { $null = $blockers.Add('windows.comServer extension (invalid with a partial-trust entry point)') }

            $reasons = [System.Collections.Generic.List[string]]::new()
            if ($mode -eq 'None')  { $null = $reasons.Add('no AppContainer/AppSilo runtime behavior declared') }
            if ($entry -ne 'Windows.PartialTrustApplication') { $null = $reasons.Add("EntryPoint is '$entry' — must be 'Windows.PartialTrustApplication' to drop into an AppContainer") }
            if ($hasRft) { $null = $reasons.Add('runFullTrust capability present — keeps the process full-trust') }
            foreach ($b in $blockers) { $null = $reasons.Add($b) }

            $minVersionOk = $true
            if ($mode -eq 'AppSilo') {
                $tdf = @($manifest.Package.Dependencies.TargetDeviceFamily) |
                    Where-Object { $_.GetAttribute('Name') -eq 'Windows.Desktop' } | Select-Object -First 1
                $minVersionOk = $false
                if ($tdf) {
                    $v = $null
                    if ([version]::TryParse($tdf.GetAttribute('MinVersion'), [ref]$v)) { $minVersionOk = ($v -ge [version]'10.0.26100.0') }
                }
                if (-not $minVersionOk) { $null = $reasons.Add('AppSilo requires Windows.Desktop MinVersion 10.0.26100.0') }
            }

            [pscustomobject]@{
                PSTypeName       = 'MSIX.IsolationStatus'
                ApplicationId    = $app.GetAttribute('Id')
                Executable       = $exe
                Mode             = $mode
                EntryPoint       = $entry
                TrustLevel       = $trustLevel
                RuntimeBehavior  = $runtimeBehavior
                HasRunFullTrust  = $hasRft
                Blockers         = @($blockers)
                WouldIsolate     = ($reasons.Count -eq 0)
                Reasons          = @($reasons)
            }
        }
        return
    }

    # ── Runtime: process-token inspection ────────────────────────────────
    _MsixEnsureTokenInspector

    $procs = @()
    if ($PSCmdlet.ParameterSetName -eq 'Process') {
        $procs = @(Get-Process -Id $ProcessId -ErrorAction Stop)
    } else {
        $pkg = Get-AppxPackage | Where-Object { $_.PackageFamilyName -eq $PackageFamilyName } | Select-Object -First 1
        if (-not $pkg) { throw "Installed package with family name '$PackageFamilyName' not found for the current user." }
        $loc = $pkg.InstallLocation
        $procs = @(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith($loc, [System.StringComparison]::OrdinalIgnoreCase) })
        if (-not $procs) {
            Write-MsixLog -Level Warning -Message "No running processes found under '$loc'. Launch the packaged app first (and note: a same-named desktop install does not count)."
            return
        }
    }

    foreach ($p in $procs) {
        $info = [MsixTokenInspector]::Inspect($p.Id)
        $integrity = switch ($info.IntegrityRid) {
            { $_ -lt 0x1000 }  { 'Untrusted'; break }
            { $_ -lt 0x2000 }  { 'Low'; break }
            { $_ -lt 0x3000 }  { 'Medium'; break }
            { $_ -lt 0x4000 }  { 'High'; break }
            default            { 'System' }
        }
        [pscustomobject]@{
            PSTypeName      = 'MSIX.IsolationRuntimeStatus'
            ProcessId       = $p.Id
            ProcessName     = $p.ProcessName
            ExecutablePath  = $p.Path
            IsPackagedPath  = ($p.Path -like '*\WindowsApps\*')
            IsAppContainer  = $info.IsAppContainer
            AppContainerSid = $info.AppContainerSid
            IntegrityLevel  = $integrity
            Isolated        = $info.IsAppContainer
        }
    }
}


function _MsixEnsureTokenInspector {
    # Compiles the P/Invoke token inspector once per session. Reading ANOTHER
    # process's token requires QueryLimitedInformation access; the definitive
    # isolation signal is TokenIsAppContainer + the S-1-15-2-* AppContainer SID.
    if ('MsixTokenInspector' -as [type]) { return }
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
 
public class MsixTokenInfo
{
    public bool IsAppContainer;
    public string AppContainerSid;
    public uint IntegrityRid;
}
 
public static class MsixTokenInspector
{
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
    [DllImport("advapi32.dll", SetLastError = true)]
    static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
    [DllImport("advapi32.dll", SetLastError = true)]
    static extern bool GetTokenInformation(IntPtr token, int infoClass, IntPtr info, int len, out int retLen);
    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    static extern bool ConvertSidToStringSid(IntPtr sid, out string sidString);
    [DllImport("advapi32.dll")]
    static extern IntPtr GetSidSubAuthority(IntPtr sid, uint index);
    [DllImport("advapi32.dll")]
    static extern IntPtr GetSidSubAuthorityCount(IntPtr sid);
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern bool CloseHandle(IntPtr handle);
 
    const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
    const uint TOKEN_QUERY = 0x0008;
    const int TokenIntegrityLevel = 25;
    const int TokenIsAppContainer = 29;
    const int TokenAppContainerSid = 31;
 
    public static MsixTokenInfo Inspect(int pid)
    {
        MsixTokenInfo result = new MsixTokenInfo();
        IntPtr process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
        if (process == IntPtr.Zero)
            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "OpenProcess failed for PID " + pid);
        IntPtr token = IntPtr.Zero;
        try
        {
            if (!OpenProcessToken(process, TOKEN_QUERY, out token))
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed for PID " + pid);
 
            int retLen;
            IntPtr buf = Marshal.AllocHGlobal(4);
            try
            {
                if (GetTokenInformation(token, TokenIsAppContainer, buf, 4, out retLen))
                    result.IsAppContainer = Marshal.ReadInt32(buf) != 0;
            }
            finally { Marshal.FreeHGlobal(buf); }
 
            if (result.IsAppContainer)
            {
                GetTokenInformation(token, TokenAppContainerSid, IntPtr.Zero, 0, out retLen);
                if (retLen > 0)
                {
                    IntPtr acBuf = Marshal.AllocHGlobal(retLen);
                    try
                    {
                        if (GetTokenInformation(token, TokenAppContainerSid, acBuf, retLen, out retLen))
                        {
                            IntPtr sid = Marshal.ReadIntPtr(acBuf); // TOKEN_APPCONTAINER_INFORMATION.TokenAppContainer
                            string sidString;
                            if (sid != IntPtr.Zero && ConvertSidToStringSid(sid, out sidString))
                                result.AppContainerSid = sidString;
                        }
                    }
                    finally { Marshal.FreeHGlobal(acBuf); }
                }
            }
 
            GetTokenInformation(token, TokenIntegrityLevel, IntPtr.Zero, 0, out retLen);
            if (retLen > 0)
            {
                IntPtr ilBuf = Marshal.AllocHGlobal(retLen);
                try
                {
                    if (GetTokenInformation(token, TokenIntegrityLevel, ilBuf, retLen, out retLen))
                    {
                        IntPtr sid = Marshal.ReadIntPtr(ilBuf); // TOKEN_MANDATORY_LABEL.Label.Sid
                        if (sid != IntPtr.Zero)
                        {
                            byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid));
                            result.IntegrityRid = (uint)Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1)));
                        }
                    }
                }
                finally { Marshal.FreeHGlobal(ilBuf); }
            }
        }
        finally
        {
            if (token != IntPtr.Zero) CloseHandle(token);
            CloseHandle(process);
        }
        return result;
    }
}
'@

}


function Get-MsixIsolationAdvice {
    <#
    .SYNOPSIS
        Turns access-denied rows from a ProcMon trace of an isolated app into
        concrete capability / consent suggestions for Add-MsixAppIsolation.
 
    .DESCRIPTION
        Run the app under isolation, capture with Invoke-MsixProcMonCapture,
        pull the failures with Get-MsixProcMonFailure, and feed them here.
        Each ACCESS DENIED row is mapped to a suggestion:
 
          - user-profile paths -> AppSilo: isolatedWin32-promptForAccess /
                                      isolatedWin32-userProfileMinimal;
                                      AppContainer: use the file dialog (implicit
                                      consent) — no standard capability grants
                                      broad profile access.
          - ProgramData\<publisher> -> isolatedWin32-accessToPublisherDirectory.
          - TCP/UDP operations -> internetClient (AppContainer) /
                                      isolatedWin32-internetClient (AppSilo).
          - HKLM writes -> no capability exists; the app needs a
                                      code change or cannot be isolated.
 
        Suggestions are aggregated (one per capability) with example paths and
        hit counts, ordered by hits.
 
    .PARAMETER Failures
        Failure rows (from Get-MsixProcMonFailure) or any objects with
        Path / Operation / Result properties. Accepts pipeline input.
 
    .PARAMETER CsvPath
        Alternative input: a ProcMon CSV export to read directly.
 
    .PARAMETER Mode
        Which isolation mode you are targeting; picks the capability
        vocabulary for the suggestions. Default: AppSilo.
 
    .EXAMPLE
        $pml = Invoke-MsixProcMonCapture -ScriptBlock { Start-Process notepad++ -Wait }
        Get-MsixProcMonFailure -PmlPath $pml | Get-MsixIsolationAdvice -Mode AppSilo
 
    .EXAMPLE
        Get-MsixIsolationAdvice -CsvPath .\trace.csv -Mode AppContainer
 
    .OUTPUTS
        [pscustomobject] per suggestion: SuggestedCapability, Mode, Hits,
        ExamplePaths, Rationale.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Objects')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(ParameterSetName = 'Objects', ValueFromPipeline)]
        [object[]]$Failures,
        [Parameter(Mandatory, ParameterSetName = 'Csv')]
        [string]$CsvPath,
        [ValidateSet('AppContainer', 'AppSilo')]
        [string]$Mode = 'AppSilo'
    )

    BEGIN {
        $all = [System.Collections.Generic.List[object]]::new()
    }
    PROCESS {
        foreach ($f in @($Failures)) { if ($null -ne $f) { $null = $all.Add($f) } }
    }
    END {
        if ($PSCmdlet.ParameterSetName -eq 'Csv') {
            if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" }
            foreach ($row in (Import-Csv -Path $CsvPath)) { $null = $all.Add($row) }
        }

        $denied = @($all | Where-Object { $_.Result -match 'ACCESS DENIED|ACCESS_DENIED' })
        if (-not $denied) {
            Write-MsixLog -Level Info -Message 'No ACCESS DENIED rows found — nothing blocked; no additional capabilities suggested.'
            return
        }

        $profileRx  = '\\Users\\[^\\]+\\(Documents|Desktop|Pictures|Music|Videos|Downloads)'
        $progDataRx = '\\ProgramData\\'
        $hklmRx     = '^HKLM'
        $netRx      = '^(TCP|UDP)'

        $buckets = @{}
        function script:_MsixAdviceAdd {
            param([hashtable]$Buckets, [string]$Key, [string]$Capability, [string]$Rationale, [object]$Row)
            if (-not $Buckets.ContainsKey($Key)) {
                $Buckets[$Key] = @{ Capability = $Capability; Rationale = $Rationale; Hits = 0; Paths = [System.Collections.Generic.List[string]]::new() }
            }
            $Buckets[$Key].Hits++
            $p = [string]$Row.Path
            if ($p -and $Buckets[$Key].Paths.Count -lt 5 -and -not $Buckets[$Key].Paths.Contains($p)) { $null = $Buckets[$Key].Paths.Add($p) }
        }

        foreach ($row in $denied) {
            $path = [string]$row.Path
            $op   = [string]$row.Operation

            if ($op -match $netRx) {
                $cap = if ($Mode -eq 'AppSilo') { 'isolatedWin32-internetClient' } else { 'internetClient' }
                _MsixAdviceAdd -Buckets $buckets -Key 'net' -Capability $cap -Rationale 'Network operations were denied.' -Row $row
            }
            elseif ($path -match $hklmRx -and $op -match 'RegSetValue|RegCreateKey') {
                _MsixAdviceAdd -Buckets $buckets -Key 'hklm' -Capability '(none — HKLM writes)' -Rationale 'The app writes to HKLM. No isolation capability grants that; the app needs a code change (per-user settings) or cannot be isolated.' -Row $row
            }
            elseif ($path -match $progDataRx) {
                _MsixAdviceAdd -Buckets $buckets -Key 'progdata' -Capability 'isolatedWin32-accessToPublisherDirectory' -Rationale 'ProgramData access: works if the directory name ends with your publisher ID (AppSilo publisher-directory consent).' -Row $row
            }
            elseif ($path -match $profileRx) {
                $cap = if ($Mode -eq 'AppSilo') { 'isolatedWin32-promptForAccess' } else { '(implicit consent — use the file dialog)' }
                $why = if ($Mode -eq 'AppSilo') { 'User-profile file access denied; the broker prompts on first access (alternative: isolatedWin32-userProfileMinimal for silent minimal access).' } else { 'User-profile access is denied in AppContainer mode; only user-driven flows (file dialog / FTA / drag-drop) grant it. Consider -Mode AppSilo for brokered prompts.' }
                _MsixAdviceAdd -Buckets $buckets -Key 'profile' -Capability $cap -Rationale $why -Row $row
            }
            else {
                _MsixAdviceAdd -Buckets $buckets -Key 'other' -Capability '(review individually)' -Rationale 'Denied paths outside the known consent patterns — check whether the app can take a code change, or validate under -Mode AppSilo with isolatedWin32-promptForAccess.' -Row $row
            }
        }

        $buckets.Values | Sort-Object -Property Hits -Descending | ForEach-Object {
            [pscustomobject]@{
                PSTypeName          = 'MSIX.IsolationAdvice'
                SuggestedCapability = $_.Capability
                Mode                = $Mode
                Hits                = $_.Hits
                ExamplePaths        = @($_.Paths)
                Rationale           = $_.Rationale
            }
        }
    }
}


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