diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 328d7e25f..d3019fe33 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -15,10 +15,63 @@ private enum WrapperKind { Posix, Cmd, PowerShell } private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); private static readonly HashSet s_posixInlineFlags = - new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" }; + new(StringComparer.Ordinal) { "-lc", "-c", "--command" }; private static readonly HashSet s_powerShellInlineFlags = - new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" }; + new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command", "/c", "/command" }; + + // Switches that take no argument. A prefix that also matches one of these + // is that switch: -i and -in are Interactive, not InputFormat. + private static readonly string[] s_powerShellSwitchNames = + [ + "Interactive", + "Login", + "MTA", + "NoExit", + "NoLogo", + "NonInteractive", + "NoProfile", + "NoProfileLoadTime", + "SSHServerMode", + "STA", + ]; + + // Canonical pwsh parameters that take one following argument. A unique + // prefix binds the same way (-wo and -wor are -WorkingDirectory), unless + // that prefix also matches a switch above. + private static readonly string[] s_powerShellValueOptionNames = + [ + "WorkingDirectory", + "ExecutionPolicy", + "InputFormat", + "OutputFormat", + "ConfigurationName", + "ConfigurationFile", + "CustomPipeName", + "EncodedCommand", + "SettingsFile", + "PSConsoleFile", + "WindowStyle", + "Version", + ]; + + // Forms that are not a unique prefix of one canonical name. -wd is the + // WorkingDirectory alias. -w is WindowStyle, which also prefixes + // WorkingDirectory. -ep and -if are the short ExecutionPolicy and + // InputFormat aliases. -config matches both ConfigurationName and + // ConfigurationFile. + private static readonly HashSet s_powerShellValueAliases = + new(StringComparer.OrdinalIgnoreCase) + { + "-wd", "/wd", + "-w", "/w", + "-ep", "/ep", + "-e", "/e", + "-ec", "/ec", + "-if", "/if", + "-config", "/config", + "-of", "/of", + }; private static readonly WrapperSpec[] s_specs = [ @@ -78,12 +131,23 @@ private static ParsedWrapper ExtractInner( private static string? ExtractPosixPayload(IReadOnlyList command) { - if (command.Count < 2) return null; - var flag = command[1].Trim(); - if (!s_posixInlineFlags.Contains(flag)) return null; - if (command.Count < 3) return null; - var payload = command[2].Trim(); - return payload.Length == 0 ? null : payload; + var fish = IsFishShell(command[0]); + for (var i = 1; i < command.Count; i++) + { + var flag = command[i].Trim(); + if (flag.Length == 0) continue; + if (flag == "--") return null; + if (s_posixInlineFlags.Contains(flag) || IsPosixInlineCluster(flag) || (fish && IsFishInitCommand(flag))) + { + if (i + 1 >= command.Count) return null; + var payload = command[i + 1].Trim(); + return payload.Length == 0 ? null : payload; + } + + if (!flag.StartsWith('-')) + return null; + } + return null; } private static string? ExtractCmdPayload(IReadOnlyList command) @@ -101,18 +165,165 @@ private static ParsedWrapper ExtractInner( private static string? ExtractPowerShellPayload(IReadOnlyList command) { + var windowsPowerShell = IsWindowsPowerShellHost(command[0]); for (var i = 1; i < command.Count; i++) { - var t = command[i].Trim().ToLowerInvariant(); + var t = command[i].Trim(); if (t.Length == 0) continue; - if (t == "--") break; - if (s_powerShellInlineFlags.Contains(t)) + if (t == "--") return null; + if (IsPowerShellValueOption(t, windowsPowerShell)) + { + i++; + continue; + } + + if (IsPowerShellFileSwitch(t)) + return null; + if (TryReadPowerShellColonPayload(t, out var inline)) + return inline.Length == 0 ? null : inline; + if (IsPowerShellInlineFlag(t)) { if (i + 1 >= command.Count) return null; var payload = command[i + 1].Trim(); return payload.Length == 0 ? null : payload; } + + if (!t.StartsWith('-') && !t.StartsWith('/')) + { + // Windows PowerShell defaults to -Command for positional text, + // including a lone script name. Explicit -File stays a script. + return windowsPowerShell ? t : null; + } } return null; } + + private static bool IsWindowsPowerShellHost(string executable) => + ExecCommandToken.NormalizedBasename(executable).Equals("powershell", StringComparison.Ordinal); + + private static bool IsFishShell(string token) + => ExecCommandToken.NormalizedBasename(token).Equals("fish", StringComparison.OrdinalIgnoreCase); + + private static bool IsFishInitCommand(string flag) + => flag == "-C" || flag.Equals("--init-command", StringComparison.Ordinal); + + private static bool IsPowerShellValueOption(string token, bool windowsPowerShell) + { + if (token.IndexOf(':') > 0) + return false; + if (s_powerShellValueAliases.Contains(token)) + return true; + if (!TryGetPowerShellSwitchBody(token, out var body)) + return false; + if (IsPowerShellValueAliasBody(body)) + return true; + + var switchMatches = CountPrefixMatches(body, s_powerShellSwitchNames); + var valueMatches = CountPrefixMatches(body, s_powerShellValueOptionNames); + if (switchMatches > 0 && + !(windowsPowerShell && IsInteractivePrefix(body) && valueMatches == 1)) + { + return false; + } + + return valueMatches == 1; + } + + private static bool IsInteractivePrefix(string body) => + "interactive".StartsWith(body, StringComparison.OrdinalIgnoreCase); + + private static bool IsPowerShellValueAliasBody(string body) + { + foreach (var alias in s_powerShellValueAliases) + { + if (!TryGetPowerShellSwitchBody(alias, out var aliasBody)) + continue; + if (aliasBody.Equals(body, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static int CountPrefixMatches(string body, string[] names) + { + var matches = 0; + foreach (var name in names) + { + if (name.StartsWith(body, StringComparison.OrdinalIgnoreCase)) + matches++; + } + + return matches; + } + + private static bool IsPowerShellInlineFlag(string token) + { + if (s_powerShellInlineFlags.Contains(token)) + return true; + if (!TryGetPowerShellSwitchBody(token, out var body)) + return false; + if (body.Equals("c", StringComparison.OrdinalIgnoreCase)) + return true; + if (body.Length < 2) + return false; + + return "command".StartsWith(body, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetPowerShellSwitchBody(string token, out string body) + { + body = ""; + if (token.StartsWith("--", StringComparison.Ordinal)) + { + body = token[2..]; + return body.Length > 0; + } + + if (token.Length < 2 || (token[0] != '-' && token[0] != '/')) + return false; + + body = token[1..]; + return body.Length > 0; + } + + private static bool IsPosixInlineCluster(string flag) + { + if (flag.Length < 3 || flag[0] != '-' || flag[1] == '-') + return false; + var sawCommand = false; + for (var i = 1; i < flag.Length; i++) + { + if (!char.IsLetter(flag[i])) + return false; + if (flag[i] == 'c') + sawCommand = true; + } + + return sawCommand; + } + + private static bool IsPowerShellFileSwitch(string token) + { + var name = token; + var colon = token.IndexOf(':'); + if (colon > 0) + name = token[..colon]; + if (!TryGetPowerShellSwitchBody(name, out var body)) + return false; + + return body.Length > 0 && + "file".StartsWith(body, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryReadPowerShellColonPayload(string token, out string payload) + { + payload = ""; + var colon = token.IndexOf(':'); + if (colon <= 0) return false; + var flag = token[..colon]; + if (!s_powerShellInlineFlags.Contains(flag)) return false; + payload = token[(colon + 1)..].Trim(); + return true; + } } diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index a97848762..97fe55716 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using Xunit; using OpenClaw.Shared.ExecApprovals; @@ -40,6 +41,270 @@ public void Normalizer_DirectExec_IsNotWrapper() [Fact] public void Normalizer_PowerShellCapital() => AssertWrapper(["powershell", "-Command", "Get-Date"], "Get-Date"); [Fact] public void Normalizer_PwshLowerC() => AssertWrapper(["pwsh", "-c", "Get-Date"], "Get-Date"); [Fact] public void Normalizer_PowerShellExeLower() => AssertWrapper(["powershell.exe", "-command", "Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PowerShellExeSlashC() => AssertWrapper(["powershell.exe", "/c", "Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PwshSlashCommand() => AssertWrapper(["pwsh", "/command", "Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PowerShellColonCommand() => AssertWrapper(["powershell", "-c:Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PwshSlashColonCommand() => AssertWrapper(["pwsh", "/c:Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PowerShellCommandColon() => AssertWrapper(["powershell.exe", "-command:Get-Date"], "Get-Date"); + [Fact] public void Normalizer_PwshSlashCommandColon() => AssertWrapper(["pwsh.exe", "/command:Get-Date"], "Get-Date"); + [Fact] public void Normalizer_BashLoginInlineCommand() => AssertWrapper(["bash", "-l", "-c", "echo hello"], "echo hello"); + [Fact] public void Normalizer_BashCombinedEc() => AssertWrapper(["bash", "-ec", "echo hello"], "echo hello"); + [Fact] public void Normalizer_BashCombinedCe() => AssertWrapper(["bash", "-ce", "echo hello"], "echo hello"); + [Fact] public void Normalizer_BashLoginCombinedEc() => AssertWrapper(["bash", "-l", "-ec", "echo hello"], "echo hello"); + + [Fact] + public void Normalizer_BashUppercaseC_IsNoclobberNotInline() + { + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-C", "script.sh"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-eC", "script.sh"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-Ce", "script.sh"]).IsWrapper); + var bound = ExecReusableCommandBinder.TryBind( + ["bash", "-eC", "script.sh"], + cwd: null, + env: null, + out var failure); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, failure); + Assert.NotNull(bound); + } + + [Fact] + public void Normalizer_BashScript_IsNotWrapper() + { + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "script.sh"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-l", "script.sh"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "script.sh", "-c", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-l", "script.sh", "-c", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "script.sh", "-ec", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-e", "script.sh"]).IsWrapper); + Assert.Null(ExecReusableCommandBinder.TryBind( + ["bash", "-ec", "echo hello"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + } + + [Fact] + public void Normalizer_PowerShellFileArgument_IsNotWrapper() + { + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "-File", "script.ps1", "/c", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["powershell", "-File:script.ps1", "/command:value"]).IsWrapper); + var bound = ExecReusableCommandBinder.TryBind( + ["pwsh", "-File", "script.ps1", "/c", "value"], + cwd: null, + env: null, + out var failure); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, failure); + Assert.NotNull(bound); + } + + [Fact] + public void Normalizer_PowerShellWorkingDirectoryOperand_DoesNotHideInlineCommand() + { + AssertWrapper( + ["pwsh", "-WorkingDirectory", "-File", "-c", "Get-Date"], + "Get-Date"); + AssertWrapper( + ["powershell.exe", "-WorkingDirectory", "C:\\temp", "-c", "Get-Date"], + "Get-Date"); + Assert.Null(ExecReusableCommandBinder.TryBind( + ["pwsh", "-WorkingDirectory", "-File", "-c", "Get-Date"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + } + + [Fact] + public void Normalizer_PowerShellWorkingDirectoryAlias_DoesNotHideInlineCommand() + { + AssertWrapper(["pwsh", "-wd", "-File", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-wo", "-File", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-wor", "-File", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["powershell.exe", "/wd", "-File", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-w", "Hidden", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-ep", "Bypass", "-c", "Get-Date"], "Get-Date"); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "-wd", "C:\\temp", "-File", "script.ps1"]).IsWrapper); + + Assert.Null(ExecReusableCommandBinder.TryBind( + ["pwsh", "-wd", "-File", "-c", "Get-Date"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + + var outcome = ExecApprovalV2Normalizer.Normalize( + Req(["pwsh", "-wd", "-File", "-c", "Get-Date"])); + Assert.True(outcome.IsResolved); + Assert.Null(outcome.Identity!.ReusableCommand); + Assert.Empty(outcome.Identity.AllowAlwaysPatterns); + } + + [Fact] + public void Normalizer_PowerShellInteractivePrefix_DoesNotHideInlineCommand() + { + AssertWrapper(["pwsh", "-i", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-in", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-int", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-inp", "Text", "-c", "Get-Date"], "Get-Date"); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "-in", "script.ps1", "-c", "Get-Date"]).IsWrapper); + + Assert.Null(ExecReusableCommandBinder.TryBind( + ["pwsh", "-i", "-c", "Get-Date"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + + var outcome = ExecApprovalV2Normalizer.Normalize(Req(["pwsh", "-in", "-c", "Get-Date"])); + Assert.True(outcome.IsResolved); + Assert.Null(outcome.Identity!.ReusableCommand); + Assert.Empty(outcome.Identity.AllowAlwaysPatterns); + } + + [Fact] + public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() + { + AssertWrapper(["pwsh", "--InputFormat", "Text", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "--c", "Get-Date"], "Get-Date"); + AssertWrapper(["powershell.exe", "-i", "Text", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["powershell.exe", "-in", "Text", "-c", "Get-Date"], "Get-Date"); + AssertWrapper( + ["powershell.exe", "-NoProfile", "Write-Output marker; #", "-c", "Get-Date"], + "Write-Output marker; #"); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "-NoProfile", "Write-Output marker; #", "-c", "Get-Date"]).IsWrapper); + AssertWrapper(["powershell.exe", "Get-Date"], "Get-Date"); + AssertWrapper(["powershell.exe", "Write-Output marker"], "Write-Output marker"); + AssertWrapper(["powershell", "script.ps1"], "script.ps1"); + Assert.Null(ExecReusableCommandBinder.TryBind( + ["powershell.exe", "Get-Date"], + cwd: null, + env: null, + out var loneCommandFailure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, loneCommandFailure); + AssertWrapper(["pwsh", "--wd", "-File", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "-of", "Text", "-c", "Get-Date"], "Get-Date"); + AssertWrapper(["pwsh", "--of", "Text", "-c", "Get-Date"], "Get-Date"); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["powershell.exe", "-fi", "script.ps1", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["powershell.exe", "-fil", "script.ps1", "value"]).IsWrapper); + + Assert.Null(ExecReusableCommandBinder.TryBind( + ["pwsh", "--wd", "-File", "-c", "Get-Date"], + cwd: null, + env: null, + out var doubleDashFailure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, doubleDashFailure); + Assert.Null(ExecReusableCommandBinder.TryBind( + ["pwsh", "-of", "Text", "-c", "Get-Date"], + cwd: null, + env: null, + out var outputFormatFailure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, outputFormatFailure); + Assert.NotNull(ExecReusableCommandBinder.TryBind( + ["powershell.exe", "-fi", "script.ps1", "value"], + cwd: null, + env: null, + out var fileFailure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, fileFailure); + + Assert.Null(ExecReusableCommandBinder.TryBind( + ["powershell.exe", "-i", "Text", "-c", "Get-Date"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + + var outcome = ExecApprovalV2Normalizer.Normalize( + Req(["pwsh", "--InputFormat", "Text", "-c", "Get-Date"])); + Assert.True(outcome.IsResolved); + Assert.Null(outcome.Identity!.ReusableCommand); + Assert.Empty(outcome.Identity.AllowAlwaysPatterns); + } + + [Fact] + public void Normalizer_PowerShellPositionalScript_KeepsLaterSwitchAsScriptArgument() + { + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "script.ps1", "/c", "value"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract( + ["pwsh", "-NoProfile", "script.ps1", "/c", "value"]).IsWrapper); + var bound = ExecReusableCommandBinder.TryBind( + ["pwsh", "script.ps1", "/c", "value"], + cwd: null, + env: null, + out var failure); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, failure); + Assert.NotNull(bound); + } + + [Fact] + public void Normalizer_FishInitCommand_IsWrapper() + { + AssertWrapper(["fish", "-C", "echo hello"], "echo hello"); + AssertWrapper(["fish", "--init-command", "echo hello"], "echo hello"); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-C", "echo hello"]).IsWrapper); + Assert.Null(ExecReusableCommandBinder.TryBind( + ["fish", "-C", "echo hello"], + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + } + + [Fact] + public void Normalizer_EmptyInlinePayload_IsNotWrapper() + { + Assert.False(ExecShellWrapperNormalizer.Extract(["powershell.exe", "/c"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["pwsh", "/command"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["powershell", "-c:"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["pwsh", "/c:"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["powershell", "-command:"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["pwsh", "/command:"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-l", "-c"]).IsWrapper); + } + + [Theory] + [InlineData("powershell.exe", "/c", "Get-Date")] + [InlineData("pwsh", "/command", "Get-Date")] + [InlineData("powershell", "-c:Get-Date")] + [InlineData("pwsh", "/c:Get-Date")] + [InlineData("powershell.exe", "-command:Get-Date")] + [InlineData("pwsh.exe", "/command:Get-Date")] + [InlineData("bash", "-l", "-c", "echo hello")] + public void TryBind_InlineShell_IsNotReusable(params string[] argv) + { + Assert.Null(ExecReusableCommandBinder.TryBind(argv, cwd: null, env: null, out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + } + + [Fact] + public void TryBind_DirectToolArg_StaysBindable() + { + var directory = Directory.CreateTempSubdirectory("exec-direct-tool"); + try + { + var tool = Path.Combine(directory.FullName, "tool.exe"); + File.WriteAllBytes(tool, [0x4D, 0x5A]); + var bound = ExecReusableCommandBinder.TryBind( + [tool, "arg"], + cwd: null, + env: null, + out var failure); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, failure); + Assert.NotNull(bound); + } + finally + { + directory.Delete(recursive: true); + } + } private static void AssertWrapper(string[] argv, string expectedPayload) { @@ -822,15 +1087,23 @@ public void ResolveForAllowlist_WrapperPowerShellCommandPayload_NotFailClosed() } [Fact] - public void ResolveForAllowlist_DirectPowerShellScriptFile_NotFailClosed() + public void ResolveForAllowlist_PositionalPowerShellScriptName_IsImplicitCommand() { - // Direct exec path: ["powershell", "script.ps1"] — no inline flag, no -EncodedCommand. - // DirectExecUsesEncodedCommand must not trigger; must resolve as a single resolution. - var resolutions = ExecCommandResolver.ResolveForAllowlist( + // Windows PowerShell runs a lone positional name as -Command. + // Explicit -File remains the reusable script form. + Assert.True(ExecShellWrapperNormalizer.Extract(["powershell", "script.ps1"]).IsWrapper); + Assert.Null(ExecReusableCommandBinder.TryBind( ["powershell", "script.ps1"], - evaluationRawCommand: null, cwd: null, env: null); - Assert.Single(resolutions); - Assert.Contains("powershell", resolutions[0].ExecutableName, StringComparison.OrdinalIgnoreCase); + cwd: null, + env: null, + out var failure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.ShellWrapper, failure); + Assert.NotNull(ExecReusableCommandBinder.TryBind( + ["powershell", "-File", "script.ps1"], + cwd: null, + env: null, + out var fileFailure)); + Assert.Equal(ExecReusableCommandBinder.BindFailure.None, fileFailure); } [Fact]