feat: normalize exec approval command identity

Normalize command identity for exec approvals and fail closed on PowerShell EncodedCommand abbreviations, including -en.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
AlexAlves87 2026-05-08 01:26:48 +02:00 committed by GitHub
parent bcd1e633e6
commit 57ebebc725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1809 additions and 0 deletions

View File

@ -0,0 +1,70 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Architectural barrier produced by PR3.
// Equivalent to ExecHostValidatedRequest in the macOS reference, extended with resolution outputs.
// No module from PR4 onward may accept ValidatedRunRequest as direct input (research doc 05 line 439).
// Rail 15: a single canonical representation reused across evaluation, logging, prompting, execution.
public sealed class CanonicalCommandIdentity
{
// ── Normalization outputs ─────────────────────────────────────────────────
// Argv exactly as produced by PR2 (no trimming; coding contract process-argv-semantics).
public IReadOnlyList<string> Command { get; }
// Canonical display form generated from argv. Never rawCommand from the agent.
// Used by logging and prompting. Research doc 05 decision 2.
public string DisplayCommand { get; }
// Safe rawCommand for executable resolution. Null in Windows v1 (rawCommand not in
// system.run protocol; research doc 05 OQ-V4 / decision 10).
public string? EvaluationRawCommand { get; }
// ── Resolution outputs ────────────────────────────────────────────────────
// Singular resolution for the state machine (PR5).
// Null if the primary executable cannot be determined.
public ExecCommandResolution? Resolution { get; }
// Per-segment resolutions for the allowlist matcher (PR4/PR5).
// Empty list means fail-closed — no allowlist satisfaction possible.
public IReadOnlyList<ExecCommandResolution> AllowlistResolutions { get; }
// Suggested allowlist patterns for prompt/UI (PR6). Not a security decision.
public IReadOnlyList<string> AllowAlwaysPatterns { get; }
// ── Request context (carried from ValidatedRunRequest) ────────────────────
public string? Cwd { get; }
public int TimeoutMs { get; }
public IReadOnlyDictionary<string, string>? Env { get; }
public string? AgentId { get; }
public string? SessionKey { get; }
internal CanonicalCommandIdentity(
IReadOnlyList<string> command,
string displayCommand,
string? evaluationRawCommand,
ExecCommandResolution? resolution,
IReadOnlyList<ExecCommandResolution> allowlistResolutions,
IReadOnlyList<string> allowAlwaysPatterns,
string? cwd,
int timeoutMs,
IReadOnlyDictionary<string, string>? env,
string? agentId,
string? sessionKey)
{
Command = command;
DisplayCommand = displayCommand;
EvaluationRawCommand = evaluationRawCommand;
Resolution = resolution;
AllowlistResolutions = allowlistResolutions;
AllowAlwaysPatterns = allowAlwaysPatterns;
Cwd = cwd;
TimeoutMs = timeoutMs;
Env = env;
AgentId = agentId;
SessionKey = sessionKey;
}
}

View File

@ -0,0 +1,85 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Either a CanonicalCommandIdentity (IsResolved=true) or a typed denial (IsResolved=false).
// Produced by ExecApprovalV2Normalizer; consumed by the coordinator pipeline (PR7).
public sealed class ExecApprovalV2NormalizationOutcome
{
public bool IsResolved { get; }
public CanonicalCommandIdentity? Identity { get; }
public ExecApprovalV2Result? Error { get; }
private ExecApprovalV2NormalizationOutcome(CanonicalCommandIdentity identity)
{
IsResolved = true;
Identity = identity;
}
private ExecApprovalV2NormalizationOutcome(ExecApprovalV2Result error)
{
IsResolved = false;
Error = error;
}
public static ExecApprovalV2NormalizationOutcome Ok(CanonicalCommandIdentity identity)
=> new(identity);
public static ExecApprovalV2NormalizationOutcome Fail(ExecApprovalV2Result error)
=> new(error);
}
// Rail 18 steps 2-4: normalize command form → resolve executable → build canonical identity.
// Stateless — safe to call concurrently.
public static class ExecApprovalV2Normalizer
{
public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest request)
{
var argv = request.Argv;
var cwd = request.Cwd;
var env = request.Env as IReadOnlyDictionary<string, string>;
// displayCommand is always derived from argv, never from rawCommand (research doc 05 decision 2).
var displayCommand = ShellQuoting.FormatExecCommand(argv);
// rawCommand is null in Windows v1 (system.run does not carry it; research doc 05 OQ-V4).
// EvaluationRawCommand stays null — correct and documented conservative output.
string? evaluationRawCommand = null;
// Singular resolution for state machine.
var resolution = ExecCommandResolver.Resolve(argv, cwd, env);
// Multi-segment resolution for allowlist.
// Empty list is fail-closed: no allowlist satisfaction possible (research doc 04 R2).
// An empty list is NOT itself a denial at this step — the evaluator decides.
var allowlistResolutions = ExecCommandResolver.ResolveForAllowlist(
argv, evaluationRawCommand, cwd, env);
// UX patterns for prompting.
var allowAlwaysPatterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(argv, cwd, env);
// Rail 6: if argv is non-empty but resolution is entirely impossible, deny.
// "Ambiguous or inconsistent" → typed deny, not silent allow.
if (resolution is null && allowlistResolutions.Count == 0)
return Fail("executable-resolution-failed");
var identity = new CanonicalCommandIdentity(
argv,
displayCommand,
evaluationRawCommand,
resolution,
allowlistResolutions,
allowAlwaysPatterns,
cwd,
request.TimeoutMs,
env,
request.AgentId,
request.SessionKey);
return ExecApprovalV2NormalizationOutcome.Ok(identity);
}
private static ExecApprovalV2NormalizationOutcome Fail(string reason)
=> ExecApprovalV2NormalizationOutcome.Fail(
ExecApprovalV2Result.ResolutionFailed(reason));
}

View File

@ -0,0 +1,501 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenClaw.Shared.ExecApprovals;
// Resolved identity of a single executable token.
// Shape mirrors macOS ExecCommandResolution struct.
public readonly record struct ExecCommandResolution(
string RawExecutable,
string? ResolvedPath,
string ExecutableName,
string? Cwd);
// The three resolution functions required by the pipeline.
// resolve() → singular, for state machine
// ResolveForAllowlist() → multi-segment, fail-closed, for allowlist matching
// ResolveAllowAlwaysPatterns() → UX suggestions for prompt
internal static class ExecCommandResolver
{
// Windows executable extensions, tried in order for basename search.
private static readonly string[] s_extensions = [".exe", ".cmd", ".bat", ".com"];
// ── Public API ───────────────────────────────────────────────────────────
// Singular resolution of the primary executable for the state machine.
// Returns null if the command is empty or resolution is impossible.
// Unwraps transparent env prefixes (no modifiers).
internal static ExecCommandResolution? Resolve(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count == 0) return null;
var raw = effective[0].Trim();
return raw.Length == 0 ? null : ResolveExecutable(raw, cwd, env);
}
// Multi-segment resolution for allowlist matching.
// Detects shell wrappers; splits payload chain; resolves one executable per segment.
// Returns empty list (fail-closed) on any ambiguity, command substitution, or env manipulation.
internal static IReadOnlyList<ExecCommandResolution> ResolveForAllowlist(
IReadOnlyList<string> command,
string? evaluationRawCommand,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
// Fail-closed: any env invocation with modifiers (flags or VAR=val assignments).
// The allowlist cannot verify which executable will actually run under a modified env —
// the resolver uses the original env while execution uses the modified one.
// Subsumes the previous shell-wrapper-only check (Hanselman review finding #2).
if (command.Count > 0
&& ExecCommandToken.IsEnv(command[0].Trim())
&& ExecEnvInvocationUnwrapper.HasModifiers(command))
return [];
var wrapper = ExecShellWrapperNormalizer.Extract(command);
if (wrapper.IsWrapper)
{
if (wrapper.InlineCommand is null) return [];
var segments = SplitShellCommandChain(wrapper.InlineCommand);
if (segments is null) return [];
var resolutions = new List<ExecCommandResolution>(segments.Count);
foreach (var segment in segments)
{
var token = ParseFirstToken(segment);
if (token is null) return [];
// -EncodedCommand and aliases in segment position: fail-closed (research doc 04 S1).
if (SegmentUsesEncodedCommand(segment, token)) return [];
var res = ResolveExecutable(token, cwd, env);
if (res is null) return [];
resolutions.Add(res.Value);
}
return resolutions;
}
// Direct exec: fail-closed if powershell/pwsh invoked directly with -EncodedCommand.
// Covers top-level `["powershell", "-enc", ...]` and transparent `["env", "pwsh", "-enc", ...]`.
if (DirectExecUsesEncodedCommand(command)) return [];
var single = ResolveSingle(command, evaluationRawCommand, cwd, env);
return single is null ? [] : [single.Value];
}
// UX suggestions of allowlist patterns for prompting.
// Unlike ResolveForAllowlist, this unwraps env with modifiers to surface the real executable.
internal static IReadOnlyList<string> ResolveAllowAlwaysPatterns(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var patterns = new List<string>();
CollectPatterns(command, cwd, env, seen, patterns, 0);
return patterns;
}
// ── Resolution helpers ───────────────────────────────────────────────────
private static ExecCommandResolution? ResolveSingle(
IReadOnlyList<string> command,
string? rawCommand,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
// Prefer first token of evaluationRawCommand when present.
if (!string.IsNullOrWhiteSpace(rawCommand))
{
var token = ParseFirstToken(rawCommand);
if (token is not null) return ResolveExecutable(token, cwd, env);
}
return Resolve(command, cwd, env);
}
private static ExecCommandResolution? ResolveExecutable(
string rawExecutable,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
try
{
var expanded = ExpandTilde(rawExecutable);
var hasSep = expanded.Contains('/') || expanded.Contains('\\');
string? resolvedPath;
if (hasSep)
{
// Reject paths with ':' in non-volume-separator positions (ADS, non-standard forms).
if (HasNonStandardColon(expanded)) return null;
resolvedPath = Path.IsPathFullyQualified(expanded)
? Path.GetFullPath(expanded)
: Path.GetFullPath(expanded, string.IsNullOrWhiteSpace(cwd)
? Directory.GetCurrentDirectory()
: cwd.Trim());
}
else
{
resolvedPath = FindInPath(expanded, GetSearchPaths(env), GetPathExtensions(env));
}
var name = resolvedPath is not null ? Path.GetFileName(resolvedPath) : expanded;
return new ExecCommandResolution(expanded, resolvedPath, name, cwd);
}
catch { return null; } // fail-closed; intentionally broad — add diagnostic tracing here if needed
}
// ── Shell command chain splitting ────────────────────────────────────────
// Splits a shell command string on ;, &&, ||, |, &, \n.
// Returns null (fail-closed) on command/process substitution: $(...), `...`, <(...), >(...).
// Returns null on unclosed quotes or unresolved escapes.
private static IReadOnlyList<string>? SplitShellCommandChain(string command)
{
var trimmed = command.Trim();
if (trimmed.Length == 0) return null;
var segments = new List<string>();
var current = new StringBuilder();
bool inSingle = false, inDouble = false, escaped = false;
var chars = trimmed.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
var ch = chars[i];
char? next = i + 1 < chars.Length ? chars[i + 1] : null;
if (escaped) { current.Append(ch); escaped = false; continue; }
if (ch == '\\' && !inSingle) { current.Append(ch); escaped = true; continue; }
if (ch == '\'' && !inDouble) { inSingle = !inSingle; current.Append(ch); continue; }
if (ch == '"' && !inSingle) { inDouble = !inDouble; current.Append(ch); continue; }
// Fail-closed on command/process substitution.
if (!inSingle && IsCommandSubstitution(ch, next, inDouble)) return null;
if (!inSingle && !inDouble)
{
var step = DelimiterStep(ch, i > 0 ? chars[i - 1] : (char?)null, next);
if (step.HasValue)
{
var seg = current.ToString().Trim();
if (seg.Length == 0) return null;
segments.Add(seg);
current.Clear();
i += step.Value - 1;
continue;
}
}
current.Append(ch);
}
if (escaped || inSingle || inDouble) return null;
var last = current.ToString().Trim();
if (last.Length == 0) return null;
segments.Add(last);
return segments;
}
private static bool IsCommandSubstitution(char ch, char? next, bool inDouble)
{
if (inDouble) return ch == '`' || (ch == '$' && next == '(');
return ch == '`' ||
(ch == '$' && next == '(') ||
(ch == '<' && next == '(') ||
(ch == '>' && next == '(');
}
private static int? DelimiterStep(char ch, char? prev, char? next)
{
if (ch == ';' || ch == '\n') return 1;
if (ch == '&')
{
if (next == '&') return 2;
return (prev == '>' || next == '>') ? null : (int?)1;
}
if (ch == '|')
{
if (next == '|' || next == '&') return 2;
return 1;
}
return null;
}
// Extracts the first shell-tokenized word from a command string.
private static string? ParseFirstToken(string command)
{
var trimmed = command.Trim();
if (trimmed.Length == 0) return null;
var first = trimmed[0];
if (first == '"' || first == '\'')
{
var rest = trimmed.AsSpan(1);
var end = rest.IndexOf(first);
if (end < 0) return null; // unclosed quote — fail-closed; do not guess the token
var inner = rest[..end].ToString();
if (inner.Length == 0) return null;
// Preserve any suffix after the closing quote up to the next whitespace.
// Handles `"git".exe` → "git.exe" and `"C:\Program Files\Git\bin\git".exe` → *.exe.
var afterClose = rest[(end + 1)..];
var suffixEnd = afterClose.IndexOfAny(' ', '\t');
var suffix = suffixEnd >= 0 ? afterClose[..suffixEnd].ToString() : afterClose.ToString();
return suffix.Length > 0 ? inner + suffix : inner;
}
var space = trimmed.AsSpan().IndexOfAny(' ', '\t');
return space >= 0 ? trimmed[..space] : trimmed;
}
// ── allowAlwaysPatterns collection ───────────────────────────────────────
private static void CollectPatterns(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env,
HashSet<string> seen,
List<string> patterns,
int depth)
{
if (depth >= 3 || command.Count == 0) return;
var wrapper = ExecShellWrapperNormalizer.Extract(command);
if (wrapper.IsWrapper && wrapper.InlineCommand is not null)
{
var segments = SplitShellCommandChain(wrapper.InlineCommand);
if (segments is null) return;
foreach (var seg in segments)
{
// allowAlwaysPatterns does NOT fail-closed on -EncodedCommand: it's UX only.
var token = ParseFirstToken(seg);
if (token is null) continue;
var res = ResolveExecutable(token, cwd, env);
if (res is null) continue;
var pattern = res.Value.ResolvedPath ?? res.Value.RawExecutable;
if (seen.Add(pattern)) patterns.Add(pattern);
}
return;
}
// For direct exec, unwrap env including with-modifier cases for pattern discovery.
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count == 0) return;
var rawToken = effective[0].Trim();
if (rawToken.Length == 0) return;
var resolution = ResolveExecutable(rawToken, cwd, env);
if (resolution is null) return;
var pat = resolution.Value.ResolvedPath ?? resolution.Value.RawExecutable;
if (seen.Add(pat)) patterns.Add(pat);
}
// ── -EncodedCommand detection ─────────────────────────────────────────────
// Research doc 04 S1: if a chain segment invokes PowerShell with -EncodedCommand (or any
// alias / unambiguous prefix abbreviation), the payload is opaque base64 — fail-closed.
// Only triggers when the first token IS a PowerShell binary AND the segment contains the flag.
// `powershell -c 'Get-Date'` (no -enc) must NOT be fail-closed.
private static bool SegmentUsesEncodedCommand(string segment, string firstToken)
{
var b = ExecCommandToken.NormalizedBasename(firstToken);
if (b is not ("powershell" or "pwsh")) return false;
var rest = segment.AsSpan();
while (rest.Length > 0)
{
var i = 0;
while (i < rest.Length && char.IsWhiteSpace(rest[i])) i++;
rest = rest[i..];
if (rest.Length == 0) break;
// Extract next token — quoted strings count as one unit so `"-enc"` is detected.
int end;
if (rest[0] is '"' or '\'')
{
var q = rest[0];
end = 1;
while (end < rest.Length && rest[end] != q) end++;
if (end < rest.Length) end++; // include closing quote
}
else
{
end = 0;
while (end < rest.Length && !char.IsWhiteSpace(rest[end])) end++;
}
var token = rest[..end].ToString();
rest = rest[end..];
if (IsEncodedCommandFlag(token)) return true;
if (token == "--") break;
}
return false;
}
// Returns true when a raw flag token (possibly quoted, possibly with colon/equals value suffix)
// represents -EncodedCommand or any of its unambiguous prefix abbreviations.
// Covers: "-EncodedCommand", "-enc", "-ec", "-e", `"-enc"`, `-enc:payload`, `-encod`, etc.
private static bool IsEncodedCommandFlag(string rawToken)
{
var t = rawToken;
if (t.Length >= 2 && t[0] is '"' or '\'' && t[^1] == t[0])
t = t[1..^1]; // strip matching outer quotes
if (t.Length == 0 || t[0] != '-') return false;
// Strip trailing :value or =value (e.g. -EncodedCommand:base64).
var sep = t.AsSpan(1).IndexOfAny('=', ':');
var flag = (sep >= 0 ? t[..(sep + 1)] : t).ToLowerInvariant();
// -e is accepted by Windows PowerShell as a short alias for -EncodedCommand.
if (flag is "-e" or "-ec" or "-enc" or "-encodedcommand") return true;
// Any unambiguous prefix abbreviation of -encodedcommand beginning at -en.
const string full = "-encodedcommand";
return flag.Length >= 3 && full.StartsWith(flag, StringComparison.Ordinal);
}
// True when direct exec (no shell wrapper) is a PowerShell invocation with -EncodedCommand.
// Unwraps transparent env prefixes so `["env", "pwsh", "-enc", ...]` is also caught.
private static bool DirectExecUsesEncodedCommand(IReadOnlyList<string> command)
{
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count < 2) return false;
var b = ExecCommandToken.NormalizedBasename(effective[0].Trim());
if (b is not ("powershell" or "pwsh")) return false;
for (var i = 1; i < effective.Count; i++)
{
var t = effective[i].Trim();
if (t == "--") break;
if (IsEncodedCommandFlag(t)) return true;
}
return false;
}
// ── PATH search ───────────────────────────────────────────────────────────
private static string? GetEnvValueIgnoreCase(IReadOnlyDictionary<string, string>? env, string key)
{
if (env is null) return null;
foreach (var kvp in env)
{
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
return kvp.Value;
}
return null;
}
private static string? FindInPath(
string name,
IReadOnlyList<string> searchPaths,
IReadOnlyList<string> extensions)
{
foreach (var dir in searchPaths)
{
if (string.IsNullOrEmpty(dir)) continue;
var candidate = Path.Combine(dir, name);
// PATHEXT extensions first — matches Windows CreateProcess resolution order.
// A no-extension shadow in PATH must not shadow a PATHEXT binary of the same stem.
// Note: PATHEXT is probed even when `name` already carries an extension (git.exe →
// tries git.exe.exe, git.exe.cmd, …). This matches CreateProcess behavior — the extra
// File.Exists calls are harmless and avoiding them would require extension detection here.
foreach (var ext in extensions)
{
var withExt = candidate + ext;
if (File.Exists(withExt)) return TryNormalizePath(withExt);
}
// Bare name as final fallback (covers names that already have an explicit extension).
if (File.Exists(candidate)) return TryNormalizePath(candidate);
}
return null;
}
private static IReadOnlyList<string> GetSearchPaths(IReadOnlyDictionary<string, string>? env)
{
var rawPath = GetEnvValueIgnoreCase(env, "PATH");
if (!string.IsNullOrEmpty(rawPath))
{
var parts = rawPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
// Fallback to process PATH.
var processPath = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(processPath))
{
var parts = processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
return WellKnownPaths();
}
private static IReadOnlyList<string> GetPathExtensions(IReadOnlyDictionary<string, string>? env)
{
var rawPathExt = GetEnvValueIgnoreCase(env, "PATHEXT");
if (!string.IsNullOrEmpty(rawPathExt))
{
var parts = rawPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
var processPathExt = Environment.GetEnvironmentVariable("PATHEXT");
if (!string.IsNullOrEmpty(processPathExt))
{
var parts = processPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
return s_extensions;
}
private static IReadOnlyList<string> WellKnownPaths()
{
var sys32 = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32");
var sys = Environment.GetFolderPath(Environment.SpecialFolder.System);
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
return
[
sys32,
sys,
Path.Combine(sys32, "OpenSSH"),
Path.Combine(pf, "Git", "usr", "bin"),
Path.Combine(pf, "Git", "bin"),
];
}
// ── Path helpers ──────────────────────────────────────────────────────────
private static string ExpandTilde(string path)
{
if (!path.StartsWith('~')) return path;
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return path.Length == 1 ? home : home + path[1..];
}
// Paths with ':' outside the volume-separator position are rejected (ADS, non-standard forms).
// Research doc 04 section 3 / S3.
private static bool HasNonStandardColon(string path)
{
// Extended-length prefix — strip it and evaluate the remainder (\\?\C:\ is valid).
var effective = path.StartsWith(@"\\?\", StringComparison.Ordinal) ? path[4..] : path;
// UNC paths (\\server\share) and extended UNC (\\?\UNC\...) have no drive colon — fine.
if (effective.StartsWith(@"\\", StringComparison.Ordinal)) return false;
var colonIdx = effective.IndexOf(':');
if (colonIdx < 0) return false; // no colon — fine
// Drive-letter form: single ASCII letter at index 0 followed by ':' — fine if no second colon.
// '1', '!' etc. at index 0 are not valid drive letters and must be rejected.
if (colonIdx == 1 && char.IsAsciiLetter(effective[0]))
return effective.IndexOf(':', 2) >= 0;
return true;
}
// Attempt 8.3 → long path normalization for paths that exist on disk.
// Only applied to resolved paths from PATH search (existence already confirmed).
// Research doc 04 section canonicalization / 8.3 short names.
private static string TryNormalizePath(string path)
{
// GetFullPath resolves . and .. but does not expand 8.3 short names.
// Full GetLongPathName P/Invoke is left as OQ-R1 in the research docs.
try { return Path.GetFullPath(path); }
catch { return path; } // hostile path must not throw out of resolution
}
}

View File

@ -0,0 +1,28 @@
using System;
using System.IO;
namespace OpenClaw.Shared.ExecApprovals;
// Utility helpers for command token classification.
internal static class ExecCommandToken
{
// Returns the lowercased last path component (basename) of a token, without extension.
internal static string BasenameLower(string token)
{
var trimmed = token.Trim();
if (trimmed.Length == 0) return string.Empty;
var name = Path.GetFileName(trimmed.Replace('\\', '/'));
if (name.Length == 0) name = trimmed;
return name.ToLowerInvariant();
}
// Returns the basename without .exe suffix (lowercased).
internal static string NormalizedBasename(string token)
{
var b = BasenameLower(token);
return b.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? b[..^4] : b;
}
internal static bool IsEnv(string token) =>
NormalizedBasename(token) == "env";
}

View File

@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace OpenClaw.Shared.ExecApprovals;
// Strips `env [OPTIONS] [VAR=VAL...] COMMAND [ARGS...]` so the true executable can be resolved.
// Fail-closed: returns null when any unknown flag is encountered or the command cannot be safely
// unwrapped. Mirrors ExecEnvInvocationUnwrapper in the windows-app reference.
internal static class ExecEnvInvocationUnwrapper
{
internal const int MaxWrapperDepth = 4;
private static readonly Regex s_envAssignment =
new(@"^[A-Za-z_][A-Za-z0-9_]*=", RegexOptions.Compiled);
// Strips one level of `env` wrapper.
// Returns the remaining argv starting at the real COMMAND token, or null on any ambiguity.
internal static IReadOnlyList<string>? Unwrap(IReadOnlyList<string> command)
{
var idx = 1;
var expectsOptionValue = false;
while (idx < command.Count)
{
var token = command[idx].Trim();
if (token.Length == 0) { idx++; continue; }
if (expectsOptionValue) { expectsOptionValue = false; idx++; continue; }
if (token == "--" || token == "-") { idx++; break; }
if (s_envAssignment.IsMatch(token)) { idx++; continue; }
if (token.StartsWith('-') && token != "-")
{
var lower = token.ToLowerInvariant();
var flag = lower.Split('=', 2)[0];
if (ExecEnvOptions.FlagOnly.Contains(flag)) { idx++; continue; }
if (ExecEnvOptions.WithValue.Contains(flag))
{
if (!lower.Contains('=')) expectsOptionValue = true;
idx++;
continue;
}
if (ExecEnvOptions.InlineValuePrefixes.Any(p => lower.StartsWith(p, StringComparison.Ordinal)))
{
idx++;
continue;
}
return null; // Unknown flag — fail-closed.
}
break; // Executable token found.
}
if (idx >= command.Count) return null;
return command.Skip(idx).ToList();
}
// Returns true when the env invocation has flags or VAR=val assignments before the command.
// `--` ends option processing without modifying the environment → not a modifier.
// `-` alone replaces the environment entirely → modifier.
internal static bool HasModifiers(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
var token = command[i].Trim();
if (token.Length == 0) continue;
if (token == "--") return false;
if (token == "-") return true;
if (token.StartsWith('-')) return true;
if (s_envAssignment.IsMatch(token)) return true;
return false; // first non-modifier token is the command
}
return false;
}
// Iteratively strips env wrappers for executable resolution only.
internal static IReadOnlyList<string> UnwrapForResolution(IReadOnlyList<string> command)
{
var current = command;
for (var depth = 0; depth < MaxWrapperDepth; depth++)
{
if (current.Count == 0) break;
var token = current[0].Trim();
if (token.Length == 0) break;
if (!ExecCommandToken.IsEnv(token)) break;
var unwrapped = Unwrap(current);
if (unwrapped is null || unwrapped.Count == 0) break;
current = unwrapped;
}
return current;
}
}

View File

@ -0,0 +1,38 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Option grammar of the POSIX `env` command.
// Mirrors the constants in the windows-app reference (ExecEnvOptions.cs).
internal static class ExecEnvOptions
{
// Options that consume the next argument as their value (or use inline = form).
internal static readonly HashSet<string> WithValue = new(System.StringComparer.Ordinal)
{
"-u", "--unset",
"-c", "--chdir",
"-s", "--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
};
// Options that are standalone flags (take no value at all).
internal static readonly HashSet<string> FlagOnly = new(System.StringComparer.Ordinal)
{
"-i", "--ignore-environment",
"-0", "--null",
};
// Prefixes for the inline-value form (e.g. `-uFOO` or `--unset=FOO`).
internal static readonly IReadOnlyList<string> InlineValuePrefixes =
[
"-u", "-c", "-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
];
}

View File

@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Single-level shell wrapper detection for the V2 exec approval pipeline.
// Differs from the legacy ExecShellWrapperParser.Expand (BFS multi-level, string-based).
// This normalizer operates on argv (IReadOnlyList<string>) and performs one level of
// wrapper detection, with recursive env-prefix unwrapping up to MaxWrapperDepth.
// Rail 18 step 2: normalize command form.
internal static class ExecShellWrapperNormalizer
{
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" };
private static readonly HashSet<string> s_powerShellInlineFlags =
new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" };
private static readonly WrapperSpec[] s_specs =
[
new(WrapperKind.Posix, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ash", "sh", "bash", "zsh", "dash", "ksh", "fish" }),
new(WrapperKind.Cmd, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "cmd", "cmd.exe" }),
new(WrapperKind.PowerShell, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "powershell", "powershell.exe", "pwsh", "pwsh.exe" }),
];
internal sealed record ParsedWrapper(bool IsWrapper, string? InlineCommand);
internal static readonly ParsedWrapper NotWrapper = new(false, null);
// Detects a single-level shell wrapper in argv.
// rawCommand is always null in Windows v1 (not in system.run protocol; research doc 05 OQ-V4).
// Detection is on argv only; rawCommand is accepted for API compatibility with future use.
internal static ParsedWrapper Extract(IReadOnlyList<string> command, string? rawCommand = null)
=> ExtractInner(command, rawCommand, 0);
private static ParsedWrapper ExtractInner(
IReadOnlyList<string> command, string? rawCommand, int depth)
{
if (depth >= ExecEnvInvocationUnwrapper.MaxWrapperDepth) return NotWrapper;
if (command.Count == 0) return NotWrapper;
var token0 = command[0].Trim();
if (token0.Length == 0) return NotWrapper;
// Recursively unwrap transparent env prefixes.
if (ExecCommandToken.IsEnv(token0))
{
var unwrapped = ExecEnvInvocationUnwrapper.Unwrap(command);
if (unwrapped is null) return NotWrapper;
return ExtractInner(unwrapped, rawCommand, depth + 1);
}
var basename = ExecCommandToken.NormalizedBasename(token0);
var spec = Array.Find(s_specs, s => s.Names.Contains(basename));
if (spec is null) return NotWrapper;
var payload = ExtractPayload(command, spec);
if (payload is null) return NotWrapper;
return new ParsedWrapper(true, payload);
}
private static string? ExtractPayload(IReadOnlyList<string> command, WrapperSpec spec) =>
spec.Kind switch
{
WrapperKind.Posix => ExtractPosixPayload(command),
WrapperKind.Cmd => ExtractCmdPayload(command),
WrapperKind.PowerShell => ExtractPowerShellPayload(command),
_ => null,
};
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;
}
private static string? ExtractCmdPayload(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
if (string.Equals(command[i].Trim(), "/c", StringComparison.OrdinalIgnoreCase))
{
var tail = string.Join(" ", command.Skip(i + 1)).Trim();
return tail.Length == 0 ? null : tail;
}
}
return null;
}
private static string? ExtractPowerShellPayload(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
var t = command[i].Trim().ToLowerInvariant();
if (t.Length == 0) continue;
if (t == "--") break;
if (s_powerShellInlineFlags.Contains(t))
{
if (i + 1 >= command.Count) return null;
var payload = command[i + 1].Trim();
return payload.Length == 0 ? null : payload;
}
}
return null;
}
}

View File

@ -0,0 +1,869 @@
using System.Collections.Generic;
using Xunit;
using OpenClaw.Shared.ExecApprovals;
namespace OpenClaw.Shared.Tests;
/// <summary>
/// Tests for PR3: normalization, executable resolution, and canonical identity.
/// Covers rail 18 steps 2-4: detect shell wrappers, resolve executable, build canonical identity.
/// Tests are UI-free (rail 10) and cover the cases required by rail 13.
/// </summary>
public class ExecApprovalV2NormalizationTests
{
// ── Helpers ──────────────────────────────────────────────────────────────
private static ValidatedRunRequest Req(
string[] argv,
string? cwd = null,
IReadOnlyDictionary<string, string>? env = null,
string? agentId = null,
string? sessionKey = null) =>
new(argv, shell: null, cwd, timeoutMs: 30_000, env, agentId, sessionKey);
// ── ExecShellWrapperNormalizer ────────────────────────────────────────────
[Fact]
public void Normalizer_DirectExec_IsNotWrapper()
{
var r = ExecShellWrapperNormalizer.Extract(["echo", "hello"]);
Assert.False(r.IsWrapper);
}
[Fact] public void Normalizer_BashWrapper() => AssertWrapper(["bash", "-c", "echo hello"], "echo hello");
[Fact] public void Normalizer_ShWrapper() => AssertWrapper(["sh", "-c", "echo hello"], "echo hello");
[Fact] public void Normalizer_ZshWrapper() => AssertWrapper(["zsh", "-c", "echo hello"], "echo hello");
[Fact] public void Normalizer_CmdWrapper() => AssertWrapper(["cmd", "/c", "dir"], "dir");
[Fact] public void Normalizer_CmdExeWrapper() => AssertWrapper(["cmd.exe", "/c", "dir"], "dir");
[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");
private static void AssertWrapper(string[] argv, string expectedPayload)
{
var r = ExecShellWrapperNormalizer.Extract(argv);
Assert.True(r.IsWrapper);
Assert.Equal(expectedPayload, r.InlineCommand);
}
[Fact]
public void Normalizer_BashWithMissingPayloadToken_IsNotWrapper()
{
// ["bash", "-c"] has the flag but no payload token → payload is null → NotWrapper.
// This matches the reference (windows-app): null payload → return NotWrapper, not IsWrapper=true.
var r = ExecShellWrapperNormalizer.Extract(["bash", "-c"]);
Assert.False(r.IsWrapper);
}
[Fact]
public void Normalizer_UnknownExecutable_IsNotWrapper()
{
var r = ExecShellWrapperNormalizer.Extract(["node", "script.js"]);
Assert.False(r.IsWrapper);
}
// ── ExecEnvInvocationUnwrapper ────────────────────────────────────────────
[Fact]
public void EnvUnwrapper_TransparentEnv_UnwrapsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "echo", "hello"]);
Assert.NotNull(result);
Assert.Equal(["echo", "hello"], result);
}
[Fact]
public void EnvUnwrapper_EnvWithAssignment_UnwrapsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "FOO=bar", "echo"]);
Assert.NotNull(result);
Assert.Equal(["echo"], result);
}
[Fact]
public void EnvUnwrapper_UnknownFlag_ReturnsNull()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "--unknown-flag", "echo"]);
Assert.Null(result);
}
[Fact]
public void EnvUnwrapper_DashDash_SkipsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "--", "echo", "hi"]);
Assert.NotNull(result);
Assert.Equal(["echo", "hi"], result);
}
[Fact]
public void Normalizer_EnvBashWrapper_DetectsShellAfterEnv()
{
// env bash -c "echo hi" → IsWrapper=true (env unwrapped, then bash detected)
var r = ExecShellWrapperNormalizer.Extract(["env", "bash", "-c", "echo hi"]);
Assert.True(r.IsWrapper);
Assert.Equal("echo hi", r.InlineCommand);
}
// ── ExecCommandResolver — singular ────────────────────────────────────────
[Fact]
public void Resolver_AbsolutePath_ResolvesToSelf()
{
var sysDir = System.Environment.GetFolderPath(System.Environment.SpecialFolder.System);
var cmd32 = System.IO.Path.Combine(sysDir, "cmd.exe");
var res = ExecCommandResolver.Resolve([cmd32], cwd: null, env: null);
Assert.NotNull(res);
Assert.Equal(cmd32, res!.Value.RawExecutable);
Assert.NotNull(res.Value.ResolvedPath);
}
[Fact]
public void Resolver_UnknownBasename_ResolvesWithNullPath()
{
var res = ExecCommandResolver.Resolve(["totally-nonexistent-binary-xyz"], cwd: null, env: null);
Assert.NotNull(res);
Assert.Null(res!.Value.ResolvedPath);
Assert.Equal("totally-nonexistent-binary-xyz", res.Value.RawExecutable);
}
[Fact]
public void Resolver_EmptyArgv_ReturnsNull()
{
var res = ExecCommandResolver.Resolve([], cwd: null, env: null);
Assert.Null(res);
}
// ── ExecCommandResolver — ResolveForAllowlist ─────────────────────────────
[Fact]
public void ResolveForAllowlist_DirectExec_ReturnsSingleResolution()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["echo", "hello"], evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
}
[Fact]
public void ResolveForAllowlist_WrapperWithChain_ReturnsTwoResolutions()
{
// bash -c "echo foo && echo bar" → two segments → two resolutions
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo && echo bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
Assert.All(resolutions, r => Assert.Equal("echo", r.ExecutableName.ToLowerInvariant()
.Replace(".exe", "")));
}
[Fact]
public void ResolveForAllowlist_BashMissingPayload_ResolvesAsBashDirectExec()
{
// ["bash", "-c"] → NotWrapper (no payload token) → treated as direct exec of bash.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c"], evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
Assert.Contains("bash", resolutions[0].ExecutableName, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveForAllowlist_CommandSubstitution_ReturnsEmpty()
{
// Fail-closed: $(...) in shell payload
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo $(cat /etc/passwd)"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_Backtick_ReturnsEmpty()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo `id`"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_PowerShellEncodedCommand_ReturnsEmpty()
{
// Research doc 04 S1: -EncodedCommand payload is opaque — fail-closed for allowlist.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "powershell -enc dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_PowerShellRegularCommand_NotFailClosed()
{
// `powershell -c 'Get-Date'` is NOT -EncodedCommand — must NOT be fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "powershell -c Get-Date"],
evaluationRawCommand: null, cwd: null, env: null);
// powershell itself resolves (path may or may not be found, but not empty due to -enc)
Assert.Single(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvFlagBeforeShellWrapper_ReturnsEmpty()
{
// env -u HOME bash -c "echo hi" → env manipulation before shell wrapper → fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "-u", "HOME", "bash", "-c", "echo hi"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvAssignmentBeforeShellWrapper_ReturnsEmpty()
{
// env FOO=bar bash -c "echo hi" → VAR=val before shell wrapper → fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "FOO=bar", "bash", "-c", "echo hi"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvDashDashBeforeShellWrapper_NotFailClosed()
{
// env -- bash -c "echo hi" → -- ends options without modifying env → transparent → not fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "--", "bash", "-c", "echo hi"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.NotEmpty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvFlagBeforeDirectExec_ReturnsEmpty()
{
// env -u HOME echo hello — env has modifiers → fail-closed regardless of what follows.
// The allowlist cannot verify which executable runs under a modified environment.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "-u", "HOME", "echo", "hello"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
// ── ExecApprovalV2Normalizer — full pipeline ──────────────────────────────
[Fact]
public void Normalize_SimpleCommand_ProducesIdentity()
{
var req = Req(["echo", "hello"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
var id = outcome.Identity!;
Assert.Equal(["echo", "hello"], id.Command);
Assert.Contains("echo", id.DisplayCommand);
Assert.Null(id.EvaluationRawCommand);
}
[Fact]
public void Normalize_ArgvPreservedExactly_NoCodingContractViolation()
{
// Coding contract process-argv-semantics: no trimming of argv elements.
var req = Req([" echo ", " value "]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
Assert.Equal([" echo ", " value "], outcome.Identity!.Command);
}
[Fact]
public void Normalize_ShellWrapper_ProducesIdentityWithBothResolutions()
{
var req = Req(["bash", "-c", "echo foo && echo bar"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
var id = outcome.Identity!;
// Singular resolution resolves the wrapper itself (bash) not the inner command.
Assert.NotNull(id.Resolution);
// Allowlist resolutions resolve the inner commands.
Assert.Equal(2, id.AllowlistResolutions.Count);
}
[Fact]
public void Normalize_BashMissingPayload_ProducesIdentityForBashDirectExec()
{
// ["bash", "-c"] → NotWrapper → treated as direct exec of bash → identity produced.
var req = Req(["bash", "-c"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
Assert.Contains("bash", outcome.Identity!.DisplayCommand, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Normalize_CommandSubstitution_AllowlistResolutionsEmpty_IdentityStillProduced()
{
// Command substitution causes empty AllowlistResolutions (fail-closed for allowlist)
// but singular Resolution may still succeed — identity is produced.
var req = Req(["bash", "-c", "echo $(id)"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
// bash itself resolves; the allowlist resolutions are empty (fail-closed inner chain)
// → singular resolution is non-null → identity is produced
Assert.True(outcome.IsResolved);
Assert.Empty(outcome.Identity!.AllowlistResolutions);
}
[Fact]
public void Normalize_DisplayCommand_AlwaysFromArgv_NeverRawCommand()
{
// DisplayCommand must be generated from argv, not rawCommand (research doc 05 decision 2).
var req = Req(["bash", "-c", "echo hello"], agentId: "agent-1");
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
// DisplayCommand contains the full argv representation.
Assert.Contains("bash", outcome.Identity!.DisplayCommand);
Assert.Contains("-c", outcome.Identity!.DisplayCommand);
}
[Fact]
public void Normalize_ContextFieldsCarriedThrough()
{
var env = new Dictionary<string, string> { ["FOO"] = "bar" };
var req = Req(["echo"], cwd: @"C:\tmp", env: env, agentId: "a1", sessionKey: "s1");
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
var id = outcome.Identity!;
Assert.Equal(@"C:\tmp", id.Cwd);
Assert.Equal("a1", id.AgentId);
Assert.Equal("s1", id.SessionKey);
Assert.Equal(30_000, id.TimeoutMs);
Assert.Equal("bar", id.Env!["FOO"]);
}
[Fact]
public void Normalize_EvaluationRawCommand_AlwaysNullInV1()
{
// rawCommand is not in system.run protocol in Windows v1 (research doc 05 OQ-V4).
var req = Req(["echo", "hello"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.True(outcome.IsResolved);
Assert.Null(outcome.Identity!.EvaluationRawCommand);
}
// ── Rail compliance ───────────────────────────────────────────────────────
[Fact]
public void Normalize_ResolutionFailed_CarriesStableCode()
{
// Rail 7: every deny carries a stable code.
// An entirely unresolvable command: empty argv is caught by PR2 upstream,
// so we force a resolution failure with a command argv that has no resolvable executable.
// The Normalizer denies when both singular resolution and allowlist resolutions are empty.
// Use a path with an invalid ADS colon to force ResolveExecutable to return null.
var req = Req(["C:\\bad:stream:path\\tool.exe"]);
var outcome = ExecApprovalV2Normalizer.Normalize(req);
Assert.False(outcome.IsResolved);
Assert.Equal(ExecApprovalV2Code.ResolutionFailed, outcome.Error!.Code);
Assert.False(string.IsNullOrWhiteSpace(outcome.Error.Reason));
}
[Fact]
public void Normalize_LegacyPath_Unaffected()
{
// Rail 19: legacy path must be unaffected by new-path changes.
// The normalizer is only called from the V2 path; it does not exist in the legacy path.
// Verify the legacy ExecShellWrapperParser type still compiles and is independent.
// (Structural test — if this compiles, the legacy type is not modified.)
_ = typeof(OpenClaw.Shared.ExecShellWrapperParser);
_ = typeof(OpenClaw.Shared.ExecShellParseResult);
}
// ── SplitShellCommandChain (via ResolveForAllowlist) ──────────────────────
[Fact]
public void SplitChain_Pipe_ReturnsTwoResolutions()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo | cat"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
}
[Fact]
public void SplitChain_Semicolon_ReturnsTwoResolutions()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo; echo bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
}
[Fact]
public void SplitChain_Newline_ReturnsTwoResolutions()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo\necho bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
}
[Fact]
public void SplitChain_BackgroundOperator_ReturnsTwoResolutions()
{
// `&` (background, not &&) is a delimiter.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo & echo bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
}
[Fact]
public void SplitChain_PipeOr_ReturnsTwoResolutions()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo foo || echo bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Equal(2, resolutions.Count);
}
[Fact]
public void SplitChain_ProcessSubstitutionLt_ReturnsEmpty()
{
// <(...) is process substitution — fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "cat <(echo foo)"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void SplitChain_UnclosedSingleQuote_ReturnsEmpty()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo 'unclosed"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void SplitChain_UnclosedDoubleQuote_ReturnsEmpty()
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo \"unclosed"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void SplitChain_QuotedSemicolon_NotSplit()
{
// Semicolon inside single quotes is not a delimiter.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "echo 'hello;world'"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
}
[Fact]
public void SplitChain_BackslashEscapedSemicolon_NotSplit()
{
// Backslash-escaped semicolon is not a delimiter.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", @"echo foo\;bar"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
}
// ── ExecEnvInvocationUnwrapper — flag variants ────────────────────────────
[Fact]
public void EnvUnwrapper_IgnoreEnvironmentShortFlag_UnwrapsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "-i", "echo", "hello"]);
Assert.NotNull(result);
Assert.Equal(["echo", "hello"], result);
}
[Fact]
public void EnvUnwrapper_IgnoreEnvironmentLongFlag_UnwrapsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "--ignore-environment", "echo"]);
Assert.NotNull(result);
Assert.Equal(["echo"], result);
}
[Fact]
public void EnvUnwrapper_ChDirInlineEquals_UnwrapsToCommand()
{
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "--chdir=/tmp", "echo"]);
Assert.NotNull(result);
Assert.Equal(["echo"], result);
}
[Fact]
public void EnvUnwrapper_UnsetInlineForm_UnwrapsToCommand()
{
// -uFOO is the inline form of --unset FOO.
var result = ExecEnvInvocationUnwrapper.Unwrap(["env", "-uFOO", "echo"]);
Assert.NotNull(result);
Assert.Equal(["echo"], result);
}
[Fact]
public void EnvUnwrapper_NestedEnv_UnwrapsToInnerCommand()
{
// UnwrapForResolution handles multiple levels of env prefix.
var result = ExecEnvInvocationUnwrapper.UnwrapForResolution(
["env", "env", "echo", "hello"]);
Assert.NotEmpty(result);
Assert.Equal("echo", result[0]);
}
[Fact]
public void EnvUnwrapForResolution_UnknownFlag_ReturnsEnvItself()
{
// Unknown flag → Unwrap returns null → UnwrapForResolution stops, returns original argv.
var result = ExecEnvInvocationUnwrapper.UnwrapForResolution(
["env", "--unknown-flag", "echo"]);
Assert.NotEmpty(result);
Assert.Equal("env", result[0]);
}
// ── ExecCommandResolver.ResolveAllowAlwaysPatterns ────────────────────────
[Fact]
public void AllowAlwaysPatterns_DirectExec_ReturnsExecutablePattern()
{
var patterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(
["echo", "hello"], cwd: null, env: null);
Assert.Single(patterns);
Assert.Contains("echo", patterns[0], StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AllowAlwaysPatterns_ShellWrapper_ReturnsInnerPatterns()
{
// echo deduplicates → 1 pattern.
var patterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(
["bash", "-c", "echo foo && echo bar"], cwd: null, env: null);
Assert.Single(patterns);
Assert.Contains("echo", patterns[0], StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AllowAlwaysPatterns_CommandSubstitution_ReturnsEmpty()
{
// SplitShellCommandChain returns null → CollectPatterns bails out → empty.
var patterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(
["bash", "-c", "echo $(id)"], cwd: null, env: null);
Assert.Empty(patterns);
}
[Fact]
public void AllowAlwaysPatterns_EncodedCommand_NotFailClosed()
{
// Unlike ResolveForAllowlist, AllowAlwaysPatterns is UX-only and does not
// fail-closed on -enc: it resolves the first token (powershell) as the pattern.
var patterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(
["bash", "-c", "powershell -enc dABlAHMAdAA="], cwd: null, env: null);
Assert.NotEmpty(patterns);
Assert.Contains("powershell", patterns[0], StringComparison.OrdinalIgnoreCase);
}
// ── ExecCommandResolver — path resolution edge cases ─────────────────────
[Fact]
public void Resolver_TildeExpanded_RawExecutableHasNoTilde()
{
var res = ExecCommandResolver.Resolve(
["~/bin/nonexistent-tool-xyz"], cwd: null, env: null);
Assert.NotNull(res);
Assert.False(res!.Value.RawExecutable.StartsWith('~'));
}
[Fact]
public void Resolver_RelativePath_ResolvedToAbsoluteWithCwd()
{
var res = ExecCommandResolver.Resolve(
["./nonexistent-tool-xyz"], cwd: @"C:\tmp", env: null);
Assert.NotNull(res);
Assert.NotNull(res!.Value.ResolvedPath);
Assert.True(System.IO.Path.IsPathFullyQualified(res.Value.ResolvedPath!));
Assert.StartsWith(@"C:\tmp", res.Value.ResolvedPath, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Resolver_CustomPathEnv_FindsExecutableInCustomDir()
{
// Provide System32 explicitly via env PATH — cmd.exe must be found.
var sysDir = System.Environment.GetFolderPath(System.Environment.SpecialFolder.System);
var env = new Dictionary<string, string> { ["PATH"] = sysDir };
var res = ExecCommandResolver.Resolve(["cmd.exe"], cwd: null, env: env);
Assert.NotNull(res);
Assert.NotNull(res!.Value.ResolvedPath);
Assert.Contains("System32", res.Value.ResolvedPath, System.StringComparison.OrdinalIgnoreCase);
}
// ── Finding #3: path/token parsing hardening (Hanselman review) ──────────
[Fact]
public void Resolver_NonLetterDriveColon_ReturnsNull()
{
// '1' at the drive position is not an ASCII letter — HasNonStandardColon must reject it.
// Previously colonIdx==1 was accepted without checking char.IsAsciiLetter.
var res = ExecCommandResolver.Resolve([@"1:\tool.exe"], cwd: null, env: null);
Assert.Null(res);
}
[Fact]
public void Resolver_ExtendedLengthPath_NotRejectedByColonCheck()
{
// \\?\C:\... is a valid extended-length path; HasNonStandardColon must not reject it.
var sysDir = System.Environment.GetFolderPath(System.Environment.SpecialFolder.System);
var extended = @"\\?\" + System.IO.Path.Combine(sysDir, "cmd.exe");
var res = ExecCommandResolver.Resolve([extended], cwd: null, env: null);
Assert.NotNull(res); // colon check must not block \\?\C:\ paths
}
[Fact]
public void ResolveForAllowlist_ParseFirstToken_UnclosedQuote_FailClosed()
{
// Unclosed quote in shell payload — ParseFirstToken must return null (fail-closed).
// Previously the old code returned rest.ToString() on end<0, silently swallowing the token.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "\"unclosed arg"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_QuotedTokenWithExtensionSuffix_SuffixPreserved()
{
// "git".exe in a shell segment — inner="git", suffix=".exe" → token="git.exe".
// Previously ParseFirstToken lost the suffix, producing RawExecutable="git" instead.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "\"git\".exe --version"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
Assert.EndsWith(".exe", resolutions[0].RawExecutable, System.StringComparison.OrdinalIgnoreCase);
}
// ── Finding #2: env modifiers fail-closed (Hanselman review) ─────────────
[Fact]
public void ResolveForAllowlist_EnvAssignmentBeforeDirectExec_ReturnsEmpty()
{
// env PATH=/evil wget — VAR=val modifier changes which executable resolves at runtime.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "PATH=/evil", "wget"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvUnknownFlagBeforeShellWrapper_ReturnsEmpty()
{
// env --bogus bash -c "..." — Hanselman called this out explicitly.
// Unknown flag → HasModifiers=true (starts with '-') → fail-closed.
// Must NOT degrade to "resolve env itself as the executable".
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "--bogus", "bash", "-c", "echo hi"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
// ── Finding #1: -EncodedCommand detection (Hanselman review) ─────────────
[Fact]
public void ResolveForAllowlist_DirectPowerShellEncodedCommand_ReturnsEmpty()
{
// Direct top-level ["powershell", "-EncodedCommand", "..."] — payload is opaque.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell", "-EncodedCommand", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPwshEcAlias_ReturnsEmpty()
{
// -ec is an official alias for -EncodedCommand.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["pwsh", "-ec", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPowerShellEncAbbreviation_ReturnsEmpty()
{
// -enco is an unambiguous prefix abbreviation of -EncodedCommand.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell", "-enco", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Theory]
[InlineData("powershell", "-en")]
[InlineData("pwsh", "-en")]
[InlineData("powershell", "-en:dABlAHMAdAA=")]
[InlineData("powershell", "-en=dABlAHMAdAA=")]
public void ResolveForAllowlist_DirectPowerShellEnAbbreviation_ReturnsEmpty(string shell, string flag)
{
// -en is also an unambiguous prefix abbreviation of -EncodedCommand.
var command = flag.Contains('=') || flag.Contains(':')
? new[] { shell, flag }
: new[] { shell, flag, "dABlAHMAdAA=" };
var resolutions = ExecCommandResolver.ResolveForAllowlist(
command,
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPowerShellExeEnc_ReturnsEmpty()
{
// powershell.exe (with .exe suffix) must also be caught.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell.exe", "-enc", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvTransparentPwshEnc_ReturnsEmpty()
{
// ["env", "pwsh", "-enc", "..."] — transparent env prefix, no modifiers, but inner
// command is powershell with -EncodedCommand → DirectExecUsesEncodedCommand catches it.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "pwsh", "-enc", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_EnvTransparentPwshEnAbbreviation_ReturnsEmpty()
{
// Transparent env prefix must still fail-closed when inner pwsh uses -en.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["env", "pwsh", "-en", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_SegmentPowerShellQuotedEncFlag_ReturnsEmpty()
{
// bash -c 'powershell "-enc" base64' — quoted -enc in shell segment → fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "powershell \"-enc\" dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Theory]
[InlineData("powershell -en dABlAHMAdAA=")]
[InlineData("powershell \"-en\" dABlAHMAdAA=")]
public void ResolveForAllowlist_SegmentPowerShellEnAbbreviation_ReturnsEmpty(string payload)
{
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", payload],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_SegmentPowerShellColonForm_ReturnsEmpty()
{
// -EncodedCommand:payload (colon separator) — must be fail-closed.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "powershell -EncodedCommand:dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_WrapperPowerShellCommandPayload_NotFailClosed()
{
// powershell -Command <payload> is a shell wrapper invocation (not direct exec).
// The wrapper path must not fail-closed when the payload contains no -EncodedCommand.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell", "-Command", "Get-Date"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPowerShellScriptFile_NotFailClosed()
{
// 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(
["powershell", "script.ps1"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
Assert.Contains("powershell", resolutions[0].ExecutableName, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveForAllowlist_DirectPowerShellEncEqualsForm_ReturnsEmpty()
{
// -enc=payload (equals separator) — Hanselman listed this form explicitly.
// IsEncodedCommandFlag strips the =payload part before comparing.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell", "-enc=dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPowerShellEAlias_ReturnsEmpty()
{
// Windows PowerShell accepts -e as a short alias for -EncodedCommand.
// Hanselman review: this was the missing gap in detection.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["powershell", "-e", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_DirectPwshEAlias_ReturnsEmpty()
{
// pwsh also accepts -e as short for -EncodedCommand.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["pwsh", "-e", "dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_SegmentPowerShellEAlias_ReturnsEmpty()
{
// Shell-wrapper segment: bash -c "powershell -e base64" — segment scanner must catch -e.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "powershell -e dABlAHMAdAA="],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Empty(resolutions);
}
[Fact]
public void ResolveForAllowlist_QuotedPathWithSpacesAndSuffix_SuffixPreserved()
{
// Hanselman's specific example: "C:\Program Files\Git\bin\git".exe
// Quoted path with spaces inside + bare suffix after the closing quote.
// ParseFirstToken must produce the full path with .exe appended.
var resolutions = ExecCommandResolver.ResolveForAllowlist(
["bash", "-c", "\"C:\\Program Files\\Git\\bin\\git\".exe --version"],
evaluationRawCommand: null, cwd: null, env: null);
Assert.Single(resolutions);
Assert.EndsWith(".exe", resolutions[0].RawExecutable, System.StringComparison.OrdinalIgnoreCase);
Assert.Contains("Program Files", resolutions[0].RawExecutable, System.StringComparison.OrdinalIgnoreCase);
}
}