Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bbc8dfe
fix(exec): keep PowerShell /c and bash -l -c one-shot
SebTardif Sep 24, 2026
031e851
ci: retrigger after gateway restart refusal
SebTardif Sep 24, 2026
9d94eb3
fix(exec): stop shell-option scans at the script operand
SebTardif Sep 26, 2026
48255b1
fix(exec): treat combined bash -ec as an inline command
SebTardif Sep 26, 2026
e2d175c
fix(exec): keep bash -C as noclobber, not an inline command
SebTardif Sep 26, 2026
7b42fe0
fix(exec): keep PowerShell operands and fish init commands inline
SebTardif Sep 30, 2026
9e8b340
ci: retrigger after output-drain timing flake
SebTardif Sep 30, 2026
53014a7
fix(exec): treat PowerShell working-directory aliases as value options
SebTardif Oct 1, 2026
b8e82ab
fix(exec): keep PowerShell Interactive prefixes from hiding -c
SebTardif Oct 1, 2026
40bdf3e
ci: retrigger after piper extractor cancellation flake
SebTardif Oct 1, 2026
d498c26
ci: retrigger after connection tests hung
SebTardif Oct 1, 2026
b3c60bd
ci: retrigger after UI test host crash
SebTardif Oct 1, 2026
c562cad
fix(exec): match PowerShell host grammar for inline commands
SebTardif Oct 1, 2026
c470a0c
ci: retrigger after local AI credential file lock flake
SebTardif Oct 1, 2026
74fb0d5
fix(exec): accept double-dash value aliases and File prefixes
SebTardif Oct 1, 2026
13441c6
fix(exec): consume the PowerShell OutputFormat alias -of
SebTardif Oct 1, 2026
a7da850
ci: retrigger after local AI credential file lock flake
SebTardif Oct 1, 2026
413a4c2
fix(exec): treat a lone Windows PowerShell command as inline
SebTardif Oct 1, 2026
ddcf698
fix(exec): treat every Windows PowerShell positional as command text
SebTardif Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 222 additions & 11 deletions src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,63 @@ private enum WrapperKind { Posix, Cmd, PowerShell }
private sealed record WrapperSpec(WrapperKind Kind, HashSet<string> Names);

private static readonly HashSet<string> s_posixInlineFlags =
new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" };
new(StringComparer.Ordinal) { "-lc", "-c", "--command" };

private static readonly HashSet<string> 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<string> 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 =
[
Expand Down Expand Up @@ -78,12 +131,23 @@ private static ParsedWrapper ExtractInner(

private static string? ExtractPosixPayload(IReadOnlyList<string> 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<string> command)
Expand All @@ -101,18 +165,165 @@ private static ParsedWrapper ExtractInner(

private static string? ExtractPowerShellPayload(IReadOnlyList<string> 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;
}
}
Loading
Loading