From bbc8dfe0236d2109a15519223db3d633bc0a992c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 11:03:25 -0700 Subject: [PATCH 01/19] fix(exec): keep PowerShell /c and bash -l -c one-shot Allow-always could store powershell.exe /c and bash -l -c as reusable rules because those spellings were not treated as inline shells. - Treat PowerShell /c, /command, and colon-attached forms as wrappers - Treat POSIX -c, --command, and -lc in any argument after the shell name - Keep bash script.sh and bash -l script.sh bindable Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 34 +++++++--- .../ExecApprovalV2NormalizationTests.cs | 63 +++++++++++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 328d7e25f..ce2004638 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -18,7 +18,7 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" }; private static readonly HashSet s_powerShellInlineFlags = - new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" }; + new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command", "/c", "/command" }; private static readonly WrapperSpec[] s_specs = [ @@ -78,12 +78,17 @@ 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; + for (var i = 1; i < command.Count; i++) + { + var flag = command[i].Trim(); + if (flag.Length == 0) continue; + if (flag == "--") break; + if (!s_posixInlineFlags.Contains(flag)) continue; + if (i + 1 >= command.Count) return null; + var payload = command[i + 1].Trim(); + return payload.Length == 0 ? null : payload; + } + return null; } private static string? ExtractCmdPayload(IReadOnlyList command) @@ -103,9 +108,11 @@ private static ParsedWrapper ExtractInner( { 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 (TryReadPowerShellColonPayload(t, out var inline)) + return inline.Length == 0 ? null : inline; if (s_powerShellInlineFlags.Contains(t)) { if (i + 1 >= command.Count) return null; @@ -115,4 +122,15 @@ private static ParsedWrapper ExtractInner( } return null; } + + 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..adf538cea 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,68 @@ 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_BashScript_IsNotWrapper() + { + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "script.sh"]).IsWrapper); + Assert.False(ExecShellWrapperNormalizer.Extract(["bash", "-l", "script.sh"]).IsWrapper); + } + + [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) { From 031e8512a2ec8ac330cd55790038f99dc3f87ddf Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 11:23:48 -0700 Subject: [PATCH 02/19] ci: retrigger after gateway restart refusal Signed-off-by: Sebastien Tardif From 9d94eb300e8ed08c28fb71b38988f1d7bffe241a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 26 Sep 2026 11:57:57 -0700 Subject: [PATCH 03/19] fix(exec): stop shell-option scans at the script operand bash script.sh -c value and pwsh -File script.ps1 /c value are direct script invocations. Scanning past the script operand classified those arguments as inline shell commands and blocked reusable approval. Inline -c and /c before the script operand still stay one-time. Shared tests: 4125 passed, 32 skipped. Tray tests: 3067 passed, 5 failed on the LF source-contract mismatch tracked in #1518. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 31 +++++++++++++++---- .../ExecApprovalV2NormalizationTests.cs | 18 +++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index ce2004638..c445a677a 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -82,11 +82,16 @@ private static ParsedWrapper ExtractInner( { var flag = command[i].Trim(); if (flag.Length == 0) continue; - if (flag == "--") break; - if (!s_posixInlineFlags.Contains(flag)) continue; - if (i + 1 >= command.Count) return null; - var payload = command[i + 1].Trim(); - return payload.Length == 0 ? null : payload; + if (flag == "--") return null; + if (s_posixInlineFlags.Contains(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; } @@ -110,7 +115,9 @@ private static ParsedWrapper ExtractInner( { var t = command[i].Trim(); if (t.Length == 0) continue; - if (t == "--") break; + if (t == "--") return null; + if (IsPowerShellFileSwitch(t)) + return null; if (TryReadPowerShellColonPayload(t, out var inline)) return inline.Length == 0 ? null : inline; if (s_powerShellInlineFlags.Contains(t)) @@ -123,6 +130,18 @@ private static ParsedWrapper ExtractInner( return null; } + private static bool IsPowerShellFileSwitch(string token) + { + var name = token; + var colon = token.IndexOf(':'); + if (colon > 0) + name = token[..colon]; + return name.Equals("-File", StringComparison.OrdinalIgnoreCase) + || name.Equals("-f", StringComparison.OrdinalIgnoreCase) + || name.Equals("/File", StringComparison.OrdinalIgnoreCase) + || name.Equals("/f", StringComparison.OrdinalIgnoreCase); + } + private static bool TryReadPowerShellColonPayload(string token, out string payload) { payload = ""; diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index adf538cea..11baa222c 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -54,6 +54,24 @@ 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); + } + + [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] From 48255b1ce800f921131ddb0ef55c89bb3e5b5d81 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 26 Sep 2026 15:37:46 -0700 Subject: [PATCH 04/19] fix(exec): treat combined bash -ec as an inline command A single-dash letter cluster that contains c, such as -ec or -ce, is an inline shell command. The next argument is the payload. Scanning still stops at the script name, so bash script.sh -ec value stays a direct script. Shared tests: 4128 passed, 32 skipped. Tray tests: 3067 passed, 5 failed on the LF source-contract mismatch tracked in #1518. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 18 +++++++++++++++++- .../ExecApprovalV2NormalizationTests.cs | 11 +++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index c445a677a..2192ff13d 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -83,7 +83,7 @@ private static ParsedWrapper ExtractInner( var flag = command[i].Trim(); if (flag.Length == 0) continue; if (flag == "--") return null; - if (s_posixInlineFlags.Contains(flag)) + if (s_posixInlineFlags.Contains(flag) || IsPosixInlineCluster(flag)) { if (i + 1 >= command.Count) return null; var payload = command[i + 1].Trim(); @@ -130,6 +130,22 @@ private static ParsedWrapper ExtractInner( return null; } + 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] is 'c' or 'C') + sawCommand = true; + } + + return sawCommand; + } + private static bool IsPowerShellFileSwitch(string token) { var name = token; diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 11baa222c..624a245d3 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -48,6 +48,9 @@ public void Normalizer_DirectExec_IsNotWrapper() [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_BashScript_IsNotWrapper() @@ -56,6 +59,14 @@ public void Normalizer_BashScript_IsNotWrapper() 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] From e2d175c0ad5afc9e38717adc5fcba5fe90b3c27a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 26 Sep 2026 16:03:08 -0700 Subject: [PATCH 05/19] fix(exec): keep bash -C as noclobber, not an inline command A combined flag is inline only when it contains a lowercase c. Uppercase C is noclobber, so bash -C and bash -eC script.sh stay direct scripts. bash -ec remains inline. Exact POSIX flags are case-sensitive, so -C does not match -c. Shared tests: 4129 passed, 32 skipped. Tray tests: 3067 passed, 5 failed on the LF source-contract mismatch tracked in #1518. Signed-off-by: Sebastien Tardif --- .../ExecApprovals/ExecShellWrapperNormalizer.cs | 4 ++-- .../ExecApprovalV2NormalizationTests.cs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 2192ff13d..52e09bd0e 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -15,7 +15,7 @@ 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", "/c", "/command" }; @@ -139,7 +139,7 @@ private static bool IsPosixInlineCluster(string flag) { if (!char.IsLetter(flag[i])) return false; - if (flag[i] is 'c' or 'C') + if (flag[i] == 'c') sawCommand = true; } diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 624a245d3..5dea9ee44 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -52,6 +52,21 @@ public void Normalizer_DirectExec_IsNotWrapper() [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() { From 7b42fe012e17973770fe544e6cbad1f607ebfadf Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 13:35:52 -0700 Subject: [PATCH 06/19] fix(exec): keep PowerShell operands and fish init commands inline A PowerShell option value named -File stopped classification before a later -c, so the binder could save that inline command. A positional script made a later /c look like a host switch. fish -C is an init command and stays a wrapper. bash -C stays noclobber. Validation: ./build.ps1 exit 0. Shared 4132 passed, 32 skipped. ExecApprovalV2NormalizationTests 120 passed. Tray 3067 passed and 5 failed, the LF source-contract mismatch in #1518. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 42 ++++++++++++++++- .../ExecApprovalV2NormalizationTests.cs | 47 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 52e09bd0e..faf71a904 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -20,6 +20,21 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); private static readonly HashSet s_powerShellInlineFlags = new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command", "/c", "/command" }; + private static readonly HashSet s_powerShellValueOptions = + new(StringComparer.OrdinalIgnoreCase) + { + "-WorkingDirectory", "/WorkingDirectory", + "-ExecutionPolicy", "/ExecutionPolicy", + "-InputFormat", "/InputFormat", + "-OutputFormat", "/OutputFormat", + "-ConfigurationName", "/ConfigurationName", + "-CustomPipeName", "/CustomPipeName", + "-SettingsFile", "/SettingsFile", + "-PSConsoleFile", "/PSConsoleFile", + "-WindowStyle", "/WindowStyle", + "-Version", "/Version", + }; + private static readonly WrapperSpec[] s_specs = [ new(WrapperKind.Posix, new HashSet(StringComparer.OrdinalIgnoreCase) @@ -78,12 +93,13 @@ private static ParsedWrapper ExtractInner( private static string? ExtractPosixPayload(IReadOnlyList command) { + 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)) + if (s_posixInlineFlags.Contains(flag) || IsPosixInlineCluster(flag) || (fish && IsFishInitCommand(flag))) { if (i + 1 >= command.Count) return null; var payload = command[i + 1].Trim(); @@ -116,6 +132,12 @@ private static ParsedWrapper ExtractInner( var t = command[i].Trim(); if (t.Length == 0) continue; if (t == "--") return null; + if (IsPowerShellValueOption(t)) + { + i++; + continue; + } + if (IsPowerShellFileSwitch(t)) return null; if (TryReadPowerShellColonPayload(t, out var inline)) @@ -126,10 +148,28 @@ private static ParsedWrapper ExtractInner( var payload = command[i + 1].Trim(); return payload.Length == 0 ? null : payload; } + + if (!t.StartsWith('-') && !t.StartsWith('/')) + return null; } return null; } + 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) + { + var colon = token.IndexOf(':'); + if (colon > 0) + return false; + + return s_powerShellValueOptions.Contains(token); + } + private static bool IsPosixInlineCluster(string flag) { if (flag.Length < 3 || flag[0] != '-' || flag[1] == '-') diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 5dea9ee44..196691bd7 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -100,6 +100,53 @@ public void Normalizer_PowerShellFileArgument_IsNotWrapper() 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_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() { From 9e8b3400d2533994af192ccc49e88e34fd1d7720 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 14:10:27 -0700 Subject: [PATCH 07/19] ci: retrigger after output-drain timing flake Core and CLI failed only RunAsync_OutputDrainIsBoundedAndPreservesFinalFragments_WhenDescendantRetainsHandles. The drain took 1547 ms against a 1 second bound. That test is not in this change. Signed-off-by: Sebastien Tardif From 53014a78c4b4c9116117e9d9121ca99b63d5374b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 08:54:17 -0700 Subject: [PATCH 08/19] fix(exec): treat PowerShell working-directory aliases as value options pwsh binds -wd as the WorkingDirectory alias and unique prefixes such as -wo and -wor the same way. An operand named -File was classified as file mode, so a later -c could be saved as an allow-always rule. Consume those forms, and the other documented value aliases, before the file check. Normalization tests cover -wd, -wo, -wor, /wd, -w, and -ep, including the binder and the allow-always identity. Native pwsh -wd -File -c and -wo -File -c still print the inline command. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 79 +++++++++++++++---- .../ExecApprovalV2NormalizationTests.cs | 26 ++++++ 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index faf71a904..2793ad3c0 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -20,19 +20,36 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); private static readonly HashSet s_powerShellInlineFlags = new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command", "/c", "/command" }; - private static readonly HashSet s_powerShellValueOptions = + // Canonical pwsh parameters that take one following argument. A unique + // prefix binds the same way (-wo and -wor are -WorkingDirectory). + private static readonly string[] s_powerShellValueOptionNames = + [ + "WorkingDirectory", + "ExecutionPolicy", + "InputFormat", + "OutputFormat", + "ConfigurationName", + "ConfigurationFile", + "CustomPipeName", + "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) { - "-WorkingDirectory", "/WorkingDirectory", - "-ExecutionPolicy", "/ExecutionPolicy", - "-InputFormat", "/InputFormat", - "-OutputFormat", "/OutputFormat", - "-ConfigurationName", "/ConfigurationName", - "-CustomPipeName", "/CustomPipeName", - "-SettingsFile", "/SettingsFile", - "-PSConsoleFile", "/PSConsoleFile", - "-WindowStyle", "/WindowStyle", - "-Version", "/Version", + "-wd", "/wd", + "-w", "/w", + "-ep", "/ep", + "-if", "/if", + "-config", "/config", }; private static readonly WrapperSpec[] s_specs = @@ -142,7 +159,7 @@ private static ParsedWrapper ExtractInner( return null; if (TryReadPowerShellColonPayload(t, out var inline)) return inline.Length == 0 ? null : inline; - if (s_powerShellInlineFlags.Contains(t)) + if (IsPowerShellInlineFlag(t)) { if (i + 1 >= command.Count) return null; var payload = command[i + 1].Trim(); @@ -163,11 +180,43 @@ private static bool IsFishInitCommand(string flag) private static bool IsPowerShellValueOption(string token) { - var colon = token.IndexOf(':'); - if (colon > 0) + if (token.IndexOf(':') > 0) + return false; + if (s_powerShellValueAliases.Contains(token)) + return true; + if (!TryGetPowerShellSwitchBody(token, out var body)) + return false; + + var matches = 0; + foreach (var name in s_powerShellValueOptionNames) + { + if (name.StartsWith(body, StringComparison.OrdinalIgnoreCase)) + matches++; + } + + return matches == 1; + } + + private static bool IsPowerShellInlineFlag(string token) + { + if (s_powerShellInlineFlags.Contains(token)) + return true; + if (!TryGetPowerShellSwitchBody(token, out var body) || body.Length < 2) + return false; + + return "command".StartsWith(body, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetPowerShellSwitchBody(string token, out string body) + { + body = ""; + if (token.Length < 2 || (token[0] != '-' && token[0] != '/')) + return false; + if (token[1] == '-') return false; - return s_powerShellValueOptions.Contains(token); + body = token[1..]; + return body.Length > 0; } private static bool IsPosixInlineCluster(string flag) diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 196691bd7..6364536e3 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -117,6 +117,32 @@ public void Normalizer_PowerShellWorkingDirectoryOperand_DoesNotHideInlineComman 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_PowerShellPositionalScript_KeepsLaterSwitchAsScriptArgument() { From b8e82ab3c44cc659c1aee581df131f20c24538e0 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:08:59 -0700 Subject: [PATCH 09/19] fix(exec): keep PowerShell Interactive prefixes from hiding -c -i and -in match Interactive, which takes no argument. The value-option prefix matcher treated them as InputFormat and skipped the following -c, so the inline command could be saved as an allow-always rule. A prefix that also matches a no-value switch stays a switch. -inp still consumes its format value. Normalization tests cover -i, -in, and -int with the binder and the allow-always identity, and -inp Text -c still stays inline. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 30 +++++++++++++++++-- .../ExecApprovalV2NormalizationTests.cs | 23 ++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 2793ad3c0..130fafb50 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -20,8 +20,25 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); private static readonly HashSet s_powerShellInlineFlags = 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). + // 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", @@ -186,15 +203,22 @@ private static bool IsPowerShellValueOption(string token) return true; if (!TryGetPowerShellSwitchBody(token, out var body)) return false; + if (CountPrefixMatches(body, s_powerShellSwitchNames) > 0) + return false; + return CountPrefixMatches(body, s_powerShellValueOptionNames) == 1; + } + + private static int CountPrefixMatches(string body, string[] names) + { var matches = 0; - foreach (var name in s_powerShellValueOptionNames) + foreach (var name in names) { if (name.StartsWith(body, StringComparison.OrdinalIgnoreCase)) matches++; } - return matches == 1; + return matches; } private static bool IsPowerShellInlineFlag(string token) diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 6364536e3..d90b37e09 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -143,6 +143,29 @@ public void Normalizer_PowerShellWorkingDirectoryAlias_DoesNotHideInlineCommand( 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_PowerShellPositionalScript_KeepsLaterSwitchAsScriptArgument() { From 40bdf3e67765b9ab03848e68ce32f28214c0a999 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:40:11 -0700 Subject: [PATCH 10/19] ci: retrigger after piper extractor cancellation flake Signed-off-by: Sebastien Tardif From d498c266432b625e5332e09d79c79b2df2c11c19 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 10:41:34 -0700 Subject: [PATCH 11/19] ci: retrigger after connection tests hung Signed-off-by: Sebastien Tardif From b3c60bdd97e6b56d4fe65c2968e46adb3c77af85 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 11:00:36 -0700 Subject: [PATCH 12/19] ci: retrigger after UI test host crash Signed-off-by: Sebastien Tardif From c562cad9956f7924d30a9b242f715ee2ac2992c8 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 11:22:41 -0700 Subject: [PATCH 13/19] fix(exec): match PowerShell host grammar for inline commands pwsh --InputFormat and --c still hid a later command. Windows PowerShell -i and -in are InputFormat, not Interactive, and a positional token with more arguments is command text rather than a script. A lone powershell script.ps1 stays a script. EncodedCommand operands stay consumed so they are not resolved as executables. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 44 ++++++++++++++++--- .../ExecApprovalV2NormalizationTests.cs | 27 ++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 130fafb50..b978f541d 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -48,6 +48,7 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); "ConfigurationName", "ConfigurationFile", "CustomPipeName", + "EncodedCommand", "SettingsFile", "PSConsoleFile", "WindowStyle", @@ -65,6 +66,8 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); "-wd", "/wd", "-w", "/w", "-ep", "/ep", + "-e", "/e", + "-ec", "/ec", "-if", "/if", "-config", "/config", }; @@ -161,12 +164,13 @@ 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(); if (t.Length == 0) continue; if (t == "--") return null; - if (IsPowerShellValueOption(t)) + if (IsPowerShellValueOption(t, windowsPowerShell)) { i++; continue; @@ -184,18 +188,27 @@ private static ParsedWrapper ExtractInner( } if (!t.StartsWith('-') && !t.StartsWith('/')) + { + // Windows PowerShell joins a positional token and everything + // after it into command text. A lone positional stays a script. + if (windowsPowerShell && i + 1 < command.Count) + return t; return 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) + private static bool IsPowerShellValueOption(string token, bool windowsPowerShell) { if (token.IndexOf(':') > 0) return false; @@ -203,12 +216,21 @@ private static bool IsPowerShellValueOption(string token) return true; if (!TryGetPowerShellSwitchBody(token, out var body)) return false; - if (CountPrefixMatches(body, s_powerShellSwitchNames) > 0) + + var switchMatches = CountPrefixMatches(body, s_powerShellSwitchNames); + var valueMatches = CountPrefixMatches(body, s_powerShellValueOptionNames); + if (switchMatches > 0 && + !(windowsPowerShell && IsInteractivePrefix(body) && valueMatches == 1)) + { return false; + } - return CountPrefixMatches(body, s_powerShellValueOptionNames) == 1; + return valueMatches == 1; } + private static bool IsInteractivePrefix(string body) => + "interactive".StartsWith(body, StringComparison.OrdinalIgnoreCase); + private static int CountPrefixMatches(string body, string[] names) { var matches = 0; @@ -225,7 +247,11 @@ private static bool IsPowerShellInlineFlag(string token) { if (s_powerShellInlineFlags.Contains(token)) return true; - if (!TryGetPowerShellSwitchBody(token, out var body) || body.Length < 2) + 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); @@ -234,10 +260,14 @@ private static bool IsPowerShellInlineFlag(string token) 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; - if (token[1] == '-') - return false; body = token[1..]; return body.Length > 0; diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index d90b37e09..f16ac3376 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -166,6 +166,33 @@ public void Normalizer_PowerShellInteractivePrefix_DoesNotHideInlineCommand() 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); + + 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() { From c470a0c2ae53047b764704f48de26d33c7ad8aac Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 11:44:01 -0700 Subject: [PATCH 14/19] ci: retrigger after local AI credential file lock flake Signed-off-by: Sebastien Tardif From 74fb0d5bd34b3c233fb19e9df3aec27c2716be96 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 12:05:07 -0700 Subject: [PATCH 15/19] fix(exec): accept double-dash value aliases and File prefixes pwsh strips one dash before alias lookup, so --wd is -wd and must be consumed before -File. Windows PowerShell accepts -fi and -fil as -File. Those prefixes stay script invocations, including when another argument follows. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 24 +++++++++++++++---- .../ExecApprovalV2NormalizationTests.cs | 18 ++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index b978f541d..558fa6e03 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -216,6 +216,8 @@ private static bool IsPowerShellValueOption(string token, bool windowsPowerShell 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); @@ -231,6 +233,19 @@ private static bool IsPowerShellValueOption(string token, bool windowsPowerShell 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; @@ -295,10 +310,11 @@ private static bool IsPowerShellFileSwitch(string token) var colon = token.IndexOf(':'); if (colon > 0) name = token[..colon]; - return name.Equals("-File", StringComparison.OrdinalIgnoreCase) - || name.Equals("-f", StringComparison.OrdinalIgnoreCase) - || name.Equals("/File", StringComparison.OrdinalIgnoreCase) - || name.Equals("/f", StringComparison.OrdinalIgnoreCase); + 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) diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index f16ac3376..84e8b4791 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -178,6 +178,24 @@ public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() "Write-Output marker; #"); Assert.False(ExecShellWrapperNormalizer.Extract( ["pwsh", "-NoProfile", "Write-Output marker; #", "-c", "Get-Date"]).IsWrapper); + AssertWrapper(["pwsh", "--wd", "-File", "-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.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"], From 13441c63776e51be53e0982c0cd15306037bc21c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 12:24:26 -0700 Subject: [PATCH 16/19] fix(exec): consume the PowerShell OutputFormat alias -of of is the OutputFormat alias and is not a prefix of OutputFormat. pwsh -of Text -c runs the inline command, but the scanner stopped at Text and allowed a reusable identity. Consume -of and --of before the next token. Signed-off-by: Sebastien Tardif --- .../ExecApprovals/ExecShellWrapperNormalizer.cs | 1 + .../ExecApprovalV2NormalizationTests.cs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 558fa6e03..14d0f2c4c 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -70,6 +70,7 @@ private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); "-ec", "/ec", "-if", "/if", "-config", "/config", + "-of", "/of", }; private static readonly WrapperSpec[] s_specs = diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 84e8b4791..5fe408b2e 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -179,6 +179,8 @@ public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() Assert.False(ExecShellWrapperNormalizer.Extract( ["pwsh", "-NoProfile", "Write-Output marker; #", "-c", "Get-Date"]).IsWrapper); 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( @@ -190,6 +192,12 @@ public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() 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, From a7da850e496c5a79271a4405de1b5f560cf25e75 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 12:42:36 -0700 Subject: [PATCH 17/19] ci: retrigger after local AI credential file lock flake Signed-off-by: Sebastien Tardif From 413a4c2d47785e78caaa8abd1138f82da76f3be7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 13:22:22 -0700 Subject: [PATCH 18/19] fix(exec): treat a lone Windows PowerShell command as inline powershell Get-Date runs as command text, but a lone positional was saved as a script. Command text is now inline, including when nothing follows it. A lone script.ps1 path stays a script. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 24 +++++++++++++++---- .../ExecApprovalV2NormalizationTests.cs | 9 +++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 14d0f2c4c..05927822e 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -190,11 +190,13 @@ private static ParsedWrapper ExtractInner( if (!t.StartsWith('-') && !t.StartsWith('/')) { - // Windows PowerShell joins a positional token and everything - // after it into command text. A lone positional stays a script. - if (windowsPowerShell && i + 1 < command.Count) - return t; - return null; + // Windows PowerShell defaults to -Command. A lone script path + // stays a script. Any other positional text is command code. + if (!windowsPowerShell) + return null; + if (i + 1 >= command.Count && LooksLikeScriptPath(t)) + return null; + return t; } } return null; @@ -203,6 +205,18 @@ private static ParsedWrapper ExtractInner( private static bool IsWindowsPowerShellHost(string executable) => ExecCommandToken.NormalizedBasename(executable).Equals("powershell", StringComparison.Ordinal); + private static bool LooksLikeScriptPath(string token) + { + if (token.IndexOfAny([' ', ';', '|', '&', '\n', '\r']) >= 0) + return false; + + return token.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase) || + token.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase) || + token.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase) || + token.Contains('\\', StringComparison.Ordinal) || + token.Contains('/', StringComparison.Ordinal); + } + private static bool IsFishShell(string token) => ExecCommandToken.NormalizedBasename(token).Equals("fish", StringComparison.OrdinalIgnoreCase); diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 5fe408b2e..26c532449 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -178,6 +178,15 @@ public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() "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"); + Assert.False(ExecShellWrapperNormalizer.Extract(["powershell", "script.ps1"]).IsWrapper); + 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"); From ddcf698fd5d1cee6d7f27077897775348b6cb28a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 13:41:34 -0700 Subject: [PATCH 19/19] fix(exec): treat every Windows PowerShell positional as command text A lone script name is still implicit -Command. Guessing from a .ps1 suffix left that command bindable. Explicit -File remains the reusable script form. Signed-off-by: Sebastien Tardif --- .../ExecShellWrapperNormalizer.cs | 22 +++-------------- .../ExecApprovalV2NormalizationTests.cs | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs index 05927822e..d3019fe33 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -190,13 +190,9 @@ private static ParsedWrapper ExtractInner( if (!t.StartsWith('-') && !t.StartsWith('/')) { - // Windows PowerShell defaults to -Command. A lone script path - // stays a script. Any other positional text is command code. - if (!windowsPowerShell) - return null; - if (i + 1 >= command.Count && LooksLikeScriptPath(t)) - return null; - return t; + // Windows PowerShell defaults to -Command for positional text, + // including a lone script name. Explicit -File stays a script. + return windowsPowerShell ? t : null; } } return null; @@ -205,18 +201,6 @@ private static ParsedWrapper ExtractInner( private static bool IsWindowsPowerShellHost(string executable) => ExecCommandToken.NormalizedBasename(executable).Equals("powershell", StringComparison.Ordinal); - private static bool LooksLikeScriptPath(string token) - { - if (token.IndexOfAny([' ', ';', '|', '&', '\n', '\r']) >= 0) - return false; - - return token.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase) || - token.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase) || - token.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase) || - token.Contains('\\', StringComparison.Ordinal) || - token.Contains('/', StringComparison.Ordinal); - } - private static bool IsFishShell(string token) => ExecCommandToken.NormalizedBasename(token).Equals("fish", StringComparison.OrdinalIgnoreCase); diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 26c532449..97fe55716 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -180,7 +180,7 @@ public void Normalizer_PowerShellHostGrammar_DoesNotHideInlineCommand() ["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"); - Assert.False(ExecShellWrapperNormalizer.Extract(["powershell", "script.ps1"]).IsWrapper); + AssertWrapper(["powershell", "script.ps1"], "script.ps1"); Assert.Null(ExecReusableCommandBinder.TryBind( ["powershell.exe", "Get-Date"], cwd: null, @@ -1087,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]