diff --git a/src/OpenClaw.Shared/ExecShellWrapperParser.cs b/src/OpenClaw.Shared/ExecShellWrapperParser.cs index b5640e3f..10568eaa 100644 --- a/src/OpenClaw.Shared/ExecShellWrapperParser.cs +++ b/src/OpenClaw.Shared/ExecShellWrapperParser.cs @@ -135,8 +135,26 @@ private static (string? Payload, string? Shell, string? Error) ParsePowerShellPa for (var i = 1; i < tokens.Length; i++) { var option = tokens[i]; - if (option.Equals("-Command", StringComparison.OrdinalIgnoreCase) || - option.Equals("-c", StringComparison.OrdinalIgnoreCase)) + + // Check for inline separator form first: -flag:value or -flag=value + var sepIdx = IndexOfFlagSeparator(option); + if (sepIdx > 0) + { + var flagPart = option[..sepIdx]; + var valuePart = option[(sepIdx + 1)..]; + + if (IsCommandFlag(flagPart)) + { + return string.IsNullOrWhiteSpace(valuePart) + ? ("", shell, "Shell wrapper payload was empty") + : (valuePart, shell, null); + } + + if (IsEncodedCommandFlag(flagPart)) + return DecodeEncodedPayload(valuePart, shell); + } + + if (IsCommandFlag(option)) { var payload = string.Join(" ", tokens, i + 1, tokens.Length - i - 1).Trim(); return string.IsNullOrWhiteSpace(payload) @@ -144,32 +162,68 @@ private static (string? Payload, string? Shell, string? Error) ParsePowerShellPa : (payload, shell, null); } - if (option.Equals("-EncodedCommand", StringComparison.OrdinalIgnoreCase) || - option.Equals("-enc", StringComparison.OrdinalIgnoreCase) || - option.Equals("-ec", StringComparison.OrdinalIgnoreCase)) + if (IsEncodedCommandFlag(option)) { var encoded = i + 1 < tokens.Length ? tokens[i + 1] : null; - if (string.IsNullOrWhiteSpace(encoded)) - return ("", shell, "Shell wrapper payload was empty"); - - try - { - var bytes = Convert.FromBase64String(encoded); - var payload = Encoding.Unicode.GetString(bytes).Trim(); - return string.IsNullOrWhiteSpace(payload) - ? ("", shell, "EncodedCommand decoded to an empty payload") - : (payload, shell, null); - } - catch (FormatException) - { - return ("", shell, "EncodedCommand could not be decoded"); - } + return DecodeEncodedPayload(encoded, shell); } } return default; } + // Returns the index of the first ':' or '=' in a flag token (after the leading '-'). + private static int IndexOfFlagSeparator(string token) + { + for (var i = 1; i < token.Length; i++) + { + if (token[i] == ':' || token[i] == '=') + return i; + } + return -1; + } + + // Matches -Command and -c (documented PowerShell -Command aliases). + private static bool IsCommandFlag(string flag) => + flag.Equals("-Command", StringComparison.OrdinalIgnoreCase) || + flag.Equals("-c", StringComparison.OrdinalIgnoreCase); + + // Matches -e/-ec aliases and all unique prefix abbreviations of -EncodedCommand. + // Windows PowerShell accepts -e as EncodedCommand despite the apparent ambiguity with + // -ExecutionPolicy, so the parser must fail closed and decode it. + private static bool IsEncodedCommandFlag(string flag) + { + if (flag.Equals("-e", StringComparison.OrdinalIgnoreCase)) + return true; + + if (flag.Equals("-ec", StringComparison.OrdinalIgnoreCase)) + return true; + + const string fullFlag = "-encodedcommand"; + return flag.Length >= 3 && // minimum: -en + flag.Length <= fullFlag.Length && + fullFlag.StartsWith(flag, StringComparison.OrdinalIgnoreCase); + } + + private static (string? Payload, string? Shell, string? Error) DecodeEncodedPayload(string? encoded, string shell) + { + if (string.IsNullOrWhiteSpace(encoded)) + return ("", shell, "Shell wrapper payload was empty"); + + try + { + var bytes = Convert.FromBase64String(encoded); + var payload = Encoding.Unicode.GetString(bytes).Trim(); + return string.IsNullOrWhiteSpace(payload) + ? ("", shell, "EncodedCommand decoded to an empty payload") + : (payload, shell, null); + } + catch (FormatException) + { + return ("", shell, "EncodedCommand could not be decoded"); + } + } + private static List SplitTopLevelCommands(string command) { var parts = new List(); diff --git a/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs b/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs index db7d2c1f..af3724cc 100644 --- a/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs @@ -138,6 +138,89 @@ public void Expand_Powershell_EcAlias_Decodes() Assert.Contains(result.Targets, t => t.Command.Contains("Remove-Item")); } + // All unique prefix abbreviations of -EncodedCommand beyond -enc/-ec. + // Windows PowerShell also accepts -e as EncodedCommand, so include it to + // keep the shell-wrapper parser fail-closed. + [Theory] + [InlineData("-e")] + [InlineData("-en")] + [InlineData("-enco")] + [InlineData("-encod")] + [InlineData("-encode")] + [InlineData("-encoded")] + [InlineData("-encodedc")] + [InlineData("-encodedco")] + [InlineData("-encodedcom")] + [InlineData("-encodedcomm")] + [InlineData("-encodedcomma")] + [InlineData("-encodedcomman")] + [InlineData("-encodedcommand")] + public void Expand_Powershell_EncodedCommand_PrefixAbbreviation_Decodes(string flag) + { + var payload = "Get-ChildItem C:\\"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload)); + var result = Expand($"powershell {flag} {encoded}"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Get-ChildItem")); + } + + // Inline separator forms: -enc:value and -enc=value + [Theory] + [InlineData("-enc")] + [InlineData("-EncodedCommand")] + [InlineData("-encodedcommand")] + public void Expand_Powershell_EncodedCommand_ColonSeparator_Decodes(string flagBase) + { + var payload = "Invoke-Something"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload)); + var result = Expand($"powershell {flagBase}:{encoded}"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Invoke-Something")); + } + + [Theory] + [InlineData("-enc")] + [InlineData("-EncodedCommand")] + public void Expand_Powershell_EncodedCommand_EqualsSeparator_Decodes(string flagBase) + { + var payload = "Write-Host hi"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload)); + var result = Expand($"powershell {flagBase}={encoded}"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Write-Host")); + } + + // -Command separator forms + [Theory] + [InlineData("-Command")] + [InlineData("-c")] + public void Expand_Powershell_Command_ColonSeparator_ExtractsPayload(string flagBase) + { + var result = Expand($"powershell {flagBase}:Get-Process"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Get-Process")); + } + + [Theory] + [InlineData("-Command")] + [InlineData("-c")] + public void Expand_Powershell_Command_EqualsSeparator_ExtractsPayload(string flagBase) + { + var result = Expand($"powershell {flagBase}=Get-Date"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Get-Date")); + } + + [Fact] + public void Expand_Powershell_SingleE_DecodesEncodedCommand() + { + var payload = "Get-ChildItem"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload)); + var result = Expand($"powershell -e {encoded}"); + Assert.Null(result.Error); + Assert.Contains(result.Targets, t => t.Command.Contains("Get-ChildItem")); + } + [Fact] public void Expand_Powershell_EncodedCommand_EmptyPayload_ReturnsError() {