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:
parent
bcd1e633e6
commit
57ebebc725
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
501
src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs
Normal file
501
src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs
Normal 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
|
||||
}
|
||||
}
|
||||
28
src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs
Normal file
28
src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs
Normal 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";
|
||||
}
|
||||
100
src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs
Normal file
100
src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs
Normal file
38
src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs
Normal 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=",
|
||||
];
|
||||
}
|
||||
118
src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs
Normal file
118
src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
869
tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs
Normal file
869
tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user