diff --git a/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs new file mode 100644 index 00000000..647b920d --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs @@ -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 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 AllowlistResolutions { get; } + + // Suggested allowlist patterns for prompt/UI (PR6). Not a security decision. + public IReadOnlyList AllowAlwaysPatterns { get; } + + // ── Request context (carried from ValidatedRunRequest) ──────────────────── + + public string? Cwd { get; } + public int TimeoutMs { get; } + public IReadOnlyDictionary? Env { get; } + public string? AgentId { get; } + public string? SessionKey { get; } + + internal CanonicalCommandIdentity( + IReadOnlyList command, + string displayCommand, + string? evaluationRawCommand, + ExecCommandResolution? resolution, + IReadOnlyList allowlistResolutions, + IReadOnlyList allowAlwaysPatterns, + string? cwd, + int timeoutMs, + IReadOnlyDictionary? 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; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs new file mode 100644 index 00000000..86331579 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs @@ -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; + + // 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)); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs new file mode 100644 index 00000000..59f53ab9 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs @@ -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 command, + string? cwd, + IReadOnlyDictionary? 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 ResolveForAllowlist( + IReadOnlyList command, + string? evaluationRawCommand, + string? cwd, + IReadOnlyDictionary? 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(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 ResolveAllowAlwaysPatterns( + IReadOnlyList command, + string? cwd, + IReadOnlyDictionary? env) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var patterns = new List(); + CollectPatterns(command, cwd, env, seen, patterns, 0); + return patterns; + } + + // ── Resolution helpers ─────────────────────────────────────────────────── + + private static ExecCommandResolution? ResolveSingle( + IReadOnlyList command, + string? rawCommand, + string? cwd, + IReadOnlyDictionary? 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? 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? SplitShellCommandChain(string command) + { + var trimmed = command.Trim(); + if (trimmed.Length == 0) return null; + + var segments = new List(); + 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 command, + string? cwd, + IReadOnlyDictionary? env, + HashSet seen, + List 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 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? 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 searchPaths, + IReadOnlyList 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 GetSearchPaths(IReadOnlyDictionary? 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 GetPathExtensions(IReadOnlyDictionary? 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 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 + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs new file mode 100644 index 00000000..28a963a4 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs @@ -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"; +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs b/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs new file mode 100644 index 00000000..3410e882 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs @@ -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? Unwrap(IReadOnlyList 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 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 UnwrapForResolution(IReadOnlyList 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; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs b/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs new file mode 100644 index 00000000..7b9e6a73 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs @@ -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 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 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 InlineValuePrefixes = + [ + "-u", "-c", "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", + ]; +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs new file mode 100644 index 00000000..71e36b47 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -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) 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 Names); + + private static readonly HashSet s_posixInlineFlags = + new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" }; + + private static readonly HashSet s_powerShellInlineFlags = + new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" }; + + private static readonly WrapperSpec[] s_specs = + [ + new(WrapperKind.Posix, new HashSet(StringComparer.OrdinalIgnoreCase) + { "ash", "sh", "bash", "zsh", "dash", "ksh", "fish" }), + new(WrapperKind.Cmd, new HashSet(StringComparer.OrdinalIgnoreCase) + { "cmd", "cmd.exe" }), + new(WrapperKind.PowerShell, new HashSet(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 command, string? rawCommand = null) + => ExtractInner(command, rawCommand, 0); + + private static ParsedWrapper ExtractInner( + IReadOnlyList 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 command, WrapperSpec spec) => + spec.Kind switch + { + WrapperKind.Posix => ExtractPosixPayload(command), + WrapperKind.Cmd => ExtractCmdPayload(command), + WrapperKind.PowerShell => ExtractPowerShellPayload(command), + _ => null, + }; + + private static string? ExtractPosixPayload(IReadOnlyList command) + { + if (command.Count < 2) return null; + var flag = command[1].Trim(); + if (!s_posixInlineFlags.Contains(flag)) return null; + if (command.Count < 3) return null; + var payload = command[2].Trim(); + return payload.Length == 0 ? null : payload; + } + + private static string? ExtractCmdPayload(IReadOnlyList 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 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; + } +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs new file mode 100644 index 00000000..5a5159e2 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -0,0 +1,869 @@ +using System.Collections.Generic; +using Xunit; +using OpenClaw.Shared.ExecApprovals; + +namespace OpenClaw.Shared.Tests; + +/// +/// 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. +/// +public class ExecApprovalV2NormalizationTests +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static ValidatedRunRequest Req( + string[] argv, + string? cwd = null, + IReadOnlyDictionary? 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 { ["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 { ["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 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); + } +}