private/Controls/ConvertTo-UiFileAction.ps1

function ConvertTo-UiFileAction {
    <#
    .SYNOPSIS
        Converts a file path to a secure scriptblock for button execution.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$File,

        [hashtable]$ArgumentList
    )

    # Resolve to absolute path (handles relative paths like .\script.ps1)
    $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($File)

    # Security: Validate the file exists at button creation time
    if (!(Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
        throw "File not found: '$resolvedPath'. The file must exist when the button is created."
    }

    # Security: Validate the path doesn't contain injection characters
    # The path will be embedded in a scriptblock, so we need to ensure it's safe
    if ($resolvedPath -match '[`$\{\}]') {
        throw "Invalid file path: '$resolvedPath'. Path contains characters that could cause script injection."
    }

    $extension = [System.IO.Path]::GetExtension($resolvedPath).ToLowerInvariant()
    $supportedExtensions = @('.ps1', '.bat', '.cmd', '.vbs', '.exe')

    if ($extension -notin $supportedExtensions) {
        throw "Unsupported file type: '$extension'. Supported types: $($supportedExtensions -join ', ')"
    }

    # Escape single quotes in path for embedding in scriptblock
    $escapedPath = $resolvedPath.Replace("'", "''")

    # Build argument string from hashtable values (used by bat/cmd/vbs/exe)
    $argString = ''
    if ($ArgumentList -and $ArgumentList.Count -gt 0) {
        $argValues = @($ArgumentList.Values | ForEach-Object { 
            $val = $_.ToString()
            if ($val -match '\s') { "`"$val`"" } else { $val }
        })

        $argString = $argValues -join ' '
    }

    # Build the scriptblock based on file type
    switch ($extension) {
        '.ps1' {
            if ($ArgumentList -and $ArgumentList.Count -gt 0) {

                # For ps1 with arguments, capture the args at creation time via closure
                $capturedArgs = $ArgumentList.Clone()

                $Action = {
                    $splatArgs = $capturedArgs
                    & $resolvedPath @splatArgs
                }.GetNewClosure()

            }
            else { $Action = [scriptblock]::Create("& '$escapedPath'") }
        }

        { $_ -in '.bat', '.cmd' } {
            if ($argString) { $Action = [scriptblock]::Create("cmd.exe /c `"$escapedPath`" $argString") }
            else { $Action = [scriptblock]::Create("cmd.exe /c `"$escapedPath`"") }
        }

        '.vbs' {
            if ($argString) { $Action = [scriptblock]::Create("cscript.exe //nologo `"$escapedPath`" $argString") }
            else { $Action = [scriptblock]::Create("cscript.exe //nologo `"$escapedPath`"") }
        }

        '.exe' {
            if ($argString) { $Action = [scriptblock]::Create("& '$escapedPath' $argString") }
            else { $Action = [scriptblock]::Create("& '$escapedPath'") }
        }
    }

    return $Action
}