diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 3b682d378..4e7910937 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -275,11 +275,15 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) var command = argv[0]; var rawCommand = GetStringArg(request.Args, "rawCommand"); + var requestedShell = GetStringArg(request.Args, "shell"); + var effectiveShell = _commandRunner?.ResolveEffectiveShell(requestedShell) + ?? ResolveDefaultEffectiveShell(requestedShell); var cwd = GetStringArg(request.Args, "cwd"); var agentId = GetStringArg(request.Args, "agentId"); var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); - Logger.Info($"system.run.prepare: {rawCommand} (cwd={cwd ?? "default"})"); + Logger.Info( + $"system.run.prepare: {rawCommand} (shell={effectiveShell}, requestedShell={requestedShell ?? "auto"}, cwd={cwd ?? "default"})"); return Success(new { @@ -289,11 +293,27 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) argv, cwd, rawCommand, + requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(), + effectiveShell, agentId, sessionKey } }); } + + private static string ResolveDefaultEffectiveShell(string? requestedShell) + { + if (string.IsNullOrWhiteSpace(requestedShell)) + return "powershell"; + + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } private async Task HandleRunAsync(NodeInvokeRequest request) { @@ -400,50 +420,35 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var fullCommand = args != null ? FormatExecCommand([command!, ..args]) : command; + var requestedShell = string.IsNullOrWhiteSpace(shell) ? null : shell.Trim(); + var effectiveShell = _commandRunner.ResolveEffectiveShell(requestedShell); + var approvedHostFallbackShell = _commandRunner is IHostFallbackAwareCommandRunner fallbackAwareRunner + ? fallbackAwareRunner.ResolveHostFallbackShellForApproval(requestedShell, effectiveShell) + : null; - Logger.Info($"system.run: {fullCommand} (shell={shell ?? "auto"}, timeout={timeoutMs}ms)"); + Logger.Info($"system.run: {fullCommand} (shell={effectiveShell}, requestedShell={shell ?? "auto"}, timeout={timeoutMs}ms)"); // Check exec approval policy if (_approvalPolicy != null) { - var approval = _approvalPolicy.Evaluate(fullCommand, shell); - var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId); - if (!approvalCheck.Allowed) - { - Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); - return Error($"Command denied by exec policy: {approval.Reason}"); - } - - var outerApprovalCoversNestedTargets = - approvalCheck.PromptDecisionKind != null || - IsExactAllowRuleForCommand(approval, fullCommand); - - var parseResult = ExecShellWrapperParser.Expand(fullCommand, shell); - if (!string.IsNullOrWhiteSpace(parseResult.Error)) - { - Logger.Warn($"system.run DENIED: {fullCommand} ({parseResult.Error})"); - return Error($"Command denied by exec policy: {parseResult.Error}"); - } + var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( + fullCommand, + effectiveShell, + sessionKey, + correlationId); + if (approvalError != null) + return approvalError; - foreach (var target in parseResult.Targets) + if (!string.IsNullOrWhiteSpace(approvedHostFallbackShell) + && !string.Equals(approvedHostFallbackShell, effectiveShell, StringComparison.OrdinalIgnoreCase)) { - var innerApproval = _approvalPolicy.Evaluate(target.Command, target.Shell); - if (outerApprovalCoversNestedTargets && !IsExplicitDeny(innerApproval)) - { - if (!innerApproval.Allowed) - { - Logger.Info( - $"system.run nested approval covered by approved wrapper: {target.Command} ({innerApproval.Reason})"); - } - continue; - } - - var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval, sessionKey, correlationId); - if (!innerApprovalCheck.Allowed) - { - Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})"); - return Error($"Command denied by exec policy: {innerApproval.Reason}"); - } + approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( + fullCommand, + approvedHostFallbackShell, + sessionKey, + correlationId); + if (approvalError != null) + return approvalError; } } @@ -453,10 +458,12 @@ private async Task HandleRunAsync(NodeInvokeRequest request) { Command = command, Args = args, - Shell = shell, + Shell = requestedShell, Cwd = cwd, TimeoutMs = timeoutMs, - Env = env + Env = env, + ApprovedEffectiveShell = effectiveShell, + ApprovedHostFallbackShell = approvedHostFallbackShell }); return Success(new @@ -531,6 +538,63 @@ private async Task EnsureApprovedAsync( return new ExecApprovalCheckResult(true, decision.Kind); } + private async Task EnsureCommandAndNestedTargetsApprovedAsync( + string fullCommand, + string? shell, + string? sessionKey, + string correlationId) + { + if (_approvalPolicy == null) + return null; + + var approval = _approvalPolicy.Evaluate(fullCommand, shell); + var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId); + if (!approvalCheck.Allowed) + { + Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); + return Error($"Command denied by exec policy: {approval.Reason}"); + } + + var outerApprovalCoversNestedTargets = + approvalCheck.PromptDecisionKind != null || + IsExactAllowRuleForCommand(approval, fullCommand); + + var parseResult = ExecShellWrapperParser.Expand(fullCommand, shell); + if (!string.IsNullOrWhiteSpace(parseResult.Error)) + { + Logger.Warn($"system.run DENIED: {fullCommand} ({parseResult.Error})"); + return Error($"Command denied by exec policy: {parseResult.Error}"); + } + + foreach (var target in parseResult.Targets) + { + var innerApproval = _approvalPolicy.Evaluate(target.Command, target.Shell); + if (outerApprovalCoversNestedTargets && !IsExplicitDeny(innerApproval)) + { + if (!innerApproval.Allowed) + { + Logger.Info( + $"system.run nested approval covered by approved wrapper: {target.Command} ({innerApproval.Reason})"); + } + continue; + } + + var innerApprovalCheck = await EnsureApprovedAsync( + target.Command, + target.Shell, + innerApproval, + sessionKey, + correlationId); + if (!innerApprovalCheck.Allowed) + { + Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})"); + return Error($"Command denied by exec policy: {innerApproval.Reason}"); + } + } + + return null; + } + private static bool CanPersistExactAllowRule(string command) => !string.IsNullOrWhiteSpace(command) && command.IndexOfAny(['*', '?']) < 0; diff --git a/src/OpenClaw.Shared/ICommandRunner.cs b/src/OpenClaw.Shared/ICommandRunner.cs index 1930bb5e7..78cfbfb76 100644 --- a/src/OpenClaw.Shared/ICommandRunner.cs +++ b/src/OpenClaw.Shared/ICommandRunner.cs @@ -35,6 +35,20 @@ public class CommandRequest /// Additional environment variables public Dictionary? Env { get; set; } + + /// + /// Optional effective shell that already passed shell-scoped approval. + /// Dynamic runners must execute this shell, or a separately approved host + /// fallback shell, so live settings cannot change the approved boundary. + /// + public string? ApprovedEffectiveShell { get; set; } + + /// + /// Optional host fallback shell that has already passed shell-scoped approval. + /// Sandboxed runners use this only when a compatibility fallback would execute + /// a different host shell than the sandbox effective shell. + /// + public string? ApprovedHostFallbackShell { get; set; } } /// @@ -57,7 +71,39 @@ public interface ICommandRunner { /// Human-readable name of this runner (e.g., "local", "docker", "wsl") string Name { get; } + + /// + /// Resolve the shell that will actually execute the request. Approval checks + /// must use this value so shell-scoped rules cannot approve one shell while + /// the runner executes another. + /// + string ResolveEffectiveShell(string? requestedShell) + { + if (string.IsNullOrWhiteSpace(requestedShell)) + return "powershell"; + + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } /// Execute a command and return the result. Task RunAsync(CommandRequest request, CancellationToken ct = default); } + +/// +/// Optional contract for runners that may preserve compatibility through an +/// uncontained host fallback with a shell different from their sandbox shell. +/// +public interface IHostFallbackAwareCommandRunner : ICommandRunner +{ + /// + /// Returns the host fallback shell that needs separate approval, or null when + /// fallback cannot change the already-approved effective shell. + /// + string? ResolveHostFallbackShellForApproval(string? requestedShell, string effectiveShell); +} diff --git a/src/OpenClaw.Shared/LocalCommandRunner.cs b/src/OpenClaw.Shared/LocalCommandRunner.cs index 2643deeee..94ea7cf90 100644 --- a/src/OpenClaw.Shared/LocalCommandRunner.cs +++ b/src/OpenClaw.Shared/LocalCommandRunner.cs @@ -7,7 +7,7 @@ namespace OpenClaw.Shared; /// -/// Executes commands locally via Process.Start (pwsh.exe / cmd.exe). +/// Executes commands locally via Process.Start (pwsh.exe / powershell.exe / cmd.exe). /// This is the default runner. Swap with DockerCommandRunner, WslCommandRunner, etc. /// public class LocalCommandRunner : ICommandRunner @@ -22,6 +22,8 @@ public LocalCommandRunner(IOpenClawLogger? logger = null) { _logger = logger ?? NullLogger.Instance; } + + public string ResolveEffectiveShell(string? requestedShell) => ResolveEffectiveShellName(requestedShell); public async Task RunAsync(CommandRequest request, CancellationToken ct = default) { @@ -232,9 +234,10 @@ private static void ValidateDirectExecutable(string executable) $"Direct-argv mode cannot guarantee argv fidelity for batch scripts: {executable}", nameof(executable)); } - private static (string fileName, string arguments) BuildProcessArgs(CommandRequest request) + internal static (string fileName, string arguments) BuildProcessArgs(CommandRequest request, string? pathEnvVar = null) { - var shell = request.Shell ?? "powershell"; + var defaultShell = string.IsNullOrWhiteSpace(request.Shell); + var shell = ResolveEffectiveShellName(request.Shell, pathEnvVar); var command = request.Command; var isCmd = shell.Equals("cmd", StringComparison.OrdinalIgnoreCase); @@ -249,8 +252,66 @@ private static (string fileName, string arguments) BuildProcessArgs(CommandReque if (isCmd) return ("cmd.exe", $"/C {command}"); if (shell.Equals("pwsh", StringComparison.OrdinalIgnoreCase)) - return ("pwsh.exe", $"-NoProfile -NonInteractive -Command {command}"); - return ("powershell.exe", $"-NoProfile -NonInteractive -Command {command}"); + { + var pwshPath = ResolveOnPath("pwsh.exe", pathEnvVar); + if (pwshPath is not null || !defaultShell) + return (pwshPath ?? "pwsh.exe", $"-NoProfile -NonInteractive -Command {command}"); + } + + return (ResolveWindowsPowerShellExe(), $"-NoProfile -NonInteractive -Command {command}"); + } + + internal static string ResolveEffectiveShellName(string? requestedShell) + => ResolveEffectiveShellName(requestedShell, pathEnvVar: null); + + private static string ResolveEffectiveShellName(string? requestedShell, string? pathEnvVar) + { + if (!string.IsNullOrWhiteSpace(requestedShell)) + { + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } + + return "powershell"; + } + + private static string? ResolveOnPath(string executableName, string? pathEnvVar = null) + { + var path = pathEnvVar + ?? Environment.GetEnvironmentVariable("PATH") + ?? Environment.GetEnvironmentVariable("Path"); + if (string.IsNullOrWhiteSpace(path)) + return null; + + foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + try + { + var candidate = Path.Combine(dir, executableName); + if (File.Exists(candidate)) + return candidate; + } + catch + { + // Ignore malformed PATH entries. + } + } + + return null; + } + + private static string ResolveWindowsPowerShellExe() + { + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + return string.IsNullOrWhiteSpace(systemRoot) + ? "powershell.exe" + : Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); } /// diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs index adf5d4337..d92640923 100644 --- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs +++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs @@ -88,7 +88,7 @@ public async Task ExecuteAsync( : config.Process.Cwd; WarnIfUnsupportedVolume(config); - LogConfig(config, configJson, request, availability.WxcExecPath); + LogConfig(config, configJson, request, availability); MxcExecutor executor; try @@ -190,7 +190,7 @@ private static void TryDeleteDir(string? path) catch (Exception ex) { Trace.WriteLine($"DirectAppContainerExecutor.TryDeleteDir '{path}' (best-effort) failed: {ex.Message}"); } } - private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequest request, string? wxcExecPath) + private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequest request, MxcAvailability availability) { // Default: redacted summary. Field counts only; no paths, no command line, // no env values. Useful for verifying Sandbox UI settings round-tripped @@ -200,21 +200,7 @@ private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequ .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) .ToArray() ?? Array.Empty(); - var summary = - "[mxc] wxc-exec config (redacted) " + - $"wxcExec={wxcExecPath}; configBytes={Encoding.UTF8.GetByteCount(configJson)}; " + - $"containerId={config.ContainerId}; version={config.Version}; " + - $"commandLineLength={config.Process.CommandLine?.Length ?? 0}; " + - $"cwd={(string.IsNullOrEmpty(config.Process.Cwd) ? "" : "")}; " + - $"envKeys=[{string.Join(",", envKeys)}]; " + - $"timeoutMs={config.Process.TimeoutMs?.ToString() ?? ""}; " + - $"capabilities=[{string.Join(",", config.ProcessContainer?.Capabilities ?? Array.Empty())}]; " + - $"readonlyCount={config.Filesystem?.ReadonlyPaths?.Length ?? 0}; " + - $"readwriteCount={config.Filesystem?.ReadwritePaths?.Length ?? 0}; " + - $"deniedCount={config.Filesystem?.DeniedPaths?.Length ?? 0}; " + - $"network={{defaultPolicy={config.Network?.DefaultPolicy ?? ""},enforcementMode={config.Network?.EnforcementMode ?? ""}}}; " + - $"ui={{disable={config.Ui?.Disable},clipboard={config.Ui?.Clipboard ?? ""},injection={config.Ui?.Injection}}}; " + - $"maxOutputBytes={request.MaxOutputBytes?.ToString() ?? ""}"; + var summary = BuildRedactedConfigSummary(availability, config, configJson, request); _logger.Debug(summary); Trace.WriteLine(summary); @@ -236,6 +222,39 @@ private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequ } } + internal static string BuildRedactedConfigSummary( + MxcAvailability availability, + MxcConfig config, + string configJson, + SandboxExecutionRequest request) + { + // Default diagnostics must not expose host paths. Keep path presence as + // a state flag; the opt-in full diagnostics channel owns path-bearing repro data. + var wxcExecState = availability.IsWxcExecResolvable && !string.IsNullOrWhiteSpace(availability.WxcExecPath) + ? "" + : ""; + var envKeys = config.Process.Env? + .Select(kv => kv.Split('=', 2)[0]) + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); + + return + "[mxc] wxc-exec config (redacted) " + + $"wxcExec={wxcExecState}; configBytes={Encoding.UTF8.GetByteCount(configJson)}; " + + $"containerId={config.ContainerId}; version={config.Version}; " + + $"commandLineLength={config.Process.CommandLine?.Length ?? 0}; " + + $"cwd={(string.IsNullOrEmpty(config.Process.Cwd) ? "" : "")}; " + + $"envKeys=[{string.Join(",", envKeys)}]; " + + $"timeoutMs={config.Process.TimeoutMs?.ToString() ?? ""}; " + + $"capabilities=[{string.Join(",", config.ProcessContainer?.Capabilities ?? Array.Empty())}]; " + + $"readonlyCount={config.Filesystem?.ReadonlyPaths?.Length ?? 0}; " + + $"readwriteCount={config.Filesystem?.ReadwritePaths?.Length ?? 0}; " + + $"deniedCount={config.Filesystem?.DeniedPaths?.Length ?? 0}; " + + $"network={{defaultPolicy={config.Network?.DefaultPolicy ?? ""},enforcementMode={config.Network?.EnforcementMode ?? ""}}}; " + + $"ui={{disable={config.Ui?.Disable},clipboard={config.Ui?.Clipboard ?? ""},injection={config.Ui?.Injection}}}; " + + $"maxOutputBytes={request.MaxOutputBytes?.ToString() ?? ""}"; + } + private void WarnIfUnsupportedVolume(MxcConfig config) { var paths = (config.Filesystem?.ReadonlyPaths ?? Array.Empty()) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 04a6491c7..f481c5342 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -14,12 +14,14 @@ namespace OpenClaw.Shared.Mxc; /// Honors : /// /// true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable. +/// true with set to true — deny when MXC is unavailable. /// false — bypass MXC; route through the host runner. /// /// -public sealed class MxcCommandRunner : ICommandRunner +public sealed class MxcCommandRunner : IHostFallbackAwareCommandRunner { public string Name => "mxc"; + private const string DefaultSandboxShell = "cmd"; private readonly ISandboxExecutor _executor; private readonly ICommandRunner _hostFallback; @@ -47,29 +49,69 @@ public MxcCommandRunner( _logger = logger ?? NullLogger.Instance; } + public string ResolveEffectiveShell(string? requestedShell) + { + var settings = _settingsProvider(); + if (!settings.SystemRunSandboxEnabled) + return _hostFallback.ResolveEffectiveShell(requestedShell); + + if (!_isSandboxAvailable() && !settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return _hostFallback.ResolveEffectiveShell(requestedShell); + + if (!string.IsNullOrWhiteSpace(requestedShell)) + return ResolveSandboxShell(requestedShell); + + return DefaultSandboxShell; + } + + public string? ResolveHostFallbackShellForApproval(string? requestedShell, string effectiveShell) + { + var settings = _settingsProvider(); + if (!settings.SystemRunSandboxEnabled || settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return null; + + if (!string.IsNullOrWhiteSpace(requestedShell)) + return null; + + var hostShell = ResolveHostFallbackShell(requestedShell); + return string.Equals(hostShell, effectiveShell, StringComparison.OrdinalIgnoreCase) + ? null + : hostShell; + } + public async Task RunAsync(CommandRequest request, CancellationToken ct = default) { var settings = _settingsProvider(); + var effectiveShell = ResolveEffectiveShell(request.Shell); + if (!TryValidateApprovedEffectiveShell(request, effectiveShell, out var approvalDeny)) + return approvalDeny!; if (!settings.SystemRunSandboxEnabled) { _logger.Info("[mxc] sandbox=disabled; routing system.run through host runner"); - return await _hostFallback.RunAsync(request, ct); + return await RunHostFallbackAsync(request, effectiveShell, ct); } - // When MXC sandboxing isn't available on this host (e.g. Windows 10, an - // older build whose wxc-exec --probe reports no usable isolation tier, or - // missing wxc-exec.exe), fall back to the host runner so the agent can - // still execute commands instead of being completely blocked. The Sandbox - // page is read-only in that state and tells the user their commands are - // running uncontained. + // Custom env changes the execution boundary. Until MXC can enforce it + // in-container, sandbox-enabled requests must not bypass policy through + // the MXC-unavailable compatibility fallback. + if (request.Env is { Count: > 0 }) + return DenyCustomEnvUnsupported(); + if (!_isSandboxAvailable()) { + if (settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return DenySandboxUnavailable( + "Sandboxed system.run is enabled, but MXC is unavailable on this host and host fallback is blocked by settings. " + + "Update Windows or repair MXC, or disable strict fallback blocking if uncontained host execution is acceptable.", + "[mxc] system.run denied: sandbox unavailable and host fallback blocked by settings"); + + // Compatibility default: keep pre-MXC host execution unless the + // operator explicitly opts into strict sandbox-unavailable blocking. _logger.Warn( - "[mxc] system.run UNCONTAINED: sandbox unavailable on this host. " + - "Commands will run on the host without containment. " + - "Update Windows to enable sandboxing."); - return await _hostFallback.RunAsync(request, ct); + "[mxc] system.run UNCONTAINED: sandbox unavailable on this host; " + + "routing through host runner for compatibility."); + return await RunHostFallbackAsync(request, effectiveShell, ct); } // A direct-argv request reaching the sandbox cannot be honored: the sandbox @@ -93,7 +135,7 @@ public async Task RunAsync(CommandRequest request, CancellationTo var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); - var argsJson = SerializeArgs(request); + var argsJson = SerializeArgs(request, effectiveShell); // Compute the effective timeout: take the smaller of the agent-supplied // timeout (request.TimeoutMs) and the user's sandbox cap (policy.TimeoutMs). @@ -113,7 +155,7 @@ public async Task RunAsync(CommandRequest request, CancellationTo try { - LogSandboxRequest(sandboxRequest, request, settings, settingsDirectoryPath, policy); + LogSandboxRequest(sandboxRequest, request, effectiveShell, settings, settingsDirectoryPath, policy); var sandboxed = await _executor.ExecuteAsync(sandboxRequest, ct); LogSandboxResult(sandboxed); return new CommandResult @@ -129,15 +171,23 @@ public async Task RunAsync(CommandRequest request, CancellationTo { // Invalidate any cached availability — what we thought was available // turned out not to be at runtime. Next command re-probes and the - // top-level !_isSandboxAvailable() branch will handle the fallback. - // We also fall back to host execution for THIS call instead of - // denying it, matching the policy from issue #494. + // top-level !_isSandboxAvailable() branch will use the compatibility + // fallback until MXC is available again. _invalidateAvailability?.Invoke(); + if (settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return DenySandboxUnavailable( + "Sandboxed system.run is enabled, but MXC became unavailable at runtime and host fallback is blocked by settings: " + + $"{ex.Message}. Repair MXC or disable strict fallback blocking if uncontained host execution is acceptable.", + $"[mxc] system.run denied: sandbox became unavailable at runtime and host fallback is blocked by settings: {ex.Message}"); + _logger.Warn( - $"[mxc] system.run UNCONTAINED (sandbox enabled but unavailable at runtime: {ex.Message}). " + - "Falling back to host execution. Update Windows to enable sandboxing."); - return await _hostFallback.RunAsync(request, ct); + $"[mxc] system.run UNCONTAINED: sandbox became unavailable at runtime ({ex.Message}); " + + "routing through host runner for compatibility."); + if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) + return deny!; + + return await RunHostFallbackAsync(request, hostShell, ct); } catch (OperationCanceledException) { @@ -145,6 +195,26 @@ public async Task RunAsync(CommandRequest request, CancellationTo // caller sees the cancellation rather than a fake "exited 0" response. throw; } + catch (NotSupportedException ex) + { + if (IsPowerShellUiUnsupported(ex)) + { + return DenySandboxUnavailable( + "Sandboxed system.run cannot execute PowerShell-family shells with the current MXC UI-deny policy. " + + "Retry with shell='cmd' or explicitly disable sandboxing if uncontained host execution is acceptable.", + $"[mxc] system.run denied: PowerShell-family shell unsupported by MXC UI-deny policy: {ex.Message}"); + } + + _logger.Warn($"[mxc] system.run denied: unsupported sandbox request: {ex.Message}"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = ex.Message, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } catch (Exception ex) { // Fail closed for ANY other error (bridge crashed, JSON malformed, IO @@ -165,12 +235,146 @@ public async Task RunAsync(CommandRequest request, CancellationTo } } - private static JsonElement SerializeArgs(CommandRequest request) + private CommandResult DenySandboxUnavailable(string stderr, string logMessage) + { + _logger.Warn(logMessage); + return new CommandResult + { + Stdout = string.Empty, + Stderr = stderr, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + + private Task RunHostFallbackAsync(CommandRequest request, string effectiveShell, CancellationToken ct) + { + var fallbackRequest = new CommandRequest + { + Command = request.Command, + Args = request.Args, + Argv = request.Argv, + Shell = effectiveShell, + Cwd = request.Cwd, + TimeoutMs = request.TimeoutMs, + Env = request.Env, + ApprovedEffectiveShell = request.ApprovedEffectiveShell, + ApprovedHostFallbackShell = request.ApprovedHostFallbackShell, + }; + return _hostFallback.RunAsync(fallbackRequest, ct); + } + + private static string ResolveSandboxShell(string requestedShell) => + requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + + private string ResolveHostFallbackShell(string? requestedShell) => + _hostFallback.ResolveEffectiveShell(requestedShell); + + private static bool IsPowerShellUiUnsupported(NotSupportedException ex) => + ex.Message.Contains("PowerShell-family shells require UI access", StringComparison.OrdinalIgnoreCase); + + private bool TryValidateApprovedEffectiveShell( + CommandRequest request, + string effectiveShell, + out CommandResult? deny) + { + deny = null; + if (string.IsNullOrWhiteSpace(request.ApprovedEffectiveShell) + || string.Equals(request.ApprovedEffectiveShell, effectiveShell, StringComparison.OrdinalIgnoreCase) + || string.Equals(request.ApprovedHostFallbackShell, effectiveShell, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + deny = DenyEffectiveShellMismatch(request.ApprovedEffectiveShell!, effectiveShell); + return false; + } + + private bool TryResolveApprovedHostFallbackShell( + CommandRequest request, + string effectiveShell, + out string hostShell, + out CommandResult? deny) + { + hostShell = ResolveHostFallbackShell(request.Shell); + deny = null; + + if (!string.IsNullOrWhiteSpace(request.Shell) + || string.Equals(hostShell, effectiveShell, StringComparison.OrdinalIgnoreCase) + || string.Equals(request.ApprovedHostFallbackShell, hostShell, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + deny = DenyFallbackShellMismatch(effectiveShell, hostShell); + return false; + } + + private CommandResult DenyEffectiveShellMismatch(string approvedShell, string effectiveShell) + { + var message = + "Sandboxed system.run could not execute because the effective shell changed " + + $"after approval. Approved shell was '{approvedShell}', but execution resolved " + + $"'{effectiveShell}'. Retry so the command can be approved for the current shell."; + _logger.Warn("[mxc] system.run denied: effective shell changed after approval"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = message, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + + private CommandResult DenyCustomEnvUnsupported() + { + const string message = + "Sandboxed system.run does not currently support custom environment variables " + + "with the Windows MXC 0.7 processcontainer backend. Remove env from the request " + + "or explicitly disable sandboxing if uncontained host execution is acceptable."; + _logger.Warn("[mxc] system.run denied: custom env is unsupported by MXC processcontainer"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = message, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + + private CommandResult DenyFallbackShellMismatch(string approvedShell, string hostFallbackShell) + { + var message = + "Sandboxed system.run could not safely fall back to host execution because the " + + $"pre-approved shell was '{approvedShell}' but host fallback would execute with " + + $"'{hostFallbackShell}' without prior approval. Retry with an explicit shell or after " + + "MXC availability has been re-probed."; + _logger.Warn("[mxc] system.run denied: host fallback shell would differ from approved shell"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = message, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + + private static JsonElement SerializeArgs(CommandRequest request, string effectiveShell) { var payload = new { command = request.Command, - shell = request.Shell ?? "powershell", + shell = effectiveShell, args = request.Args ?? Array.Empty(), cwd = request.Cwd, env = request.Env, @@ -184,23 +388,40 @@ private static JsonElement SerializeArgs(CommandRequest request) private void LogSandboxRequest( SandboxExecutionRequest sandboxRequest, CommandRequest commandRequest, + string effectiveShell, SettingsData settings, string settingsDirectoryPath, SandboxPolicy policy) { - var settingsJson = JsonSerializer.Serialize(ToSandboxSettingsDiagnostic(settings, settingsDirectoryPath), DiagnosticJson); - var policyJson = JsonSerializer.Serialize(policy, DiagnosticJson); + var envKeys = commandRequest.Env?.Keys + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); var message = "[mxc] system.run sandbox request " + $"executor={_executor.Name}; contained={_executor.IsContained}; " + - $"sandboxSettingsJson={settingsJson}; " + - $"shell={commandRequest.Shell ?? "powershell"}; " + + $"sandboxSettings={{enabled={settings.SystemRunSandboxEnabled},blockHostFallbackWhenMxcUnavailable={settings.SystemRunBlockHostFallbackWhenMxcUnavailable}," + + $"allowOutbound={settings.SystemRunAllowOutbound},clipboard={settings.SandboxClipboard},documents={settings.SandboxDocumentsAccess?.ToString() ?? ""}," + + $"downloads={settings.SandboxDownloadsAccess?.ToString() ?? ""},desktop={settings.SandboxDesktopAccess?.ToString() ?? ""}," + + $"customFolderCount={settings.SandboxCustomFolders?.Count ?? 0},timeoutMs={settings.SandboxTimeoutMs},maxOutputBytes={settings.SandboxMaxOutputBytes}," + + $"settingsDirectoryPath={(string.IsNullOrWhiteSpace(settingsDirectoryPath) ? "" : "")}}}; " + + $"shell={effectiveShell}; requestedShell={(string.IsNullOrWhiteSpace(commandRequest.Shell) ? "" : "")}; " + $"commandLength={commandRequest.Command?.Length ?? 0}; " + $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " + - $"envKeys=[{string.Join(",", commandRequest.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " + + $"envKeys=[{string.Join(",", envKeys)}]; " + $"timeoutMs={sandboxRequest.TimeoutMs}; maxOutputBytes={sandboxRequest.MaxOutputBytes?.ToString() ?? ""}; " + - $"policyJson={policyJson}"; + $"policy={{readonlyCount={policy.Filesystem?.ReadonlyPaths?.Count ?? 0},readwriteCount={policy.Filesystem?.ReadwritePaths?.Count ?? 0}," + + $"deniedCount={policy.Filesystem?.DeniedPaths?.Count ?? 0},networkAllowOutbound={policy.Network?.AllowOutbound},uiAllowWindows={policy.Ui?.AllowWindows}," + + $"clipboard={policy.Ui?.Clipboard},timeoutMs={policy.TimeoutMs?.ToString() ?? ""}}}"; LogMxcDiagnostic(message); + + if (string.Equals(Environment.GetEnvironmentVariable(DirectAppContainerExecutor.LogFullConfigEnvVar), "1", StringComparison.Ordinal)) + { + var settingsJson = JsonSerializer.Serialize(ToSandboxSettingsDiagnostic(settings, settingsDirectoryPath), DiagnosticJson); + var policyJson = JsonSerializer.Serialize(policy, DiagnosticJson); + LogMxcDiagnostic( + "[mxc] system.run sandbox request (full) " + + $"sandboxSettingsJson={settingsJson}; policyJson={policyJson}"); + } } private static object ToSandboxSettingsDiagnostic(SettingsData settings, string settingsDirectoryPath) @@ -208,6 +429,7 @@ private static object ToSandboxSettingsDiagnostic(SettingsData settings, string return new { systemRunSandboxEnabled = settings.SystemRunSandboxEnabled, + systemRunBlockHostFallbackWhenMxcUnavailable = settings.SystemRunBlockHostFallbackWhenMxcUnavailable, systemRunAllowOutbound = settings.SystemRunAllowOutbound, sandboxClipboard = settings.SandboxClipboard, sandboxDocumentsAccess = settings.SandboxDocumentsAccess, diff --git a/src/OpenClaw.Shared/Mxc/MxcConfig.cs b/src/OpenClaw.Shared/Mxc/MxcConfig.cs index 31fc77aa0..081073fc5 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfig.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfig.cs @@ -10,7 +10,7 @@ namespace OpenClaw.Shared.Mxc; public sealed record MxcConfig { [JsonPropertyName("version")] - public string Version { get; init; } = "0.4.0-alpha"; + public string Version { get; init; } = "0.7.0-alpha"; [JsonPropertyName("containerId")] public required string ContainerId { get; init; } diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 689536680..136ce93ca 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -1,4 +1,6 @@ using System.Text; +using System.Security.AccessControl; +using System.Security.Principal; namespace OpenClaw.Shared.Mxc; @@ -11,12 +13,15 @@ namespace OpenClaw.Shared.Mxc; /// /// Translates (from the Sandbox page) and the /// agent's request into the JSON shape wxc-exec consumes. -/// — grants every existing -/// $PATH directory as readonly so command-line tools (git, node, -/// python, ...) can be read from inside the sandbox. Drive roots skipped. +/// — reconstructs a bounded +/// PATH inside the launched shell and grants backend-safe PATH +/// directories as readonly, so user-level tools can be resolved and executed +/// without asking MXC's DACL fallback to prepare protected system directories. /// Scratch dir injection — adds the per-invocation scratch dir as -/// readwrite and forces TEMP/TMP/TMPDIR at it so -/// commands don't write to the user's real %TEMP%. +/// readwrite and bootstraps TEMP/TMP/TMPDIR inside the +/// launched shell. Explicit process.env injection is intentionally +/// disabled for the current Windows MXC 0.7 processcontainer backend because +/// non-empty env entries fail process creation. /// Cwd auto-grant — adds request.Cwd as readonly when not already /// covered by an allow grant. AppContainer does NOT auto-grant cwd, so this /// is required for commands to even start. @@ -25,10 +30,18 @@ namespace OpenClaw.Shared.Mxc; /// -EncodedCommand). /// /// Env scrubbing happens upstream in SystemCapability.HandleRunAsync -/// via ExecEnvSanitizer.Sanitize; this class doesn't scrub env. +/// via ExecEnvSanitizer.Sanitize; this class rejects explicit env until +/// the backend accepts it. /// public static class MxcConfigBuilder { + // MXC processcontainer defaults to cmd because it starts inside the + // AppContainer while preserving the default UI-deny boundary. PowerShell + // remains available when explicitly requested, but callers must supply a + // policy with AllowWindows=true because MXC 0.7 requires UI access for + // PowerShell startup. + private const string DefaultShell = "cmd"; + /// /// Default per-process timeout when the caller doesn't supply one. /// @@ -39,40 +52,68 @@ public static class MxcConfigBuilder /// /// Capability invocation request. /// Per-invocation scratch directory the executor created. - /// Optional explicit container id (test/diagnostic use). Random GUID when null. - /// Optional override for the PATH env-var contents (test use). public static MxcConfig Build( + SandboxExecutionRequest request, + string scratchDir) => + Build(request, scratchDir, MxcConfigBuildContext.Default); + + internal static MxcConfig Build( SandboxExecutionRequest request, string scratchDir, - string? containerId = null, - string? pathEnvVar = null) + MxcConfigBuildContext context) { if (request is null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir)); + if (context is null) throw new ArgumentNullException(nameof(context)); + var deniedPathExists = context.DeniedPathExists ?? PathExists; + var readonlyGrantIsBackendSafe = context.ReadonlyGrantIsBackendSafe ?? IsBackendSafeReadonlyGrant; var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); + var shell = NormalizeSupportedShell(args.Shell); + if (IsPowerShellFamilyShell(shell) && policy?.Ui?.AllowWindows != true) + { + throw new NotSupportedException( + "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend."); + } - // commandLine — shell-quoted. - var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv); + if (request.Env is { Count: > 0 }) + { + throw new NotSupportedException( + "Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."); + } - // readonly = UI grants + every existing PATH dir (so tools like git, - // node, python can be read inside the sandbox). Additional compatibility - // paths are added below. + // readonly = UI grants. Additional compatibility paths are added below. + // PATH itself is bootstrapped inside the shell, and backend-safe PATH + // directories are also granted readonly so PATH-resolved user tools can + // actually be read/executed from inside AppContainer. var roFromPolicy = (policy?.Filesystem?.ReadonlyPaths ?? Array.Empty()).ToList(); - var pathDirs = ResolvePathDirsForReadonly(pathEnvVar); + var pathDirs = ResolvePathDirsForShellPath(context.PathEnvVar); foreach (var dir in pathDirs) + { + if (!readonlyGrantIsBackendSafe(dir)) continue; if (!roFromPolicy.Contains(dir, StringComparer.OrdinalIgnoreCase)) roFromPolicy.Add(dir); + } + + // commandLine — shell-quoted, with PATH/TEMP/TMP/TMPDIR bootstrapped + // inside the shell because MXC 0.7 rejects non-empty process.env. + var commandLine = ShellCommandLine.Build(shell, args.Command, args.Argv, scratchDir, pathDirs); + var allowWindows = policy?.Ui?.AllowWindows == true; // readwrite = UI grants + scratch dir. var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList(); if (!rwFromPolicy.Contains(scratchDir, StringComparer.OrdinalIgnoreCase)) rwFromPolicy.Add(scratchDir); - AddCompatibilityReadonlyPaths(roFromPolicy, roFromPolicy.Concat(rwFromPolicy).ToArray()); // denied list from policy (settings dir, ~/.ssh, browser profiles, ...). - var denied = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList(); + // Use the full list for local allow-list filtering, but do not emit + // known host profile roots to the MXC DACL fallback: those paths often + // cannot be prepared and make the sandbox fail before command launch. + var deniedForFiltering = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList(); + var deniedForBackend = deniedForFiltering + .Where(path => ShouldEmitDeniedPathToBackend(path, deniedPathExists)) + .ToList(); // cwd auto-grant — AppContainer does not auto-grant the working // directory. Give ungranted cwd read access so shells can start, but @@ -82,18 +123,18 @@ public static MxcConfig Build( && !IsCoveredBy(request.Cwd, roFromPolicy) && !IsCoveredBy(request.Cwd, rwFromPolicy)) { - if (!OverlapsAny(request.Cwd, denied)) + if (!OverlapsAny(request.Cwd, deniedForFiltering)) roFromPolicy.Add(request.Cwd); } // Deny wins: strip any allow that overlaps a deny after the merges above. - roFromPolicy = FilterOutDenied(roFromPolicy, denied); - rwFromPolicy = FilterOutDenied(rwFromPolicy, denied); + roFromPolicy = FilterOutDenied(roFromPolicy, deniedForFiltering); + rwFromPolicy = FilterOutDenied(rwFromPolicy, deniedForFiltering); - // env — agent-supplied vars (already scrubbed upstream by - // ExecEnvSanitizer in SystemCapability) plus TEMP/TMP/TMPDIR forced - // to scratch. - var env = BuildEnv(request.Env, scratchDir, pathDirs); + // process.env — intentionally empty. MXC 0.7 processcontainer currently + // fails process creation when a non-empty process.env array is supplied, + // so shell-level bootstrap above carries PATH/scratch temp instead. + var env = BuildEnv(request.Env); // timeout — caller-supplied or default. var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : DefaultProcessTimeoutMs; @@ -111,14 +152,14 @@ public static MxcConfig Build( var topLevelUi = new MxcUi { - Disable = true, + Disable = !allowWindows, Clipboard = MapClipboard(policy?.Ui?.Clipboard ?? ClipboardPolicy.None), Injection = false, }; var processContainerUi = new MxcBaseProcessUi { - Isolation = "container", + Isolation = allowWindows ? "desktop" : "container", DesktopSystemControl = false, SystemSettings = "none", Ime = false, @@ -127,7 +168,7 @@ public static MxcConfig Build( return new MxcConfig { Version = MxcPolicyBuilder.SupportedPolicyVersion, - ContainerId = containerId ?? Guid.NewGuid().ToString("N"), + ContainerId = context.ContainerId ?? Guid.NewGuid().ToString("N"), // Top-level "containment" is intentionally omitted; the SDK doesn't // emit it either. Isolation lives in processContainer.ui.isolation. Process = new MxcProcess @@ -147,7 +188,7 @@ public static MxcConfig Build( { ReadonlyPaths = roFromPolicy.ToArray(), ReadwritePaths = rwFromPolicy.ToArray(), - DeniedPaths = denied.ToArray(), + DeniedPaths = deniedForBackend.ToArray(), // SDK output didn't include clearPolicyOnExit even when the // input policy had it set, so we omit it here too. ClearPolicyOnExit = null, @@ -163,11 +204,11 @@ public static MxcConfig Build( } /// - /// Walk PATH and return each existing directory as a readonly grant - /// candidate. Drive roots (e.g. C:\) are skipped so a misconfigured - /// PATH entry can't grant the entire system drive. + /// Walk PATH and return each existing directory for shell-level PATH + /// bootstrap. Drive roots (e.g. C:\) are skipped so a misconfigured + /// PATH entry cannot make the payload search an entire drive root. /// - public static List ResolvePathDirsForReadonly(string? pathEnvVar = null) + public static List ResolvePathDirsForShellPath(string? pathEnvVar = null) { var path = pathEnvVar ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty; var pathDirs = path @@ -212,76 +253,117 @@ private static bool IsDriveRoot(string dir) } } - private static void AddCompatibilityReadonlyPaths(List readonlyPaths, IEnumerable grantedPaths) + private static bool IsBackendSafeReadonlyGrant(string dir) { - foreach (var path in grantedPaths) - AddCompatibilityReadonlyPath(readonlyPaths, path); - - AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows)); - AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty); + if (IsDriveRoot(dir)) return false; + if (IsProtectedSystemPath(dir)) return false; + if (!CanMxcDaclFallbackPreparePath(dir)) return false; + return true; } - private static void AddCompatibilityReadonlyPath(List readonlyPaths, string path) + private static bool CanMxcDaclFallbackPreparePath(string dir) { - string? root; - try { root = Path.GetPathRoot(Path.GetFullPath(path)); } - catch { return; } + if (!OperatingSystem.IsWindows()) + return true; + + try + { + var identity = WindowsIdentity.GetCurrent(); + var principals = new HashSet(); + if (identity.User is not null) + principals.Add(identity.User); + if (identity.Groups is not null) + { + foreach (var group in identity.Groups) + { + if (group is SecurityIdentifier sid) + principals.Add(sid); + } + } + + if (principals.Count == 0) + return false; + + var rules = new DirectoryInfo(dir) + .GetAccessControl(AccessControlSections.Access) + .GetAccessRules(includeExplicit: true, includeInherited: true, targetType: typeof(SecurityIdentifier)); + + var allowed = false; + foreach (FileSystemAccessRule rule in rules) + { + if (rule.IdentityReference is not SecurityIdentifier sid || !principals.Contains(sid)) + continue; + + if ((rule.FileSystemRights & (FileSystemRights.ChangePermissions | FileSystemRights.FullControl)) == 0) + continue; + + if (rule.AccessControlType == AccessControlType.Deny) + return false; - if (string.IsNullOrWhiteSpace(root)) - return; + if (rule.AccessControlType == AccessControlType.Allow) + allowed = true; + } - if (!readonlyPaths.Contains(root, StringComparer.OrdinalIgnoreCase)) - readonlyPaths.Add(root); + return allowed; + } + catch + { + return false; + } } - /// - /// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit. - /// - /// - /// Env from the agent has already been scrubbed upstream in - /// SystemCapability.HandleRunAsync via - /// ExecEnvSanitizer.Sanitize (which rejects the whole command if - /// anything dangerous is present). We pass the surviving entries through - /// and force TEMP/TMP/TMPDIR to - /// so tools inside the sandbox don't write into the user's real %TEMP%. - /// - public static IReadOnlyList BuildEnv( - IReadOnlyDictionary? requestEnv, - string scratchDir, - IReadOnlyList? pathDirs = null) + private static bool IsProtectedSystemPath(string dir) { - // Windows env vars are case-insensitive — use OrdinalIgnoreCase so - // duplicate-case agent entries don't end up as separate strings. - var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!OperatingSystem.IsWindows()) + return false; + + var normalized = NormalizePath(dir); + if (string.IsNullOrWhiteSpace(normalized)) + return false; - if (requestEnv is not null) + foreach (var root in ProtectedSystemRoots()) { - foreach (var (name, value) in requestEnv) + var protectedRoot = NormalizePath(root); + if (!string.IsNullOrWhiteSpace(protectedRoot) && + IsSameOrNested(normalized, protectedRoot)) { - if (string.IsNullOrEmpty(name) || value is null) continue; - // Reject names with NUL/CR/LF/'=' so an agent can't smuggle - // a second KEY=VALUE pair into a single name field. - bool malformed = false; - foreach (var ch in name) - { - if (ch == '=' || ch == '\0' || ch == '\r' || ch == '\n') - { - malformed = true; - break; - } - } - if (malformed) continue; - env[name] = value; + return true; } } - env["TEMP"] = scratchDir; - env["TMP"] = scratchDir; - env["TMPDIR"] = scratchDir; - if (pathDirs is { Count: > 0 }) - env["PATH"] = string.Join(Path.PathSeparator, pathDirs); + return false; + } - return env.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); + private static IEnumerable ProtectedSystemRoots() + { + yield return Environment.GetFolderPath(Environment.SpecialFolder.Windows); + yield return Environment.GetEnvironmentVariable("SystemRoot") ?? string.Empty; + yield return Environment.GetEnvironmentVariable("windir") ?? string.Empty; + yield return Environment.GetFolderPath(Environment.SpecialFolder.System); + yield return Environment.GetFolderPath(Environment.SpecialFolder.SystemX86); + yield return Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + yield return Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + yield return Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFiles); + yield return Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); + } + + /// + /// Build the explicit process.env array for the wxc-exec sandbox. + /// + /// + /// Current MXC 0.7 Windows processcontainer accepts an empty env array but + /// rejects non-empty entries at CreateProcessW. Emit an explicit + /// empty array for normal requests so the config does not rely on implicit + /// host-environment inheritance semantics. PATH and scratch temp variables + /// are set by the shell command line bootstrap instead. + /// + public static IReadOnlyList? BuildEnv(IReadOnlyDictionary? requestEnv) + { + if (requestEnv is null || requestEnv.Count == 0) + return Array.Empty(); + + throw new NotSupportedException( + "Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."); } private static List FilterOutDenied(List allowed, List denied) @@ -303,6 +385,59 @@ private static List FilterOutDenied(List allowed, List d .ToList(); } + private static bool ShouldEmitDeniedPathToBackend(string path, Func pathExists) + { + var normalized = NormalizePath(path); + if (string.IsNullOrWhiteSpace(normalized)) + return false; + + foreach (var hostProfileRoot in HostProfileDenyRoots()) + { + var root = NormalizePath(hostProfileRoot); + if (!string.IsNullOrWhiteSpace(root) && IsSameOrNested(normalized, root)) + return false; + } + + try + { + if (!pathExists(normalized)) + return false; + } + catch + { + return false; + } + + return true; + } + + private static bool PathExists(string path) => Directory.Exists(path) || File.Exists(path); + + private static IEnumerable HostProfileDenyRoots() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + + if (!string.IsNullOrWhiteSpace(userProfile)) + { + yield return Path.Combine(userProfile, ".ssh"); + } + + if (!string.IsNullOrWhiteSpace(localAppData)) + { + yield return Path.Combine(localAppData, "Google", "Chrome", "User Data"); + yield return Path.Combine(localAppData, "Microsoft", "Edge", "User Data"); + yield return Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data"); + } + + if (!string.IsNullOrWhiteSpace(appData)) + { + yield return Path.Combine(appData, "Mozilla", "Firefox", "Profiles"); + yield return Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine"); + } + } + private static bool IsCoveredBy(string candidate, IEnumerable ancestors) { var nc = NormalizePath(candidate); @@ -361,12 +496,12 @@ private static string NormalizePath(string path) private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement args) { if (args.ValueKind != System.Text.Json.JsonValueKind.Object) - return new SystemRunArgs("", "powershell", Array.Empty()); + return new SystemRunArgs("", DefaultShell, Array.Empty()); string command = args.TryGetProperty("command", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String ? (c.GetString() ?? "") : ""; string shell = args.TryGetProperty("shell", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String - ? (s.GetString() ?? "powershell") : "powershell"; + ? (s.GetString() ?? DefaultShell) : DefaultShell; string[] argv = Array.Empty(); if (args.TryGetProperty("args", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.Array) { @@ -378,9 +513,37 @@ private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement arg return new SystemRunArgs(command, shell, argv); } + private static bool IsPowerShellFamilyShell(string shell) + { + var normalized = shell.Trim().ToLowerInvariant(); + return normalized is "powershell" or "pwsh"; + } + + private static string NormalizeSupportedShell(string shell) + { + var normalized = string.IsNullOrWhiteSpace(shell) + ? DefaultShell + : shell.Trim().ToLowerInvariant(); + return normalized switch + { + "cmd" or "powershell" or "pwsh" => normalized, + _ => throw new NotSupportedException( + $"Unsupported shell '{shell}' for the Windows MXC 0.7 processcontainer backend."), + }; + } + private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); } +internal sealed record MxcConfigBuildContext( + string? ContainerId = null, + string? PathEnvVar = null, + Func? DeniedPathExists = null, + Func? ReadonlyGrantIsBackendSafe = null) +{ + public static MxcConfigBuildContext Default { get; } = new(); +} + /// /// Shell command-line construction for the sandboxed payload — wraps the /// agent's command in cmd.exe /S /C "..." or @@ -389,24 +552,82 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList< /// internal static class ShellCommandLine { - public static string Build(string shell, string command, IReadOnlyList argv) + private const int MaxShellBootstrapPathChars = 4096; + private static readonly string[] CmdBootstrapTempEnvNames = ["TEMP", "TMP", "TMPDIR"]; + + public static string Build( + string shell, + string command, + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) { - var normalized = (shell ?? "powershell").Trim().ToLowerInvariant(); + var normalized = (shell ?? "cmd").Trim().ToLowerInvariant(); + var bootstrapPathDirs = LimitPathDirsForCommandLine(pathDirs); return normalized switch { - "cmd" => BuildCmd(command, argv), - "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : "powershell.exe", command, argv), - _ => BuildPowershell("powershell.exe", command, argv), + "cmd" => BuildCmd(command, argv, scratchDir, bootstrapPathDirs), + "pwsh" or "powershell" => BuildPowershell( + normalized == "pwsh" ? ResolvePwshExe(pathDirs) : ResolveWindowsPowerShellExe(), + command, + argv, + scratchDir, + bootstrapPathDirs), + _ => throw new NotSupportedException( + $"Unsupported shell '{shell}' for the Windows MXC 0.7 processcontainer backend."), }; } - private static string BuildCmd(string command, IReadOnlyList argv) + private static IReadOnlyList LimitPathDirsForCommandLine(IReadOnlyList pathDirs) + { + if (pathDirs.Count == 0) + return Array.Empty(); + + var bounded = new List(); + var currentLength = 0; + foreach (var dir in pathDirs) + { + var additionalLength = dir.Length + (bounded.Count == 0 ? 0 : 1); + if (currentLength + additionalLength > MaxShellBootstrapPathChars) + break; + + bounded.Add(dir); + currentLength += additionalLength; + } + + return bounded; + } + + private static string BuildCmd( + string command, + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) { + ThrowIfCmdContainsLineBreak(command, nameof(command)); + foreach (var arg in argv) + ThrowIfCmdContainsLineBreak(arg, "argv"); + // cmd /S /C " [args]" — /S strips outer quotes so cmd treats - // everything after /C as the command line verbatim. - var sb = new StringBuilder("cmd.exe /S /C \""); - sb.Append(command); - foreach (var a in argv) + // everything after /C as the command line verbatim. If the payload + // references env vars we bootstrap in this same /C line, rewrite just + // those refs to delayed expansion; otherwise cmd expands %TEMP% before + // the preceding set command runs. + var rewrittenCommand = RewriteCmdBootstrapEnvRefs(command, pathDirs, out var needsDelayedExpansion); + var rewrittenArgv = new List(argv.Count); + foreach (var arg in argv) + { + rewrittenArgv.Add(RewriteCmdBootstrapEnvRefs(arg, pathDirs, out var argNeedsDelayedExpansion)); + needsDelayedExpansion |= argNeedsDelayedExpansion; + } + + var sb = new StringBuilder(QuoteProcessPath(ResolveCmdExe())); + if (needsDelayedExpansion) + sb.Append(" /V:ON"); + sb.Append(" /S /C \""); + AppendCmdEnvironmentBootstrap(sb, scratchDir, pathDirs); + sb.Append(rewrittenCommand); + foreach (var a in rewrittenArgv) { sb.Append(' '); sb.Append(QuoteForCmd(a)); @@ -415,11 +636,80 @@ private static string BuildCmd(string command, IReadOnlyList argv) return sb.ToString(); } - private static string BuildPowershell(string exe, string command, IReadOnlyList argv) + private static void ThrowIfCmdContainsLineBreak(string value, string fieldName) + { + if (value.IndexOfAny(new[] { '\r', '\n' }) >= 0) + { + throw new NotSupportedException( + $"cmd shell {fieldName} values cannot contain CR or LF characters with the Windows MXC 0.7 processcontainer backend."); + } + } + + private static string RewriteCmdBootstrapEnvRefs( + string value, + IReadOnlyList pathDirs, + out bool rewritten) + { + rewritten = false; + var result = value; + foreach (var name in CmdBootstrapTempEnvNames) + { + result = ReplaceOrdinalIgnoreCase( + result, + $"%{name}%", + $"!{name}!", + ref rewritten); + } + + if (pathDirs.Count > 0) + { + result = ReplaceOrdinalIgnoreCase( + result, + "%PATH%", + "!PATH!", + ref rewritten); + } + + return result; + } + + private static string ReplaceOrdinalIgnoreCase( + string value, + string search, + string replacement, + ref bool replaced) + { + var index = value.IndexOf(search, StringComparison.OrdinalIgnoreCase); + if (index < 0) + return value; + + var sb = new StringBuilder(value.Length); + var cursor = 0; + while (index >= 0) + { + sb.Append(value, cursor, index - cursor); + sb.Append(replacement); + cursor = index + search.Length; + index = value.IndexOf(search, cursor, StringComparison.OrdinalIgnoreCase); + replaced = true; + } + + sb.Append(value, cursor, value.Length - cursor); + return sb.ToString(); + } + + private static string BuildPowershell( + string exe, + string command, + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) { // -EncodedCommand avoids quoting pitfalls entirely. // We concatenate command + argv with spaces and let powershell parse it. - var sb = new StringBuilder(command); + var sb = new StringBuilder(); + AppendPowershellEnvironmentBootstrap(sb, scratchDir, pathDirs); + sb.Append(command); foreach (var a in argv) { sb.Append(' '); @@ -427,17 +717,107 @@ private static string BuildPowershell(string exe, string command, IReadOnlyList< } var script = sb.ToString(); var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); - return $"{exe} -NoProfile -NonInteractive -EncodedCommand {encoded}"; + return $"{QuoteProcessPath(exe)} -NoProfile -NonInteractive -EncodedCommand {encoded}"; + } + + private static void AppendCmdEnvironmentBootstrap( + StringBuilder sb, + string scratchDir, + IReadOnlyList pathDirs) + { + AppendCmdSet(sb, "TEMP", scratchDir); + AppendCmdSet(sb, "TMP", scratchDir); + AppendCmdSet(sb, "TMPDIR", scratchDir); + if (pathDirs.Count > 0) + AppendCmdSet(sb, "PATH", string.Join(Path.PathSeparator, pathDirs)); + } + + private static void AppendCmdSet(StringBuilder sb, string name, string value) + { + if (string.IsNullOrEmpty(value)) return; + sb.Append("set \"") + .Append(name) + .Append('=') + .Append(value.Replace("\"", "")) + .Append("\" && "); + } + + private static void AppendPowershellEnvironmentBootstrap( + StringBuilder sb, + string scratchDir, + IReadOnlyList pathDirs) + { + AppendPowershellSet(sb, "TEMP", scratchDir); + AppendPowershellSet(sb, "TMP", scratchDir); + AppendPowershellSet(sb, "TMPDIR", scratchDir); + if (pathDirs.Count > 0) + AppendPowershellSet(sb, "PATH", string.Join(Path.PathSeparator, pathDirs)); + } + + private static void AppendPowershellSet(StringBuilder sb, string name, string value) + { + if (string.IsNullOrEmpty(value)) return; + sb.Append("$env:") + .Append(name) + .Append(" = ") + .Append(QuoteEnvironmentValueForPowershell(value)) + .Append("; "); + } + + private static string ResolveCmdExe() + { + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + return string.IsNullOrWhiteSpace(systemRoot) + ? "cmd.exe" + : Path.Combine(systemRoot, "System32", "cmd.exe"); + } + + private static string ResolveWindowsPowerShellExe() + { + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + return string.IsNullOrWhiteSpace(systemRoot) + ? "powershell.exe" + : Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + } + + private static string ResolvePwshExe(IReadOnlyList pathDirs) + { + const string executableName = "pwsh.exe"; + foreach (var dir in pathDirs) + { + try + { + var candidate = Path.Combine(dir, executableName); + if (File.Exists(candidate)) + return candidate; + } + catch + { + // Keep PATH resolution best-effort; launch will fail closed if + // pwsh is not resolvable on the host. + } + } + + return executableName; + } + + private static string QuoteProcessPath(string path) + { + if (path.Length > 0 && path.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return path; + + return "\"" + path.Replace("\"", "\\\"") + "\""; } private static string QuoteForCmd(string arg) { // Note: `%VAR%` env-var expansion inside `cmd /S /C "..."` cannot be // reliably suppressed via quoting (cmd parses % before applying quote - // rules). The cmd shell route is opt-in and runs inside the AppContainer - // with a controlled env, so the expansion target is sandbox-side, not - // host-side. Callers wanting verbatim arguments should use powershell - // (-EncodedCommand) which has no env-expansion ambiguity. + // rules). Bootstrap env refs are rewritten before quoting; callers + // wanting fully verbatim arguments should use powershell + // (-EncodedCommand) which has no cmd env-expansion ambiguity. if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '"', '&', '|', '<', '>', '^', '(', ')', '%' }) < 0) return arg; return "\"" + arg.Replace("\"", "\"\"") + "\""; @@ -449,4 +829,7 @@ private static string QuoteForPowershell(string arg) return arg; return "'" + arg.Replace("'", "''") + "'"; } + + private static string QuoteEnvironmentValueForPowershell(string value) => + "'" + value.Replace("'", "''") + "'"; } diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 2503ec839..61d260856 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -10,26 +10,30 @@ namespace OpenClaw.Shared.Mxc; /// Policy decisions: /// /// readonlyPaths — populated from user-granted folders (Documents, -/// Downloads, Desktop, custom). The MXC config builder additionally merges -/// PATH-specific tool directories so spawned shells can find git/node/python/etc. +/// Downloads, Desktop, custom). The MXC config builder also grants +/// backend-safe host PATH directories as readonly so PATH-resolved user tools +/// can execute inside AppContainer. /// readwritePaths — user-granted read+write folders. The MXC config -/// builder adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR -/// to point at it, so the user's real %TEMP% stays out of reach. +/// builder adds a per-invocation scratch directory and bootstraps +/// TEMP/TMP/TMPDIR inside the launched shell so the user's real %TEMP% stays +/// out of reach. /// deniedPaths — settings directory (protect MCP token, gateway /// credentials, ElevenLabs key), ~/.ssh, and the common browser profile /// roots (Chrome / Edge / Firefox / Brave). Always blocked regardless of grants. /// network.allowOutbound — bound by . -/// ui — default-deny across the board; shell exec doesn't need windows. +/// ui — default-deny in base policy. PowerShell-family shells +/// need an explicit allowWindows policy on MXC 0.7 and fail closed under +/// the default UI-deny policy. /// /// public static class MxcPolicyBuilder { /// - /// Policy schema version. @microsoft/mxc-sdk 0.7.0 still lists 0.4.0-alpha as a - /// supported (stable) config schema; keep the documented 0.4.0-alpha schema - /// until we intentionally adopt a newer MXC policy contract. + /// Policy schema version emitted to wxc-exec. @microsoft/mxc-sdk + /// 0.7.0 emits and accepts the 0.7.0-alpha contract used by + /// processcontainer/AppContainer execution on Windows build 26100+. /// - public const string SupportedPolicyVersion = "0.4.0-alpha"; + public const string SupportedPolicyVersion = "0.7.0-alpha"; /// /// Build the policy for a system.run invocation given current settings. @@ -42,31 +46,31 @@ public static class MxcPolicyBuilder public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsDirectoryPath) { var deniedPaths = new List(); - if (!string.IsNullOrWhiteSpace(settingsDirectoryPath)) - deniedPaths.Add(settingsDirectoryPath); + AddDeniedPath(deniedPaths, settingsDirectoryPath); var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var sshPath = Path.Combine(userProfile, ".ssh"); - if (!string.IsNullOrWhiteSpace(sshPath)) - deniedPaths.Add(sshPath); + if (!string.IsNullOrWhiteSpace(userProfile)) + AddDeniedPath(deniedPaths, Path.Combine(userProfile, ".ssh")); // Always-blocked browser profile roots. Cookies, saved passwords, autofill, // and session tokens live here — they must remain unreachable even if the // user (or a malicious settings.json) tries to grant a parent folder. - // Add these regardless of whether the browser is installed; the AppContainer - // policy treats nonexistent denies as a no-op. + // Keep these paths in the logical deny list even when they do not + // exist yet, so parent-folder grants cannot create sensitive roots. The + // config builder filters host-profile roots before backend emission + // because MXC 0.7 AppContainer+DACL fails on some nonexistent paths. var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); if (!string.IsNullOrWhiteSpace(localAppData)) { - deniedPaths.Add(Path.Combine(localAppData, "Google", "Chrome", "User Data")); - deniedPaths.Add(Path.Combine(localAppData, "Microsoft", "Edge", "User Data")); - deniedPaths.Add(Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data")); + AddDeniedPath(deniedPaths, Path.Combine(localAppData, "Google", "Chrome", "User Data")); + AddDeniedPath(deniedPaths, Path.Combine(localAppData, "Microsoft", "Edge", "User Data")); + AddDeniedPath(deniedPaths, Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data")); } if (!string.IsNullOrWhiteSpace(appData)) { - deniedPaths.Add(Path.Combine(appData, "Mozilla", "Firefox", "Profiles")); - deniedPaths.Add(Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine")); + AddDeniedPath(deniedPaths, Path.Combine(appData, "Mozilla", "Firefox", "Profiles")); + AddDeniedPath(deniedPaths, Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine")); } var readonlyPaths = new List(); @@ -116,6 +120,12 @@ public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsD TimeoutMs: settings.SandboxTimeoutMs > 0 ? settings.SandboxTimeoutMs : null); } + private static void AddDeniedPath(List deniedPaths, string path) + { + if (!string.IsNullOrWhiteSpace(path)) + deniedPaths.Add(path); + } + /// /// Remove any allow-list entry that overlaps a denied path. /// Case-insensitive (NTFS semantics) and tolerant of trailing slashes. diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs index c9a25c1e9..0f4c1c353 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -41,15 +41,15 @@ public enum ClipboardPolicy /// /// When is true, system.run /// is contained via MXC AppContainer. When MXC is unavailable on the host, system.run -/// falls back to the host runner with a warning so older Windows builds are not -/// completely blocked. When the toggle is false, system.run runs on the host -/// as before. +/// uses compatibility host fallback by default and blocks only when +/// is enabled. +/// When the toggle is false, system.run runs on the host without attempting MXC. /// public enum SandboxMode { - /// Use MXC when available; otherwise fall back uncontained with a warning. + /// Use MXC when available; otherwise use compatibility fallback unless strict blocking is enabled. Enabled, - /// Bypass MXC entirely. + /// Bypass MXC entirely and run on the host. Disabled, } diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index 5c857c00f..e0314dae7 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -8,6 +8,12 @@ namespace OpenClaw.Shared; /// public record class SettingsData { + /// + /// Version for settings-file migrations that need to distinguish legacy + /// serialized defaults from explicit operator choices. + /// + public int SettingsSchemaVersion { get; set; } = 1; + public string? GatewayUrl { get; set; } public bool UseSshTunnel { get; set; } = false; public string? SshTunnelUser { get; set; } @@ -126,13 +132,22 @@ public record class SettingsData // ── MXC sandbox ───────────────────────────────────────────────────── /// /// Master switch for system.run containment. When true (default), - /// system.run runs inside an MXC AppContainer; if MXC is unavailable on - /// this host the invocation is denied — there is no host fallback. When - /// false, system.run runs on the host as it did before MXC support - /// was added. + /// system.run uses MXC containment when available and uses the compatibility + /// host fallback when MXC is unavailable unless strict blocking is enabled. Unsupported + /// sandbox request features are rejected while sandboxing remains enabled. + /// When false, system.run always runs on the host as it did before + /// MXC support was added. /// public bool SystemRunSandboxEnabled { get; set; } = true; + /// + /// When sandboxing is enabled but MXC is unavailable, block system.run + /// instead of using the compatibility host fallback. Default false + /// preserves the pre-MXC host fallback unless the operator opts into strict + /// fail-closed behavior. + /// + public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get; set; } = false; + /// /// When sandboxed, allow system.run commands to reach the public internet. /// Default false — most shell commands are local-only. diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs index be1615472..8d8e0c2a7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -191,17 +191,19 @@ private void LoadState() /// MXC availability AND the current sandbox toggle state. Three visual states: /// 1. Available + ON → 🛡 "Sandbox is on" + toggle visible /// 2. Available + OFF → ⚠ "Sandbox is off — high risk" + toggle visible - /// 3. Unavailable → ⚠ "Sandbox unavailable — commands run uncontained" + toggle hidden - /// When MXC is unavailable the toggle is hidden and MxcCommandRunner falls - /// back to host execution with a warning so older Windows builds are not - /// completely blocked. + /// 3. Unavailable + ON → ⚠ "Sandbox unavailable — host fallback" or "commands blocked" + toggle visible + /// 4. Unavailable + OFF → ⚠ "Sandbox is off — host execution" + toggle visible + /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner uses + /// compatibility host fallback by default and blocks only when strict fallback + /// blocking is explicitly enabled. /// private void UpdateSandboxStatusCard() { var availability = _cachedAvailability; var enabled = SandboxEnabledToggle.IsOn; + var blockHostFallback = CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false; - UpdateUnavailableActionBar(availability); + UpdateUnavailableActionBar(availability, enabled); if (availability is null) { @@ -217,9 +219,23 @@ private void UpdateSandboxStatusCard() if (!available) { SandboxStatusIcon.Text = "⚠"; - SandboxStatusTitle.Text = "Node Sandbox unavailable — commands run uncontained"; - SandboxStatusSubtext.Text = "Containment isn't available on this PC, so commands run without sandbox protection."; - SandboxEnabledToggle.Visibility = Visibility.Collapsed; + SandboxEnabledToggle.Visibility = Visibility.Visible; + + if (enabled && blockHostFallback) + { + SandboxStatusTitle.Text = "Node Sandbox unavailable — commands blocked"; + SandboxStatusSubtext.Text = "Containment isn't available on this PC, and strict fallback blocking is on, so agent-started commands are blocked."; + } + else if (enabled) + { + SandboxStatusTitle.Text = "Node Sandbox unavailable — host fallback"; + SandboxStatusSubtext.Text = "Containment isn't available on this PC, so agent-started commands run on the host without sandbox protection."; + } + else + { + SandboxStatusTitle.Text = "Node Sandbox is off — host execution"; + SandboxStatusSubtext.Text = "Containment isn't available and Node Sandbox is off, so agent-started commands run on the host without sandbox protection."; + } return; } @@ -260,7 +276,7 @@ private void UpdateSandboxStatusCard() /// - wxc-exec.exe missing → "Show install instructions" /// - Anything else → no primary action, just the learn-more hyperlink /// - private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? availability) + private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? availability, bool sandboxEnabled) { // Null = still probing; hide the bar until we have a verdict. if (availability is null || availability.HasAnyBackend) @@ -286,6 +302,11 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava && !availability.IsAppContainerAvailable; var isSetupIssue = !availability.IsWxcExecResolvable; + var blockHostFallback = sandboxEnabled + && (CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false); + var unavailableBehavior = blockHostFallback + ? "Commands are blocked while sandboxing is unavailable because strict fallback blocking is enabled. " + : "Commands will run on the host without sandbox protection while sandboxing is unavailable. "; if (isProbeError) { @@ -301,7 +322,7 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava { UnavailableActionBar.Title = "Your Windows version doesn't support sandboxing yet"; UnavailableActionMessage.Text = - $"{reasonText}\n\nCommands run uncontained on this machine — sandboxing requires a recent Windows build with the AppContainer primitives shipped. " + + $"{reasonText}\n\n{unavailableBehavior}Sandboxing requires a recent Windows build with the AppContainer primitives shipped. " + "Install the latest Windows updates (or join the Windows Insider Program for the newest builds) to enable containment."; UnavailablePrimaryButton.Content = "Open Windows Update"; UnavailablePrimaryButton.Tag = "windowsupdate"; @@ -311,7 +332,7 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava { UnavailableActionBar.Title = "Sandboxing components are missing"; UnavailableActionMessage.Text = - $"{reasonText}\n\nThe wxc-exec binary couldn't be located, so commands run uncontained. " + + $"{reasonText}\n\nThe wxc-exec binary couldn't be located. {unavailableBehavior}" + "If this is a developer build, build the tray app so wxc-exec.exe is copied into the output folder. " + "Otherwise reinstall the companion app to restore sandboxing."; UnavailablePrimaryButton.Content = "Show install instructions"; @@ -320,8 +341,10 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava } else { - UnavailableActionBar.Title = "Sandbox unavailable — commands run uncontained"; - UnavailableActionMessage.Text = reasonText; + UnavailableActionBar.Title = blockHostFallback + ? "Sandbox unavailable — commands blocked" + : "Sandbox unavailable — host fallback"; + UnavailableActionMessage.Text = $"{reasonText}\n\n{unavailableBehavior}"; UnavailablePrimaryButton.Visibility = Visibility.Collapsed; } diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 622b76b19..ba9733f19 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -567,8 +567,10 @@ private void DetachClientHandlers(WindowsNodeClient client) /// Build the for system.run. Returns an /// wrapping . /// The runner honors - /// and, per issue #494, falls back to - /// at runtime when MXC isn't available on this host. + /// by attempting MXC containment when available, preserving compatibility + /// host fallback when MXC is unavailable unless strict fallback blocking is + /// enabled, and rejecting unsupported sandbox request features while + /// sandboxing remains enabled. /// private ICommandRunner BuildSystemRunRunner() { @@ -597,11 +599,15 @@ private ICommandRunner BuildSystemRunRunner() else { // MXC unavailable on this host. The runner's top-level - // !_isSandboxAvailable() guard will route to the host fallback - // for every call; the executor is constructed only to satisfy - // the constructor contract and is never invoked. + // !_isSandboxAvailable() guard will either block or use the + // compatibility host fallback, depending on settings. The executor is + // constructed only to satisfy the constructor contract and is never + // invoked. var reason = string.Join("; ", peeked.UnsupportedReasons); - _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable, commands will run uncontained: {reason})"); + var unavailableMode = (_settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false) + ? "commands will be blocked by strict fallback settings" + : "commands will run through host fallback"; + _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable, {unavailableMode}: {reason})"); } var settingsDirectory = SettingsManager.SettingsDirectoryPath; @@ -629,12 +635,14 @@ private SettingsData SnapshotSettings() return new SettingsData { SystemRunSandboxEnabled = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, SystemRunAllowOutbound = false, }; return new SettingsData { SystemRunSandboxEnabled = _settings.SystemRunSandboxEnabled, + SystemRunBlockHostFallbackWhenMxcUnavailable = _settings.SystemRunBlockHostFallbackWhenMxcUnavailable, SystemRunAllowOutbound = _settings.SystemRunAllowOutbound, // Sandbox page fields — read by MxcPolicyBuilder.ForSystemRun. SandboxClipboard = _settings.SandboxClipboard, diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 8653dffce..52acbdc8c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -18,6 +18,7 @@ public class SettingsManager private readonly string _settingsDirectory; private readonly string _settingsFilePath; private const string ProtectedSecretPrefix = "dpapi:"; + private const int CurrentSettingsSchemaVersion = 1; private static readonly byte[] ProtectedSecretEntropy = Encoding.UTF8.GetBytes("OpenClawTray.Settings.v1"); public static string SettingsDirectoryPath => GetDefaultSettingsDirectory(); @@ -143,8 +144,10 @@ public List A2UIImageHosts public string? PreferredGatewayId { get => _data.PreferredGatewayId; set => _data = _data with { PreferredGatewayId = value }; } // ── MXC sandbox ───────────────────────────────────────────────────── - /// Master switch for system.run containment. When true (default), system.run runs sandboxed and is denied if MXC is unavailable. When false, system.run runs on host like before. + /// Master switch for system.run containment. When true (default), system.run uses MXC when available and falls back to host execution when unavailable unless strict fallback blocking is enabled. When false, system.run runs on host like before. public bool SystemRunSandboxEnabled { get => _data.SystemRunSandboxEnabled; set => _data = _data with { SystemRunSandboxEnabled = value }; } + /// When true, sandbox-enabled system.run blocks instead of using the compatibility host fallback if MXC is unavailable. Default false. + public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get => _data.SystemRunBlockHostFallbackWhenMxcUnavailable; set => _data = _data with { SystemRunBlockHostFallbackWhenMxcUnavailable = value }; } /// When sandboxed, allow system.run commands to reach the public internet. Default false. public bool SystemRunAllowOutbound { get => _data.SystemRunAllowOutbound; set => _data = _data with { SystemRunAllowOutbound = value }; } @@ -199,7 +202,7 @@ public void Load() var loaded = SettingsData.FromJson(json); if (loaded != null) { - _data = NormalizeLoadedData(loaded); + _data = NormalizeLoadedData(loaded, json); } } } @@ -213,6 +216,7 @@ public void Load() private static SettingsData CreateDefaultData() => new() { + SettingsSchemaVersion = CurrentSettingsSchemaVersion, GatewayUrl = "ws://localhost:18789", UseSshTunnel = false, SshTunnelUser = "", @@ -266,6 +270,7 @@ public void Load() SkippedUpdateTag = "", PreferredGatewayId = null, SystemRunSandboxEnabled = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, SystemRunAllowOutbound = false, SandboxClipboard = SandboxClipboardMode.None, SandboxDocumentsAccess = null, @@ -276,11 +281,12 @@ public void Load() SandboxMaxOutputBytes = 4 * 1024 * 1024 }; - private static SettingsData NormalizeLoadedData(SettingsData loaded) + private static SettingsData NormalizeLoadedData(SettingsData loaded, string? rawJson = null) { var defaults = CreateDefaultData(); var data = loaded with { + SettingsSchemaVersion = CurrentSettingsSchemaVersion, GatewayUrl = loaded.GatewayUrl ?? defaults.GatewayUrl, SshTunnelUser = loaded.SshTunnelUser ?? defaults.SshTunnelUser, SshTunnelHost = loaded.SshTunnelHost ?? defaults.SshTunnelHost, @@ -302,6 +308,7 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded) PreferredGatewayId = loaded.PreferredGatewayId ?? defaults.PreferredGatewayId, UserRules = loaded.UserRules != null ? new List(loaded.UserRules) : new(), SandboxCustomFolders = CloneSandboxCustomFolders(loaded.SandboxCustomFolders), + SystemRunBlockHostFallbackWhenMxcUnavailable = loaded.SystemRunBlockHostFallbackWhenMxcUnavailable, SandboxTimeoutMs = loaded.SandboxTimeoutMs > 0 ? loaded.SandboxTimeoutMs : defaults.SandboxTimeoutMs, SandboxMaxOutputBytes = loaded.SandboxMaxOutputBytes > 0 ? loaded.SandboxMaxOutputBytes : defaults.SandboxMaxOutputBytes, McpOnlyMode = null diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 353294de9..8edf4b18f 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -112,6 +112,27 @@ public async Task Run_AcceptsCommandAsArray() Assert.Equal(new[] { "hello", "world" }, runner.LastRequest.Args); } + [Fact] + public async Task Run_PassesApprovedEffectiveShellToRunner() + { + var cap = new SystemCapability(NullLogger.Instance); + var runner = new FakeCommandRunner { ForcedEffectiveShell = "cmd" }; + cap.SetCommandRunner(runner); + + var req = new NodeInvokeRequest + { + Id = "r1-shell", + Command = "system.run", + Args = Parse("""{"command":"hostname"}""") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + Assert.Equal("cmd", runner.LastRequest!.ApprovedEffectiveShell); + Assert.Null(runner.LastRequest.Shell); + } + [Fact] public async Task Run_AcceptsCommandAsString() { @@ -370,6 +391,29 @@ public async Task RunPrepare_ReturnsPlan_WithArgvAndCwd() Assert.Equal("agent1", agentId.GetString()); } + [Fact] + public async Task RunPrepare_ReturnsRequestedAndEffectiveShellFromRunner() + { + var runner = new FakeCommandRunner { ForcedEffectiveShell = "cmd" }; + var cap = new SystemCapability(NullLogger.Instance); + cap.SetCommandRunner(runner); + var req = new NodeInvokeRequest + { + Id = "p-shell", + Command = "system.run.prepare", + Args = Parse("""{"command":"echo hi","shell":"bash"}""") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + Assert.Equal("bash", runner.LastResolvedShell); + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); + var plan = payload.GetProperty("plan"); + Assert.Equal("bash", plan.GetProperty("requestedShell").GetString()); + Assert.Equal("cmd", plan.GetProperty("effectiveShell").GetString()); + } + [Fact] public async Task RunPrepare_ReturnsError_WhenMissingCommand() { @@ -743,6 +787,26 @@ private class FakeCommandRunner : ICommandRunner { public string Name => "fake"; public CommandRequest? LastRequest { get; private set; } + public string? LastResolvedShell { get; private set; } + public string? ForcedEffectiveShell { get; set; } + + public string ResolveEffectiveShell(string? requestedShell) + { + LastResolvedShell = requestedShell; + if (ForcedEffectiveShell != null) + return ForcedEffectiveShell; + + if (string.IsNullOrWhiteSpace(requestedShell)) + return "powershell"; + + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } public Task RunAsync(CommandRequest request, CancellationToken ct = default) { diff --git a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs index 21808c957..c8e808e9b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs @@ -117,4 +117,43 @@ public void Name_IsStableForTelemetry() Assert.Equal("mxc-direct-appc", executor.Name); Assert.True(executor.IsContained); } + + [Fact] + public void BuildRedactedConfigSummary_DoesNotExposeWxcExecOrRequestPaths() + { + var availability = new MxcAvailability( + isAppContainerAvailable: true, + isIsolationSessionAvailable: false, + isWxcExecResolvable: true, + wxcExecPath: "C:\\secret\\mxc\\wxc-exec.exe", + unsupportedReasons: Array.Empty()); + var config = new MxcConfig + { + ContainerId = "test", + Process = new MxcProcess + { + CommandLine = "cmd /c echo hi", + Cwd = "C:\\secret\\work", + Env = new[] { "SECRET_PATH=C:\\secret\\value" }, + }, + Filesystem = new MxcFilesystem + { + ReadwritePaths = new[] { "C:\\secret\\repo" }, + ReadonlyPaths = new[] { "C:\\secret\\docs" }, + DeniedPaths = new[] { "C:\\secret\\.ssh" }, + }, + }; + + var summary = DirectAppContainerExecutor.BuildRedactedConfigSummary( + availability, + config, + "{\"fake\":true}", + NewRequest()); + + Assert.Contains("wxcExec=", summary); + Assert.Contains("cwd=", summary); + Assert.Contains("envKeys=[SECRET_PATH]", summary); + Assert.DoesNotContain("C:\\secret", summary, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("cmd /c echo hi", summary, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json index 4f40a008a..a0b35ce9b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-alpha", + "version": "0.7.0-alpha", "containerId": "golden-balanced", "lifecycle": { "destroyOnExit": true, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json index 8de4398ad..d2cabfad9 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-alpha", + "version": "0.7.0-alpha", "containerId": "golden-custom", "lifecycle": { "destroyOnExit": true, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json index 0da09ef69..0b57f5b93 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-alpha", + "version": "0.7.0-alpha", "containerId": "golden-locked-down", "lifecycle": { "destroyOnExit": true, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json index c31f263a9..fb835a5e6 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-alpha", + "version": "0.7.0-alpha", "containerId": "golden-permissive", "lifecycle": { "destroyOnExit": true, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs index ce391dffd..ffb72188f 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs @@ -345,4 +345,5 @@ public void Probe_WhenProbeReportsDaclTier_ReportsDegraded() try { File.Delete(fakeExe); } catch { /* best-effort */ } } } + } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index 4ffba897c..ffb35f0db 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -91,20 +91,19 @@ public async Task SystemRun_EchoCmd_ExecutesInsideAppContainer() } [IntegrationFact] - public async Task SystemRun_PowerShell_ReturnsStdout() + public async Task SystemRun_DefaultShell_ExecutesInsideAppContainer() { var runner = TryBuildRunner(); if (runner is null) return; // skip — MXC unavailable on this host var result = await runner.RunAsync(new CommandRequest { - Command = "Write-Output 'pwsh-from-mxc'", - Shell = "powershell", + Command = "echo hello-default-mxc", TimeoutMs = 30_000, }); Assert.True( - result.ExitCode == 0 && result.Stdout.Contains("pwsh-from-mxc"), + result.ExitCode == 0 && result.Stdout.Contains("hello-default-mxc"), $"ExitCode={result.ExitCode}\nStdout={result.Stdout}\nStderr={result.Stderr}\nTimedOut={result.TimedOut}\nDurationMs={result.DurationMs}"); } @@ -183,6 +182,113 @@ public async Task SystemRun_CmdDir_ReadsGrantedCustomFolder() } } + [IntegrationFact] + public async Task SystemRun_FilesystemAccessMatrix_EnforcesReadwriteAndReadonlyPaths() + { + var root = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "openclaw-mxc-fs-matrix-" + Guid.NewGuid().ToString("N"))).FullName; + var rwDir = Directory.CreateDirectory(Path.Combine(root, "rw")).FullName; + var roDir = Directory.CreateDirectory(Path.Combine(root, "ro")).FullName; + + var roInput = Path.Combine(roDir, "input.txt"); + var rwOutput = Path.Combine(rwDir, "rw_marker.tmp"); + var roForbidden = Path.Combine(roDir, "forbidden.tmp"); + + await File.WriteAllTextAsync(roInput, "readonly test data"); + + try + { + if (!HasSupportedSandboxPath(root)) + { + Console.WriteLine( + "[mxc-integration] SKIPPING: filesystem matrix path is not in a supported local sandbox location."); + return; + } + + var runner = TryBuildRunner( + configure: settings => + { + settings.SandboxCustomFolders = new List + { + new() { Path = rwDir, Access = SandboxFolderAccess.ReadWrite }, + new() { Path = roDir, Access = SandboxFolderAccess.ReadOnly }, + }; + }); + if (runner is null) return; // skip — MXC unavailable on this host + + var command = string.Join(" & ", new[] + { + $"(echo RW_WRITE_VALUE > {CmdQuote(rwOutput)} && echo RW_WRITE=PASS || echo RW_WRITE=FAIL)", + $"(type {CmdQuote(rwOutput)} > nul && echo RW_READ=PASS || echo RW_READ=FAIL)", + $"(type {CmdQuote(roInput)} > nul && echo RO_READ=PASS || echo RO_READ=FAIL)", + $"(echo RO_WRITE_VALUE > {CmdQuote(roForbidden)} && echo RO_WRITE=PASS || echo RO_WRITE=FAIL)", + }); + + var result = await runner.RunAsync(new CommandRequest + { + Command = command, + Shell = "cmd", + TimeoutMs = 30_000, + }); + + Assert.False(result.TimedOut, $"Filesystem matrix timed out.\nStdout={result.Stdout}\nStderr={result.Stderr}"); + var matrix = ParseMatrix(result.Stdout); + AssertMatrix(matrix, "RW_WRITE", "PASS", result); + AssertMatrix(matrix, "RW_READ", "PASS", result); + AssertMatrix(matrix, "RO_READ", "PASS", result); + AssertMatrix(matrix, "RO_WRITE", "FAIL", result); + + Assert.True(File.Exists(rwOutput), $"RW output should exist on host: {rwOutput}"); + Assert.False(File.Exists(roForbidden), $"RO output should not exist on host: {roForbidden}"); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + [IntegrationFact] + public async Task SystemRun_CustomEnv_DeniesBeforeMxcUnavailableHostFallback() + { + var executor = new ThrowIfCalledSandboxExecutor(); + var hostFallback = new LocalCommandRunner(NullLogger.Instance); + var settings = new SettingsData + { + SystemRunSandboxEnabled = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, + }; + var runner = new MxcCommandRunner( + executor, + hostFallback, + () => settings, + () => Path.Combine(Path.GetTempPath(), "openclaw-mxc-smoke-test-settings"), + () => false, + invalidateAvailability: null, + new ConsoleLogger()); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo %OPENCLAW_MXC_ENV_FALLBACK_MARKER%", + Shell = "cmd", + Env = new Dictionary + { + ["OPENCLAW_MXC_ENV_FALLBACK_MARKER"] = "OPENCLAW_ENV_FALLBACK_SHOULD_NOT_RUN", + }, + TimeoutMs = 30_000, + }); + + Console.WriteLine( + "[mxc-integration] custom-env-deny " + + $"exitCode={result.ExitCode}; " + + $"fallbackMarkerSeen={result.Stdout.Contains("OPENCLAW_ENV_FALLBACK_SHOULD_NOT_RUN", StringComparison.Ordinal)}; " + + $"stderrContainsCustomEnv={result.Stderr.Contains("custom environment variables", StringComparison.OrdinalIgnoreCase)}"); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); + Assert.DoesNotContain("OPENCLAW_ENV_FALLBACK_SHOULD_NOT_RUN", result.Stdout); + Assert.Equal(0, executor.CallCount); + } + private static bool HasSupportedSandboxPath(string path) { try @@ -205,5 +311,51 @@ private static bool HasSupportedSandboxPath(string path) private static bool IsGitHubActions() => string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); -} + private static Dictionary ParseMatrix(string stdout) + { + var matrix = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var rawLine in stdout.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + var separator = line.IndexOf('='); + if (separator <= 0) + continue; + + var key = line[..separator]; + var value = line[(separator + 1)..]; + if (value is "PASS" or "FAIL") + matrix[key] = value; + } + + return matrix; + } + + private static void AssertMatrix( + IReadOnlyDictionary matrix, + string key, + string expected, + CommandResult result) + { + Assert.True(matrix.TryGetValue(key, out var actual), + $"Missing matrix key {key}.\nStdout={result.Stdout}\nStderr={result.Stderr}\nExitCode={result.ExitCode}"); + Assert.Equal(expected, actual); + } + + private static string CmdQuote(string value) => "\"" + value.Replace("\"", "\"\"") + "\""; + + private sealed class ThrowIfCalledSandboxExecutor : ISandboxExecutor + { + public string Name => "throw-if-called"; + public bool IsContained => true; + public int CallCount { get; private set; } + + public Task ExecuteAsync( + SandboxExecutionRequest request, + CancellationToken ct = default) + { + CallCount++; + throw new InvalidOperationException("Custom-env denial should happen before sandbox execution."); + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 70a199b98..fbf8e05a8 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -7,11 +7,14 @@ namespace OpenClaw.Shared.Tests.Mxc; public class MxcCommandRunnerTests { - private static SettingsData NewSettings(bool sandboxEnabled = true) + private static SettingsData NewSettings( + bool sandboxEnabled = true, + bool blockHostFallbackWhenMxcUnavailable = false) { return new SettingsData { SystemRunSandboxEnabled = sandboxEnabled, + SystemRunBlockHostFallbackWhenMxcUnavailable = blockHostFallbackWhenMxcUnavailable, SystemRunAllowOutbound = false, }; } @@ -34,23 +37,155 @@ private static MxcCommandRunner NewRunner( } [Fact] - public async Task RunAsync_SandboxEnabled_FallsBackToHostWhenExecutorIsUnavailable() + public void ResolveEffectiveShell_DefaultsToSandboxCmd_WhenSandboxEnabled() + { + var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner(new FakeSandboxExecutor(), fallback, NewSettings(sandboxEnabled: true)); + + Assert.Equal("cmd", runner.ResolveEffectiveShell(null)); + Assert.Equal("cmd", runner.ResolveEffectiveShell(" cmd ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); + } + + [Fact] + public void ResolveEffectiveShell_DelegatesToHost_WhenSandboxDisabled() + { + var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner(new FakeSandboxExecutor(), fallback, NewSettings(sandboxEnabled: false)); + + Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); + Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); + } + + [Fact] + public void ResolveEffectiveShell_DelegatesToHost_WhenMxcUnavailableAndCompatibilityFallbackEnabled() + { + var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner( + new FakeSandboxExecutor(), + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), + sandboxAvailable: false); + + Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); + Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); + } + + [Fact] + public void ResolveEffectiveShell_UsesSandboxShell_WhenStrictFallbackBlockingEnabledAndMxcUnavailable() + { + var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner( + new FakeSandboxExecutor(), + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: true), + sandboxAvailable: false); + + Assert.Equal("cmd", runner.ResolveEffectiveShell(null)); + Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailableAndCompatibilityFallbackEnabled() { - // Issue #494: when MXC is enabled but the executor reports unavailable at - // runtime, fall back to host instead of denying — older Windows users - // need their commands to run uncontained, with a warning in the UI. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; var fallback = new FakeCommandRunner { Result = new CommandResult { ExitCode = 0, Stdout = "host-ran" }, }; - var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false)); - var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "powershell" }); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("host-ran", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToApprovedHostDefaultWhenExecutorIsUnavailable() + { + var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host-ran" }, + }; + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false)); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + ApprovedEffectiveShell = "cmd", + ApprovedHostFallbackShell = "powershell", + }); Assert.Equal(0, result.ExitCode); Assert.Equal("host-ran", result.Stdout); Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_DeniesOmittedShellFallbackWhenHostDefaultWasNotApproved() + { + var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host-ran" }, + }; + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false)); + + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("without prior approval", result.Stderr); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_DeniesWhenEffectiveShellDriftsAfterApproval() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings(sandboxEnabled: true), + sandboxAvailable: true); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + ApprovedEffectiveShell = "powershell", + }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("effective shell changed after approval", result.Stderr); + Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); } [Fact] @@ -85,13 +220,54 @@ public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost() } [Fact] - public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() + public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() { - // Issue #494: on hosts where MXC is unavailable (Windows 10 / old build / - // missing wxc-exec), the agent must still be able to run commands. - // Route through the host runner; the Sandbox page UI shows a clear - // "running uncontained" warning so the user knows the protection - // boundary isn't active. + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + Env = new Dictionary { ["FOO"] = "bar" }, + }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); + Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_MxcUnavailable_RejectsCustomEnvWithoutHostFallback() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), + sandboxAvailable: false); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + Env = new Dictionary { ["FOO"] = "bar" }, + }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); + Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOff() + { + // Explicit sandbox opt-out means host execution is intentional, even on + // hosts where MXC is unavailable. var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner { @@ -112,10 +288,8 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() } [Fact] - public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() + public async Task RunAsync_MxcUnavailable_RoutesToHost_WhenCompatibilityFallbackEnabled() { - // With sandboxing enabled, unavailable MXC is detected before the - // executor path and routes to the host fallback. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" }; var fallback = new FakeCommandRunner { @@ -124,7 +298,9 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() var runner = NewRunner( executor, fallback, - NewSettings(sandboxEnabled: true), + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), sandboxAvailable: false); var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); @@ -132,9 +308,31 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() Assert.Equal(0, result.ExitCode); Assert.Equal("host", result.Stdout); Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); Assert.Null(executor.LastRequest); } + [Fact] + public async Task RunAsync_MxcUnavailable_Denies_WithStrictFallbackBlocking() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: true), + sandboxAvailable: false); + + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("host fallback is blocked", result.Stderr); + Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); + } + [Fact] public async Task RunAsync_Success_MapsSandboxResultIntoCommandResult() { @@ -173,10 +371,33 @@ public async Task RunAsync_Success_MapsSandboxResultIntoCommandResult() Assert.Equal(5000, executor.LastRequest.TimeoutMs); } + [Fact] + public async Task RunAsync_DefaultShell_UsesCmdForMxcProcessContainer() + { + var executor = new FakeSandboxExecutor + { + Result = new SandboxExecutionResult( + ExitCode: 0, + Stdout: "hello", + Stderr: string.Empty, + TimedOut: false, + DurationMs: 1, + ContainmentTag: "mxc"), + }; + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + await runner.RunAsync(new CommandRequest { Command = "echo hello" }); + + Assert.NotNull(executor.LastRequest); + var args = executor.LastRequest!.Args; + Assert.Equal("cmd", args.GetProperty("shell").GetString()); + } + [Fact] public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() { - // SandboxUnavailableException is the only exception that triggers the deny path. + // SandboxUnavailableException is the only exception that triggers the fallback path. // A normal failed exec inside the sandbox propagates as an error CommandResult. var executor = new FakeSandboxExecutor { @@ -203,9 +424,8 @@ public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndFallsBack() { // When the executor throws SandboxUnavailableException at runtime the - // runner invokes its invalidate-availability callback (so the next - // command re-probes) AND falls back to the host runner for this call - // (issue #494 — don't strand the agent). + // runner invokes its invalidate-availability callback and preserves + // the compatible host fallback path for this call. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "wxc-exec went missing" }; var fallback = new FakeCommandRunner { @@ -215,18 +435,82 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa var runner = new MxcCommandRunner( executor, fallback, - () => NewSettings(sandboxEnabled: true), + () => NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), () => "C:\\test\\settings", () => true, invalidateAvailability: () => invalidationCount++, NullLogger.Instance); - var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "powershell" }); Assert.Equal(0, result.ExitCode); Assert.Equal("host", result.Stdout); Assert.Equal(1, invalidationCount); Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_CustomEnv_RejectsBeforeReprobeOrHostFallback() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var sandboxAvailable = true; + var invalidationCount = 0; + var runner = new MxcCommandRunner( + executor, + fallback, + () => NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), + () => "C:\\test\\settings", + () => sandboxAvailable, + invalidateAvailability: () => + { + invalidationCount++; + sandboxAvailable = false; + }, + NullLogger.Instance); + + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + Shell = "powershell", + Env = new Dictionary { ["FOO"] = "bar" }, + }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); + Assert.Equal(0, invalidationCount); + Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_SandboxUnavailableException_Denies_WhenStrictFallbackBlockingEnabled() + { + var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "wxc-exec went missing" }; + var fallback = new FakeCommandRunner(); + var invalidationCount = 0; + var runner = new MxcCommandRunner( + executor, + fallback, + () => NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: true), + () => "C:\\test\\settings", + () => true, + invalidateAvailability: () => invalidationCount++, + NullLogger.Instance); + + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("host fallback is blocked", result.Stderr); + Assert.Equal(1, invalidationCount); + Assert.Null(fallback.LastRequest); } [Fact] @@ -247,7 +531,68 @@ public async Task RunAsync_GenericException_ReturnsDeny_DoesNotPropagate() Assert.Equal(-1, result.ExitCode); Assert.Contains("bridge JSON parse error", result.Stderr); Assert.Contains("InvalidOperationException", result.Stderr); - // Host fallback must NOT have been touched — fail closed, not fallback. + // Unexpected executor errors must not become host execution. + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_PowerShellUiUnsupported_DeniesEvenWhenCompatibilityFallbackEnabled() + { + var executor = new FakeSandboxExecutor + { + ThrowsArbitrary = new NotSupportedException("PowerShell-family shells require UI access"), + }; + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; + var runner = NewRunner( + executor, + fallback, + NewSettings(sandboxEnabled: true, blockHostFallbackWhenMxcUnavailable: false)); + + var result = await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = "powershell" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("cannot execute PowerShell-family shells", result.Stderr); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_PowerShellUiUnsupported_DeniesWhenStrictFallbackBlockingEnabled() + { + var executor = new FakeSandboxExecutor + { + ThrowsArbitrary = new NotSupportedException("PowerShell-family shells require UI access"), + }; + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings(sandboxEnabled: true, blockHostFallbackWhenMxcUnavailable: true)); + + var result = await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = "powershell" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("cannot execute PowerShell-family shells", result.Stderr); + Assert.Null(fallback.LastRequest); + } + + [Fact] + public async Task RunAsync_OtherNotSupportedException_ReturnsExplicitDeny_DoesNotFallBack() + { + var executor = new FakeSandboxExecutor + { + ThrowsArbitrary = new NotSupportedException("Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."), + }; + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("Explicit environment variables are not supported", result.Stderr); + Assert.DoesNotContain("unexpected", result.Stderr, StringComparison.OrdinalIgnoreCase); Assert.Null(fallback.LastRequest); } @@ -340,6 +685,22 @@ private sealed class FakeCommandRunner : ICommandRunner public string Name => "fake-host"; public CommandRequest? LastRequest { get; private set; } public CommandResult Result { get; set; } = new() { ExitCode = 0, Stdout = string.Empty }; + public string EffectiveShellForNull { get; set; } = "powershell"; + + public string ResolveEffectiveShell(string? requestedShell) + { + if (string.IsNullOrWhiteSpace(requestedShell)) + return EffectiveShellForNull; + + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } + public Task RunAsync(CommandRequest request, CancellationToken ct = default) { LastRequest = request; @@ -385,7 +746,66 @@ public async Task RunAsync_PassesMaxOutputBytesToExecutor() } [Fact] - public async Task RunAsync_LogsSandboxSettingsSnapshotAndPolicy() + public async Task RunAsync_SandboxRequestUsesNormalizedEffectiveShellForUnsupportedExplicitShell() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "bash" }); + + Assert.NotNull(executor.LastRequest); + Assert.Equal("powershell", executor.LastRequest!.Args.GetProperty("shell").GetString()); + Assert.False(executor.LastRequest.Policy.Ui!.AllowWindows); + } + + [Theory] + [InlineData("powershell")] + [InlineData("pwsh")] + public async Task RunAsync_SandboxRequestKeepsUiDeniedForPowerShellFamilyShells(string shell) + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = shell }); + + Assert.NotNull(executor.LastRequest); + Assert.False(executor.LastRequest!.Policy.Ui!.AllowWindows); + } + + [Fact] + public async Task RunAsync_SandboxRequestKeepsUiDeniedForCmdShell() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + + await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "cmd" }); + + Assert.NotNull(executor.LastRequest); + Assert.False(executor.LastRequest!.Policy.Ui!.AllowWindows); + } + + [Fact] + public async Task RunAsync_HostFallbackUsesNormalizedEffectiveShellForUnsupportedExplicitShell() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings(sandboxEnabled: true, blockHostFallbackWhenMxcUnavailable: false), + sandboxAvailable: false); + + await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "bash" }); + + Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_LogsRedactedSandboxSettingsAndPolicySummary() { var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner(); @@ -403,14 +823,50 @@ public async Task RunAsync_LogsSandboxSettingsSnapshotAndPolicy() await runner.RunAsync(new CommandRequest { Command = "echo hi" }); var requestLog = Assert.Single(logger.DebugMessages, m => m.Contains("system.run sandbox request", StringComparison.Ordinal)); - Assert.Contains("sandboxSettingsJson=", requestLog); - Assert.Contains("\"systemRunAllowOutbound\":true", requestLog); - Assert.Contains("\"sandboxClipboard\":\"both\"", requestLog); - Assert.Contains("\"path\":\"C:\\\\Code\\\\repo\"", requestLog); - Assert.Contains("\"access\":\"readWrite\"", requestLog); - Assert.Contains("policyJson=", requestLog); - Assert.Contains("\"network\":{\"allowOutbound\":true", requestLog); - Assert.Contains("\"readwritePaths\":[\"C:\\\\Code\\\\repo\"", requestLog); + Assert.Contains("sandboxSettings={enabled=True", requestLog); + Assert.Contains("allowOutbound=True", requestLog); + Assert.Contains("clipboard=Both", requestLog); + Assert.Contains("customFolderCount=1", requestLog); + Assert.Contains("settingsDirectoryPath=", requestLog); + Assert.Contains("policy={readonlyCount=", requestLog); + Assert.Contains("readwriteCount=1", requestLog); + Assert.Contains("networkAllowOutbound=True", requestLog); + Assert.DoesNotContain("sandboxSettingsJson=", requestLog); + Assert.DoesNotContain("policyJson=", requestLog); + Assert.DoesNotContain("C:\\Code\\repo", requestLog, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("C:\\test\\settings", requestLog, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RunAsync_LogsFullSandboxSettingsAndPolicy_WhenFullConfigDiagnosticsEnabled() + { + var previous = Environment.GetEnvironmentVariable(DirectAppContainerExecutor.LogFullConfigEnvVar); + try + { + Environment.SetEnvironmentVariable(DirectAppContainerExecutor.LogFullConfigEnvVar, "1"); + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var settings = NewSettings(sandboxEnabled: true); + settings.SystemRunAllowOutbound = true; + settings.SandboxCustomFolders = new() + { + new SandboxCustomFolder { Path = "C:\\Code\\repo", Access = SandboxFolderAccess.ReadWrite }, + }; + var logger = new CapturingLogger(); + var runner = NewRunner(executor, fallback, settings, logger: logger); + + await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + var fullLog = Assert.Single(logger.DebugMessages, m => m.Contains("system.run sandbox request (full)", StringComparison.Ordinal)); + Assert.Contains("sandboxSettingsJson=", fullLog); + Assert.Contains("policyJson=", fullLog); + Assert.Contains("\"path\":\"C:\\\\Code\\\\repo\"", fullLog); + Assert.Contains("\"readwritePaths\":[\"C:\\\\Code\\\\repo\"", fullLog); + } + finally + { + Environment.SetEnvironmentVariable(DirectAppContainerExecutor.LogFullConfigEnvVar, previous); + } } [Fact] @@ -430,10 +886,8 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout() } [Fact] - public async Task RunAsync_UnavailableExecutor_FallsBackToHost() + public async Task RunAsync_UnavailableExecutor_FallsBackToHost_WhenCompatibilityFallbackEnabled() { - // Issue #494: executor reports unavailable at runtime → fall back to - // host runner with a warning, not a -1 deny. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, @@ -443,12 +897,18 @@ public async Task RunAsync_UnavailableExecutor_FallsBackToHost() { Result = new CommandResult { ExitCode = 0, Stdout = "host" }, }; - var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); + var runner = NewRunner( + executor, + fallback, + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false)); - var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + var result = await runner.RunAsync(new CommandRequest { Command = "echo hi", Shell = "powershell" }); Assert.Equal(0, result.ExitCode); Assert.Equal("host", result.Stdout); Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 92895f93f..1750de1f5 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using Xunit; using OpenClaw.Shared.Mxc; @@ -10,7 +11,7 @@ namespace OpenClaw.Shared.Tests.Mxc; /// Golden tests vs SDK output captured by tools/mxc/dump-sdk-config.cjs. /// Per-Sandbox-UI-setting audit tests. /// Round-trip JSON shape (camelCase, no orphan fields). -/// Env scrub case-insensitivity. +/// Explicit env limitation for the Windows MXC 0.7 processcontainer backend. /// ResolveToolDirsFromPath via synthetic PATH. /// /// @@ -41,6 +42,8 @@ private static class P P.Settings, P.Ssh, P.Chrome, P.Edge, P.Brave, P.Firefox, P.PsRead, }; + private static readonly Func DeniedPathExists = _ => true; + private static SandboxPolicy LockedDownPolicy() => new( Version: MxcPolicyBuilder.SupportedPolicyVersion, Filesystem: new FilesystemPolicy( @@ -91,6 +94,31 @@ private static class P Policy: policy, TimeoutMs: 0); // explicitly zero so timeout in builder uses request.TimeoutMs=0 → default 30s + private static MxcConfig BuildConfig( + SandboxExecutionRequest request, + string scratchDir = P.Scratch, + string? containerId = null, + string? pathEnvVar = "", + Func? deniedPathExists = null, + Func? readonlyGrantIsBackendSafe = null) => + MxcConfigBuilder.Build( + request, + scratchDir, + new MxcConfigBuildContext( + ContainerId: containerId, + PathEnvVar: pathEnvVar, + DeniedPathExists: deniedPathExists ?? DeniedPathExists, + ReadonlyGrantIsBackendSafe: readonlyGrantIsBackendSafe)); + + private static string ExpectedSystemCmdExe() + { + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + return string.IsNullOrWhiteSpace(systemRoot) + ? "cmd.exe" + : Path.Combine(systemRoot, "System32", "cmd.exe"); + } + [Theory] [InlineData("locked-down", "LockedDown")] [InlineData("balanced", "Balanced")] @@ -107,11 +135,15 @@ public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod) }; // The golden test reproduces the exact harness recipe: no commandLine, - // no env, no cwd, no PATH-resolved tool dirs. We pass an empty PATH and - // an empty agent env. The harness stripped process.* (commandLine, cwd, - // env, timeout) too; do the same on the C# side before comparing. - var request = RequestFor(policy); - var config = MxcConfigBuilder.Build( + // no cwd, no shell PATH dirs. We pass an empty PATH and no + // caller env. The C# config still emits env: [] to make the sandbox env + // boundary explicit and bootstraps shell env in commandLine; the + // harness stripped process.* (commandLine, cwd, env, timeout), so do + // the same on the C# side before comparing. Use cmd so this pure + // policy golden stays independent from shell command-line encoding. + using var argsDoc = JsonDocument.Parse("""{"shell":"cmd"}"""); + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + var config = BuildConfig( request, scratchDir: P.Scratch, containerId: GoldenContainerId, @@ -172,7 +204,7 @@ private static string ResolveGoldenPath(string preset) public void Build_OutboundOn_AddsInternetClientCapability() { var policy = BalancedPolicy(); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); Assert.Contains("internetClient", config.ProcessContainer!.Capabilities!); Assert.Equal("allow", config.Network!.DefaultPolicy); } @@ -181,7 +213,7 @@ public void Build_OutboundOn_AddsInternetClientCapability() public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks() { var policy = LockedDownPolicy(); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); Assert.DoesNotContain("internetClient", config.ProcessContainer!.Capabilities!); Assert.Equal("block", config.Network!.DefaultPolicy); } @@ -194,19 +226,19 @@ public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks() public void Build_ClipboardMode_RoundTripsToWxcExecString(ClipboardPolicy mode, string expected) { var policy = LockedDownPolicy() with { Ui = new UiPolicy(false, mode, false) }; - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); Assert.Equal(expected, config.Ui!.Clipboard); } [Fact] public void Build_AddsScratchDirToReadwritePaths() { - var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(BalancedPolicy()), pathEnvVar: ""); Assert.Contains(P.Scratch, config.Filesystem!.ReadwritePaths!); } [Fact] - public void Build_OverridesTempEnvVarsToScratch() + public void Build_RejectsExplicitEnvironmentUntilBackendSupportsIt() { var request = RequestFor(BalancedPolicy()) with { @@ -217,18 +249,17 @@ public void Build_OverridesTempEnvVarsToScratch() ["TMPDIR"] = "C:\\real-tmpdir", }, }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); - var env = config.Process.Env!; - Assert.Contains($"TEMP={P.Scratch}", env); - Assert.Contains($"TMP={P.Scratch}", env); - Assert.Contains($"TMPDIR={P.Scratch}", env); + + var ex = Assert.Throws(() => + BuildConfig(request, pathEnvVar: "")); + Assert.Contains("Explicit environment variables", ex.Message); } [Fact] public void Build_AutoGrantsCwdAsReadonly_WhenNotAlreadyCovered() { var request = RequestFor(BalancedPolicy()) with { Cwd = "C:\\unrelated\\workdir" }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); Assert.Contains("C:\\unrelated\\workdir", config.Filesystem!.ReadonlyPaths!); Assert.DoesNotContain("C:\\unrelated\\workdir", config.Filesystem!.ReadwritePaths!); } @@ -238,7 +269,7 @@ public void Build_DoesNotDowngradeCwd_WhenAlreadyCoveredByReadwrite() { var policy = PermissivePolicy(); // Documents already in readwrite var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!); Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadwritePaths!); @@ -249,7 +280,7 @@ public void Build_DoesNotAutoGrantCwd_WhenAlreadyCovered() { var policy = BalancedPolicy(); // Documents already in readonly var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); // Should not have added the subfolder explicitly (parent already grants). Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!); } @@ -259,20 +290,78 @@ public void Build_DoesNotAutoGrantCwd_WhenOverlapsDenied() { var policy = BalancedPolicy(); var request = RequestFor(policy) with { Cwd = Path.Combine(P.Ssh, "keys") }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadonlyPaths!); } [Fact] - public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() + public void Build_FiltersHostProfileDeniedPathsBeforeBackendEmissionButStillFiltersAllows() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + return; + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + return; + + var chromeProfile = Path.Combine(localAppData, "Google", "Chrome", "User Data"); + var sshPath = Path.Combine(userProfile, ".ssh"); + var settingsDeny = Path.Combine(P.Settings, "openclaw-settings"); + var policy = new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { chromeProfile, userProfile }, + ReadonlyPaths: Array.Empty(), + DeniedPaths: new[] { chromeProfile, sshPath, settingsDeny }, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000); + + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); + + Assert.DoesNotContain(chromeProfile, config.Filesystem!.ReadwritePaths!, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(userProfile, config.Filesystem.ReadwritePaths!, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(chromeProfile, config.Filesystem.DeniedPaths!, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(sshPath, config.Filesystem.DeniedPaths!, StringComparer.OrdinalIgnoreCase); + Assert.Contains(settingsDeny, config.Filesystem.DeniedPaths!, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Build_FiltersMissingDeniedPathsBeforeBackendEmissionButStillFiltersAllows() { - // Synthesize an existing dir on PATH; ensure it shows up as a readonly - // grant candidate. No tool-name filter — every existing PATH dir - // counts (mirrors the SDK's getAvailableToolsPolicy behavior). + var parent = "C:\\Users\\example\\AppData\\Roaming"; + var missingSettingsDeny = Path.Combine(parent, "OpenClawTray"); + var policy = new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { parent }, + ReadonlyPaths: Array.Empty(), + DeniedPaths: new[] { missingSettingsDeny }, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000); + + var config = BuildConfig( + RequestFor(policy), + pathEnvVar: "", + deniedPathExists: _ => false); + + Assert.DoesNotContain(parent, config.Filesystem!.ReadwritePaths!, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(missingSettingsDeny, config.Filesystem.DeniedPaths!, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void ResolvePathDirsForShellPath_ReturnsExistingPathDirs() + { + // Synthesize an existing dir on PATH; ensure it shows up in the shell + // PATH bootstrap. var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-tool-test-" + Guid.NewGuid().ToString("N"))).FullName; try { - var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: tempDir); + var dirs = MxcConfigBuilder.ResolvePathDirsForShellPath(pathEnvVar: tempDir); Assert.Contains(tempDir, dirs); } finally @@ -283,14 +372,84 @@ public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() } [Fact] - public void Build_SynthesizesPathEnvFromGrantedPathDirs() + public void Build_BootstrapsShellPathAndGrantsBackendSafePathDirsReadonly() { var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-test-" + Guid.NewGuid().ToString("N"))).FullName; try { - var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: tempDir); - Assert.Contains($"PATH={tempDir}", config.Process.Env!); + using var argsDoc = JsonDocument.Parse("""{"command":"git --version","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: tempDir); + + Assert.NotNull(config.Process.Env); + Assert.Empty(config.Process.Env); Assert.Contains(tempDir, config.Filesystem!.ReadonlyPaths!); + Assert.Contains($"set \"TEMP={P.Scratch}\"", config.Process.CommandLine); + Assert.Contains($"set \"TMP={P.Scratch}\"", config.Process.CommandLine); + Assert.Contains($"set \"TMPDIR={P.Scratch}\"", config.Process.CommandLine); + Assert.Contains($"set \"PATH={tempDir}\"", config.Process.CommandLine); + Assert.Contains("git --version", config.Process.CommandLine); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void Build_BootstrapsUnsafePathDirsWithoutGrantingThem() + { + var unsafeDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-unsafe-" + Guid.NewGuid().ToString("N"))).FullName; + var safeDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-safe-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + using var argsDoc = JsonDocument.Parse("""{"command":"tool --version","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + var pathEnv = string.Join(Path.PathSeparator, unsafeDir, safeDir); + + var config = BuildConfig( + request, + pathEnvVar: pathEnv, + readonlyGrantIsBackendSafe: dir => !string.Equals(dir, unsafeDir, StringComparison.OrdinalIgnoreCase)); + + Assert.Contains($"set \"PATH={pathEnv}\"", config.Process.CommandLine); + Assert.DoesNotContain(unsafeDir, config.Filesystem!.ReadonlyPaths!, StringComparer.OrdinalIgnoreCase); + Assert.Contains(safeDir, config.Filesystem.ReadonlyPaths!, StringComparer.OrdinalIgnoreCase); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(unsafeDir, true); } catch { } + try { Directory.Delete(safeDir, true); } catch { } + } + } + + [Fact] + public void Build_CmdShell_RewritesBootstrapPercentEnvRefsToDelayedExpansion() + { + var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-test-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + using var argsDoc = JsonDocument.Parse(""" + { + "command": "echo %TEMP% %TMP% %TMPDIR% %PATH%", + "shell": "cmd", + "args": ["%TEMP%\\out.txt"] + } + """); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: tempDir); + + Assert.Contains(" /V:ON /S /C \"", config.Process.CommandLine, StringComparison.Ordinal); + Assert.Contains("echo !TEMP! !TMP! !TMPDIR! !PATH!", config.Process.CommandLine, StringComparison.Ordinal); + Assert.Contains("!TEMP!\\out.txt", config.Process.CommandLine, StringComparison.Ordinal); + Assert.DoesNotContain("%TEMP%", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("%TMP%", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("%TMPDIR%", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("%PATH%", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); } finally { @@ -300,7 +459,7 @@ public void Build_SynthesizesPathEnvFromGrantedPathDirs() } [Fact] - public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal() + public void Build_DoesNotAddDriveRootCompatibilityGrant() { var policy = new SandboxPolicy( Version: MxcPolicyBuilder.SupportedPolicyVersion, @@ -313,8 +472,8 @@ public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal() Ui: new UiPolicy(false, ClipboardPolicy.None, false), TimeoutMs: 30_000); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); - Assert.Contains("C:\\", config.Filesystem!.ReadonlyPaths!); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); + Assert.DoesNotContain("C:\\", config.Filesystem!.ReadonlyPaths!); } [Fact] @@ -331,26 +490,59 @@ public void Build_DoesNotTreatReadwriteChildAsCoveringParentCwd() Ui: new UiPolicy(false, ClipboardPolicy.None, false), TimeoutMs: 30_000); - var config = MxcConfigBuilder.Build(RequestFor(policy) with { Cwd = "C:\\workspace" }, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(policy) with { Cwd = "C:\\workspace" }, pathEnvVar: ""); Assert.Contains("C:\\workspace", config.Filesystem!.ReadonlyPaths!); Assert.DoesNotContain("C:\\workspace", config.Filesystem!.ReadwritePaths!); } [Fact] - public void ResolvePathDirsForReadonly_SkipsNonExistentDirs() + public void ResolvePathDirsForShellPath_SkipsNonExistentDirs() { var fake = Path.Combine(Path.GetTempPath(), "definitely-not-real-xyzqq-" + Guid.NewGuid().ToString("N")); - var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: fake); + var dirs = MxcConfigBuilder.ResolvePathDirsForShellPath(pathEnvVar: fake); Assert.Empty(dirs); } [Fact] - public void ResolvePathDirsForReadonly_SkipsDriveRoots() + public void ResolvePathDirsForShellPath_SkipsDriveRoots() { - var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: "C:\\"); + var dirs = MxcConfigBuilder.ResolvePathDirsForShellPath(pathEnvVar: "C:\\"); Assert.Empty(dirs); } + [Fact] + public void ResolvePathDirsForShellPath_KeepsProtectedDirsInPathOnly() + { + if (!OperatingSystem.IsWindows()) + return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrWhiteSpace(programFiles)) + return; + + var dirs = MxcConfigBuilder.ResolvePathDirsForShellPath(pathEnvVar: programFiles); + Assert.Contains(programFiles, dirs, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Build_BootstrapsProtectedPathDirsWithoutGrantingThem() + { + if (!OperatingSystem.IsWindows()) + return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrWhiteSpace(programFiles)) + return; + + using var argsDoc = JsonDocument.Parse("""{"command":"tool --version","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: programFiles); + + Assert.Contains($"set \"PATH={programFiles}\"", config.Process.CommandLine); + Assert.DoesNotContain(programFiles, config.Filesystem!.ReadonlyPaths!, StringComparer.OrdinalIgnoreCase); + } + [Fact] public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied() { @@ -365,7 +557,7 @@ public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied() Network: new NetworkPolicy(false, false), Ui: new UiPolicy(false, ClipboardPolicy.None, false), TimeoutMs: 30_000); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadwritePaths!); Assert.DoesNotContain(Path.Combine(P.Chrome, "Profile 1"), config.Filesystem!.ReadonlyPaths!); } @@ -373,7 +565,7 @@ public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied() [Fact] public void Build_TimeoutDefaultsTo30sWhenRequestZero() { - var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: ""); + var config = BuildConfig(RequestFor(BalancedPolicy()), pathEnvVar: ""); Assert.Equal(30_000, config.Process.TimeoutMs); } @@ -381,12 +573,296 @@ public void Build_TimeoutDefaultsTo30sWhenRequestZero() public void Build_TimeoutHonorsRequestValue() { var req = RequestFor(BalancedPolicy()) with { TimeoutMs = 12_345 }; - var config = MxcConfigBuilder.Build(req, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(req, pathEnvVar: ""); Assert.Equal(12_345, config.Process.TimeoutMs); } + [Fact] + public void Build_DefaultShell_UsesCmdAndPreservesUiDeny() + { + using var argsDoc = JsonDocument.Parse("""{"command":"echo hi"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + Assert.StartsWith(ExpectedSystemCmdExe(), config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(" /S /C \"set \"TEMP=", config.Process.CommandLine, StringComparison.Ordinal); + Assert.Contains("echo hi\"", config.Process.CommandLine, StringComparison.Ordinal); + Assert.True(config.Ui!.Disable); + Assert.Equal("container", config.ProcessContainer!.Ui!.Isolation); + } + + [Fact] + public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolation() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"powershell"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + Assert.False(config.Ui!.Disable); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); + } + + [Theory] + [InlineData("powershell")] + [InlineData("pwsh")] + public void Build_PowerShellFamilyShell_WhenUiDenied_FailsClosed(string shell) + { + using var argsDoc = JsonDocument.Parse($$"""{"command":"Write-Output hi","shell":"{{shell}}"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var ex = Assert.Throws(() => BuildConfig(request, pathEnvVar: "")); + + Assert.Contains("PowerShell-family shells require UI access", ex.Message); + } + + [Fact] + public void Build_UnsupportedShell_FailsClosedBeforeCommandLineFallback() + { + using var argsDoc = JsonDocument.Parse("""{"command":"echo hi","shell":"bash"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var ex = Assert.Throws(() => BuildConfig(request, pathEnvVar: "")); + + Assert.Contains("Unsupported shell 'bash'", ex.Message); + } + + [Fact] + public void Build_CmdShell_UsesResolvedCmdExe() + { + using var argsDoc = JsonDocument.Parse("""{"command":"echo hi","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + Assert.StartsWith(ExpectedSystemCmdExe(), config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(" /S /C \"set \"TEMP=", config.Process.CommandLine, StringComparison.Ordinal); + Assert.Contains("echo hi\"", config.Process.CommandLine, StringComparison.Ordinal); + Assert.True(config.Ui!.Disable); + Assert.Equal("container", config.ProcessContainer!.Ui!.Isolation); + } + + [Fact] + public void Build_CmdShell_CommandWithLineBreak_FailsClosed() + { + using var argsDoc = JsonDocument.Parse("""{"command":"echo ok\r\nwhoami","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var ex = Assert.Throws(() => BuildConfig(request, pathEnvVar: "")); + + Assert.Contains("cannot contain CR or LF", ex.Message); + } + + [Fact] + public void Build_CmdShell_ArgvWithLineBreak_FailsClosed() + { + using var argsDoc = JsonDocument.Parse("""{"command":"echo","args":["ok\r\nwhoami"],"shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var ex = Assert.Throws(() => BuildConfig(request, pathEnvVar: "")); + + Assert.Contains("cannot contain CR or LF", ex.Message); + } + + [Fact] + public void Build_CmdShell_IgnoresHostComSpec() + { + var previous = Environment.GetEnvironmentVariable("ComSpec"); + try + { + Environment.SetEnvironmentVariable("ComSpec", "C:\\malicious\\cmd.exe"); + using var argsDoc = JsonDocument.Parse("""{"command":"echo hi","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + Assert.StartsWith(ExpectedSystemCmdExe(), config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("C:\\malicious\\cmd.exe", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + } + finally + { + Environment.SetEnvironmentVariable("ComSpec", previous); + } + } + + [Fact] + public void Build_PwshShell_WhenPolicyAllowsWindows_UsesPwshAndEnablesDesktopIsolation() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + Assert.StartsWith("pwsh.exe", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); + Assert.False(config.Ui!.Disable); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); + } + + [Fact] + public void Build_PwshShell_ResolvesPwshFromPathBeforeClearingProcessEnvironment() + { + var tempRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-pwsh-resolve-test-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var binDir = Directory.CreateDirectory(Path.Combine(tempRoot, "bin")).FullName; + var pwshPath = Path.Combine(binDir, "pwsh.exe"); + File.WriteAllBytes(pwshPath, Array.Empty()); + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: binDir); + + var expectedPrefix = pwshPath.IndexOfAny(new[] { ' ', '\t', '"' }) < 0 + ? pwshPath + : "\"" + pwshPath.Replace("\"", "\\\"") + "\""; + Assert.StartsWith(expectedPrefix, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Empty(config.Process.Env!); + Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempRoot, true); } catch { } + } + } + + [Fact] + public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"powershell"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: ""); + + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + var expected = string.IsNullOrWhiteSpace(systemRoot) + ? "powershell.exe" + : Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + + Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); + Assert.False(config.Ui!.Disable); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); + } + + [Fact] + public void Build_PowerShellShell_QuotesPathBootstrapValue() + { + var tempRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-pwsh-path-test-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var dir1 = Directory.CreateDirectory(Path.Combine(tempRoot, "bin1")).FullName; + var dir2 = Directory.CreateDirectory(Path.Combine(tempRoot, "bin2")).FullName; + var pathEnv = string.Join(Path.PathSeparator, dir1, dir2); + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output $env:PATH","shell":"powershell"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: pathEnv); + var script = DecodePowershellEncodedCommand(config.Process.CommandLine); + + Assert.Contains("$env:PATH = '" + pathEnv.Replace("'", "''") + "';", script); + Assert.DoesNotContain("$env:PATH = " + pathEnv + ";", script); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempRoot, true); } catch { } + } + } + + [Fact] + public void Build_CmdShell_BoundsPathBootstrapBeforeCommandLine() + { + var tempRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-cmd-long-path-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var dirs = Enumerable.Range(0, 180) + .Select(i => Directory.CreateDirectory(Path.Combine(tempRoot, $"bin{i:D3}")).FullName) + .ToArray(); + var pathEnv = string.Join(Path.PathSeparator, dirs); + using var argsDoc = JsonDocument.Parse("""{"command":"echo %PATH%","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: pathEnv); + + Assert.Contains("set \"PATH=", config.Process.CommandLine, StringComparison.Ordinal); + Assert.Contains(dirs[0], config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(dirs[^1], config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.True(config.Process.CommandLine.Length < 12_000, config.Process.CommandLine); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempRoot, true); } catch { } + } + } + + [Fact] + public void Build_PowerShellShell_BoundsPathBootstrapBeforeEncoding() + { + var tempRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-pwsh-long-path-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var dirs = Enumerable.Range(0, 180) + .Select(i => Directory.CreateDirectory(Path.Combine(tempRoot, $"bin{i:D3}")).FullName) + .ToArray(); + var pathEnv = string.Join(Path.PathSeparator, dirs); + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output $env:PATH","shell":"powershell"}"""); + var policy = BalancedPolicy() with + { + Ui = new UiPolicy(AllowWindows: true, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + }; + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; + + var config = BuildConfig(request, pathEnvVar: pathEnv); + var script = DecodePowershellEncodedCommand(config.Process.CommandLine); + + Assert.Contains("$env:PATH = '", script, StringComparison.Ordinal); + Assert.Contains(dirs[0], script, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(dirs[^1], script, StringComparison.OrdinalIgnoreCase); + Assert.True(script.Length < 8_000, script); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempRoot, true); } catch { } + } + } + // ---- helpers for tolerant JSON comparison ---- + private static string DecodePowershellEncodedCommand(string commandLine) + { + const string marker = " -EncodedCommand "; + var markerIndex = commandLine.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + Assert.True(markerIndex >= 0, commandLine); + var encoded = commandLine[(markerIndex + marker.Length)..].Trim(); + return Encoding.Unicode.GetString(Convert.FromBase64String(encoded)); + } + private static void AssertJsonEqual(JsonObjectNode expected, JsonObjectNode actual, string path) { foreach (var key in expected.Keys) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs index cfbad2e07..dfe9a3443 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs @@ -28,11 +28,35 @@ public void ForSystemRun_DefaultSettings_DefaultDenyAcrossTheBoard() public void ForSystemRun_DeniesSettingsDirectoryPath() { var settings = new SettingsData(); - var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\Users\\test\\AppData\\OpenClawTray"); + var settingsDir = CreateTempDeniedDir(); + + try + { + var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDir); + + Assert.NotNull(policy.Filesystem); + Assert.NotNull(policy.Filesystem!.DeniedPaths); + Assert.Contains(policy.Filesystem.DeniedPaths!, p => + string.Equals(p, settingsDir, StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDeleteTempDir(settingsDir); + } + } + + [Fact] + public void ForSystemRun_DeniesMissingSettingsDirectoryPath() + { + var settings = new SettingsData(); + var missingSettingsDir = Path.Combine(Path.GetTempPath(), "openclaw-missing-settings-" + Guid.NewGuid().ToString("N")); + + var policy = MxcPolicyBuilder.ForSystemRun(settings, missingSettingsDir); Assert.NotNull(policy.Filesystem); Assert.NotNull(policy.Filesystem!.DeniedPaths); - Assert.Contains("C:\\Users\\test\\AppData\\OpenClawTray", policy.Filesystem.DeniedPaths!); + Assert.Contains(policy.Filesystem.DeniedPaths!, p => + string.Equals(p, missingSettingsDir, StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -43,8 +67,8 @@ public void ForSystemRun_DeniesSshDirectoryByDefault() Assert.NotNull(policy.Filesystem); Assert.NotNull(policy.Filesystem!.DeniedPaths); - // .ssh path is the home-relative one; verify it's present and ends with ".ssh". - Assert.Contains(policy.Filesystem.DeniedPaths!, p => p.EndsWith(".ssh")); + var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); + Assert.Contains(policy.Filesystem.DeniedPaths!, p => string.Equals(p, expected, StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -179,20 +203,50 @@ public void ForSystemRun_TimeoutMsZero_TreatedAsUnset() [Fact] public void ForSystemRun_BrowserProfileDirectories_AreDenied() { - // The UI claims SSH keys, browser profiles, and OpenClaw's own settings - // are always blocked. Verify the policy backs that claim for browsers — - // these paths must always appear in DeniedPaths regardless of settings, - // even if the browser isn't installed (the AppContainer policy treats - // nonexistent denies as a no-op). + // These roots stay in the logical deny list even when absent so parent + // grants cannot create sensitive profile directories. Backend emission + // is filtered later by MxcConfigBuilder for MXC 0.7 DACL safety. var settings = new SettingsData(); var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\settings"); var denied = policy.Filesystem!.DeniedPaths!; - Assert.Contains(denied, p => p.EndsWith("Google\\Chrome\\User Data", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(denied, p => p.EndsWith("Microsoft\\Edge\\User Data", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(denied, p => p.EndsWith("Mozilla\\Firefox\\Profiles", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(denied, p => p.EndsWith("BraveSoftware\\Brave-Browser\\User Data", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(denied, p => p.EndsWith("Microsoft\\Windows\\PowerShell\\PSReadLine", StringComparison.OrdinalIgnoreCase)); + AssertDeniedPath(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Google", "Chrome", "User Data")); + AssertDeniedPath(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "Edge", "User Data")); + AssertDeniedPath(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Mozilla", "Firefox", "Profiles")); + AssertDeniedPath(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "BraveSoftware", "Brave-Browser", "User Data")); + AssertDeniedPath(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", "Windows", "PowerShell", "PSReadLine")); + } + + [Fact] + public void ForSystemRun_UserProfileGrant_FilteredBecauseItContainsSsh() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + return; + + var settings = new SettingsData + { + SandboxCustomFolders = new() + { + new SandboxCustomFolder { Path = userProfile, Access = SandboxFolderAccess.ReadWrite }, + }, + }; + + var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\settings"); + + AssertDeniedPath(policy.Filesystem!.DeniedPaths!, Path.Combine(userProfile, ".ssh")); + Assert.DoesNotContain(policy.Filesystem.ReadwritePaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(userProfile), StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -200,22 +254,28 @@ public void ForSystemRun_CustomFolder_PointingAtDeniedPath_FilteredOut() { // A user (or malicious settings.json) can't punch through the always-denied // list by adding a custom folder grant equal to one of the denies. - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var sshPath = Path.Combine(userProfile, ".ssh"); + var settingsDir = CreateTempDeniedDir(); var settings = new SettingsData { SandboxCustomFolders = new() { - new SandboxCustomFolder { Path = sshPath, Access = SandboxFolderAccess.ReadWrite }, + new SandboxCustomFolder { Path = settingsDir, Access = SandboxFolderAccess.ReadWrite }, }, }; - var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\settings"); + try + { + var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDir); - Assert.DoesNotContain(policy.Filesystem!.ReadwritePaths!, p => - string.Equals(Path.GetFullPath(p), Path.GetFullPath(sshPath), StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(policy.Filesystem.ReadonlyPaths!, p => - string.Equals(Path.GetFullPath(p), Path.GetFullPath(sshPath), StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(policy.Filesystem!.ReadwritePaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(settingsDir), StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(policy.Filesystem.ReadonlyPaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(settingsDir), StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDeleteTempDir(settingsDir); + } } [Fact] @@ -224,40 +284,83 @@ public void ForSystemRun_CustomFolder_NestedInsideDeniedPath_FilteredOut() // Even subdirectories of denied paths must be stripped — a grant of // ~\.ssh\config or %LOCALAPPDATA%\Google\Chrome\User Data\Default // can't bleed through. - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var sshConfig = Path.Combine(userProfile, ".ssh", "config"); + var settingsDir = CreateTempDeniedDir(); + var nested = Path.Combine(settingsDir, "nested"); var settings = new SettingsData { SandboxCustomFolders = new() { - new SandboxCustomFolder { Path = sshConfig, Access = SandboxFolderAccess.ReadOnly }, + new SandboxCustomFolder { Path = nested, Access = SandboxFolderAccess.ReadOnly }, }, }; - var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\settings"); + try + { + var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDir); - Assert.DoesNotContain(policy.Filesystem!.ReadonlyPaths!, p => - Path.GetFullPath(p).StartsWith( - Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.Combine(userProfile, ".ssh"))), - StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(policy.Filesystem!.ReadonlyPaths!, p => + Path.GetFullPath(p).StartsWith( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(settingsDir)), + StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDeleteTempDir(settingsDir); + } } [Fact] public void ForSystemRun_CustomFolder_ParentOfDeniedPath_FilteredOut() { - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var settingsDir = CreateTempDeniedDir(); + var parent = Directory.GetParent(settingsDir)!.FullName; var settings = new SettingsData { SandboxCustomFolders = new() { - new SandboxCustomFolder { Path = userProfile, Access = SandboxFolderAccess.ReadWrite }, + new SandboxCustomFolder { Path = parent, Access = SandboxFolderAccess.ReadWrite }, }, }; - var policy = MxcPolicyBuilder.ForSystemRun(settings, "C:\\settings"); + try + { + var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDir); - Assert.DoesNotContain(policy.Filesystem!.ReadwritePaths!, p => - string.Equals(Path.GetFullPath(p), Path.GetFullPath(userProfile), StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(policy.Filesystem!.ReadwritePaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(parent), StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDeleteTempDir(settingsDir); + } + } + + [Fact] + public void ForSystemRun_CustomFolder_ParentOfMissingSettingsDirectory_FilteredOut() + { + var parent = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-settings-parent-" + Guid.NewGuid().ToString("N"))).FullName; + var missingSettingsDir = Path.Combine(parent, "OpenClawTray"); + var settings = new SettingsData + { + SandboxCustomFolders = new() + { + new SandboxCustomFolder { Path = parent, Access = SandboxFolderAccess.ReadWrite }, + }, + }; + + try + { + var policy = MxcPolicyBuilder.ForSystemRun(settings, missingSettingsDir); + + Assert.Contains(policy.Filesystem!.DeniedPaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(missingSettingsDir), StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(policy.Filesystem.ReadwritePaths!, p => + string.Equals(Path.GetFullPath(p), Path.GetFullPath(parent), StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDeleteTempDir(parent); + } } [Fact] @@ -277,6 +380,34 @@ public void ForSystemRun_CustomFolder_NotOverlappingDeny_StillGranted() Assert.Contains("D:\\code\\my-project", policy.Filesystem!.ReadwritePaths!); } + private static void AssertDeniedPath(IReadOnlyList denied, string path) + { + if (string.IsNullOrWhiteSpace(path)) + return; + + Assert.Contains(denied, p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + } + + private static string CreateTempDeniedDir() + { + var path = Path.Combine(Path.GetTempPath(), "openclaw-denied-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDeleteTempDir(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // Test cleanup is best-effort and should not hide the assertion result. + } + } + private static string GetExpectedDownloadsPath() { var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 1fa012cb7..24031766e 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -23,6 +23,10 @@ public GatewayClientTestHelper( string gatewayUrl = "ws://localhost:18789", string? identityPath = null) { + // Isolate test identities because other test classes can construct + // gateway clients concurrently under the same AppData root. + identityPath ??= CreateTempIdentityPath(); + _client = new OpenClawGatewayClient( gatewayUrl, "test-token", @@ -34,7 +38,11 @@ public GatewayClientTestHelper( public GatewayClientTestHelper(IOpenClawLogger logger) { - _client = new OpenClawGatewayClient("ws://localhost:18789", "test-token", logger); + _client = new OpenClawGatewayClient( + "ws://localhost:18789", + "test-token", + logger, + identityPath: CreateTempIdentityPath()); } public string ClassifyNotification(string text) @@ -1975,7 +1983,11 @@ public void ParseNodeListPayload_DefaultsForMinimalPayload() public async Task NodeRenameAsync_RejectsEmptyNodeId_WithoutHittingTransport() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); var result = await client.NodeRenameAsync("", "New Name"); @@ -1987,7 +1999,11 @@ public async Task NodeRenameAsync_RejectsEmptyNodeId_WithoutHittingTransport() public async Task NodeRenameAsync_RejectsEmptyDisplayName_WithoutHittingTransport() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); var result = await client.NodeRenameAsync("node-1", " "); @@ -1999,7 +2015,11 @@ public async Task NodeRenameAsync_RejectsEmptyDisplayName_WithoutHittingTranspor public async Task NodeRenameAsync_ReturnsErrorWhenNotConnected() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); var result = await client.NodeRenameAsync("node-1", "Pretty Name"); @@ -2011,7 +2031,11 @@ public async Task NodeRenameAsync_ReturnsErrorWhenNotConnected() public async Task NodePairRemoveAsync_ReturnsFailureForEmptyNodeId() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); var result = await client.NodePairRemoveAsync(""); @@ -2023,7 +2047,11 @@ public async Task NodePairRemoveAsync_ReturnsFailureForEmptyNodeId() public async Task NodePairRemoveAsync_ReturnsFailureWhenNotConnected() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); var result = await client.NodePairRemoveAsync("node-1"); @@ -2035,7 +2063,11 @@ public async Task NodePairRemoveAsync_ReturnsFailureWhenNotConnected() public void Constructor_InitializesWithProvidedValues() { var logger = new TestLogger(); - var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + var client = new OpenClawGatewayClient( + "http://test:8080", + "my-token", + logger, + identityPath: CreateTempIdentityPath()); // Verify URL was normalized (http → ws) — field is now on base class WebSocketClientBase var field = typeof(OpenClawGatewayClient).BaseType?.GetField( @@ -2049,7 +2081,10 @@ public void Constructor_InitializesWithProvidedValues() public void Constructor_UsesNullLogger_WhenNotProvided() { // Verify construction without logger doesn't throw and still normalizes URL - var client = new OpenClawGatewayClient("https://test:8080", "my-token"); + var client = new OpenClawGatewayClient( + "https://test:8080", + "my-token", + identityPath: CreateTempIdentityPath()); var field = typeof(OpenClawGatewayClient).BaseType?.GetField( "_gatewayUrl", @@ -2069,7 +2104,10 @@ public void Constructor_UsesNullLogger_WhenNotProvided() [InlineData("HTTPS://HOST.EXAMPLE.COM", "wss://HOST.EXAMPLE.COM")] public void Constructor_NormalizesHttpToWs(string inputUrl, string expectedWsUrl) { - var client = new OpenClawGatewayClient(inputUrl, "test-token"); + var client = new OpenClawGatewayClient( + inputUrl, + "test-token", + identityPath: CreateTempIdentityPath()); var field = typeof(OpenClawGatewayClient).BaseType?.GetField( "_gatewayUrl", diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index 67144f9e8..38d9c31d7 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -508,6 +508,169 @@ public async Task SystemRun_WithPromptPolicy_PayloadSessionKeyOverridesArgsSessi } } + [Fact] + public async Task SystemRun_WithPolicy_EvaluatesImplicitShellUsingRunnerEffectiveShell() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var logger = new ExecTestLogger(); + var policy = new ExecApprovalPolicy(tempDir, logger); + policy.SetRules( + new[] + { + new ExecApprovalRule + { + Pattern = "Get-Process", + Action = ExecApprovalAction.Allow, + Shells = new[] { "powershell" } + } + }, + ExecApprovalAction.Deny); + var runner = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var cap = new SystemCapability(logger); + cap.SetCommandRunner(runner); + cap.SetApprovalPolicy(policy); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "implicit-shell-policy", + Command = "system.run", + Args = Parse("""{"command":"Get-Process"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("denied", res.Error!, StringComparison.OrdinalIgnoreCase); + Assert.Null(runner.LastRequest); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task SystemRun_WithPolicy_NormalizesUnsupportedExplicitShellBeforeApproval() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var logger = new ExecTestLogger(); + var policy = new ExecApprovalPolicy(tempDir, logger); + policy.SetRules( + new[] + { + new ExecApprovalRule + { + Pattern = "Get-Process", + Action = ExecApprovalAction.Allow, + Shells = new[] { "bash" } + } + }, + ExecApprovalAction.Deny); + var runner = new FakeCommandRunner(); + var cap = new SystemCapability(logger); + cap.SetCommandRunner(runner); + cap.SetApprovalPolicy(policy); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "unsupported-explicit-shell-policy", + Command = "system.run", + Args = Parse("""{"command":"Get-Process","shell":"bash"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("denied", res.Error!, StringComparison.OrdinalIgnoreCase); + Assert.Null(runner.LastRequest); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task SystemRun_PreservesOmittedShellWhenCallingRunner() + { + var logger = new ExecTestLogger(); + var policy = new ExecApprovalPolicy(Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"), logger); + policy.SetRules( + new[] + { + new ExecApprovalRule + { + Pattern = "echo hi", + Action = ExecApprovalAction.Allow, + Shells = new[] { "cmd", "powershell" } + } + }, + ExecApprovalAction.Deny); + var runner = new FakeCommandRunner + { + EffectiveShellForNull = "cmd", + HostFallbackShellForApproval = "powershell" + }; + var cap = new SystemCapability(logger); + cap.SetCommandRunner(runner); + cap.SetApprovalPolicy(policy); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "preserve-omitted-shell", + Command = "system.run", + Args = Parse("""{"command":"echo hi"}""") + }); + + Assert.True(res.Ok, res.Error); + Assert.NotNull(runner.LastRequest); + Assert.Null(runner.LastRequest!.Shell); + Assert.Equal("powershell", runner.LastRequest.ApprovedHostFallbackShell); + } + + [Fact] + public async Task SystemRun_DeniesOmittedShellWhenFallbackShellIsNotApproved() + { + var logger = new ExecTestLogger(); + var policy = new ExecApprovalPolicy(Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"), logger); + policy.SetRules( + new[] + { + new ExecApprovalRule + { + Pattern = "echo hi", + Action = ExecApprovalAction.Allow, + Shells = new[] { "cmd" } + } + }, + ExecApprovalAction.Deny); + var runner = new FakeCommandRunner + { + EffectiveShellForNull = "cmd", + HostFallbackShellForApproval = "powershell" + }; + var cap = new SystemCapability(logger); + cap.SetCommandRunner(runner); + cap.SetApprovalPolicy(policy); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "deny-unapproved-fallback-shell", + Command = "system.run", + Args = Parse("""{"command":"echo hi"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("denied", res.Error!, StringComparison.OrdinalIgnoreCase); + Assert.Null(runner.LastRequest); + } + [Fact] public async Task SystemRun_WithPromptPolicy_PromptsOnceForShellWrapper_WhenUserApprovesOnce() { @@ -701,12 +864,31 @@ public async Task SystemRun_WithPromptPolicy_Denies_WhenUserDenies() /// /// Fake runner for unit testing — no actual process execution. /// - private class FakeCommandRunner : ICommandRunner + private class FakeCommandRunner : IHostFallbackAwareCommandRunner { public string Name => "fake"; public CommandRequest? LastRequest { get; private set; } public CommandResult Result { get; set; } = new() { Stdout = "ok", ExitCode = 0 }; public bool ShouldThrow { get; set; } + public string EffectiveShellForNull { get; set; } = "powershell"; + public string? HostFallbackShellForApproval { get; set; } + + public string ResolveEffectiveShell(string? requestedShell) + { + if (string.IsNullOrWhiteSpace(requestedShell)) + return EffectiveShellForNull; + + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } + + public string? ResolveHostFallbackShellForApproval(string? requestedShell, string effectiveShell) => + HostFallbackShellForApproval; public Task RunAsync(CommandRequest request, CancellationToken ct = default) { @@ -740,6 +922,80 @@ public Task RequestAsync( } } +public class LocalCommandRunnerTests +{ + [Fact] + public void BuildProcessArgs_DefaultShellUsesWindowsPowerShellWhenPwshAvailableOnPath() + { + var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-pwsh-path-" + Guid.NewGuid().ToString("N"))).FullName; + var fakePwsh = Path.Combine(tempDir, "pwsh.exe"); + try + { + File.WriteAllBytes(fakePwsh, Array.Empty()); + + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + }, pathEnvVar: tempDir); + + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide assertion failures. + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + } + + [Fact] + public void BuildProcessArgs_DefaultShellFallsBackToWindowsPowerShellWhenPwshMissing() + { + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + }, pathEnvVar: string.Empty); + + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + + [Fact] + public void BuildProcessArgs_ExplicitPwshDoesNotFallback() + { + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + Shell = "pwsh", + }, pathEnvVar: string.Empty); + + Assert.Equal("pwsh.exe", fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + + [Fact] + public void BuildProcessArgs_ExplicitWindowsPowerShellUsesWindowsPowerShell() + { + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + Shell = "powershell", + }); + + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + + private static string ExpectedWindowsPowerShellExe() + { + var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir"); + return string.IsNullOrWhiteSpace(systemRoot) + ? "powershell.exe" + : Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} + /// /// Integration tests for LocalCommandRunner — actually executes processes. /// Gated by OPENCLAW_RUN_INTEGRATION=1. @@ -1015,7 +1271,7 @@ public void LegacyPath_WhenArgvNull_WrapsInPowerShell() Assert.False(plan.IsDirectArgv); Assert.Null(plan.ArgList); - Assert.Equal("powershell.exe", plan.FileName); + Assert.EndsWith("powershell.exe", plan.FileName, StringComparison.OrdinalIgnoreCase); Assert.Contains("Write-Output hi", plan.Arguments); } diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index fd8f299f6..a777cb4b2 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -140,12 +140,20 @@ public void MxcSdk_IsRestoredCopiedValidatedAndIncludedInInstallerPayload() { var repositoryRoot = TestRepositoryPaths.GetRepositoryRoot(); var packageJson = File.ReadAllText(Path.Combine(repositoryRoot, "package.json")); + var packageLock = File.ReadAllText(Path.Combine(repositoryRoot, "package-lock.json")); var trayProject = File.ReadAllText(Path.Combine( repositoryRoot, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); var iss = File.ReadAllText(Path.Combine(repositoryRoot, "installer.iss")); Assert.Contains(@"""@microsoft/mxc-sdk""", packageJson); + Assert.Contains(@"""@microsoft/mxc-sdk"": ""^0.7.0""", packageJson); + Assert.Contains(@"""node_modules/@microsoft/mxc-sdk""", packageLock); + Assert.Contains(@"""version"": ""0.7.0""", packageLock); Assert.Contains("RestoreMxcNodeBridge", trayProject); + Assert.Contains(@"Inputs=""$(OpenClawRepoRoot)package-lock.json""", trayProject); + Assert.Contains(@"$(OpenClawRepoRoot)node_modules\.openclaw-mxc-sdk-$(MxcSdkExpectedVersion).stamp", trayProject); + Assert.Contains(@"Outputs=""$(MxcSdkRestoreStamp)""", trayProject); + Assert.Contains(@"", trayProject); Assert.Contains("npm ci --no-audit --no-fund", trayProject); Assert.Contains("CopyWxcExecToOutput", trayProject); Assert.Contains("CopyWxcExecToPublish", trayProject); diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 7eb049397..a45a4a53a 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -56,6 +56,9 @@ public void RoundTrip_AllFields_Preserved() SkippedUpdateTag = "v1.2.3", NotifyChatResponses = false, PreferStructuredCategories = true, + SystemRunSandboxEnabled = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = true, + SystemRunAllowOutbound = true, UserRules = new List { new() { Pattern = "build.*fail", IsRegex = true, Category = "urgent", Enabled = true } @@ -66,6 +69,7 @@ public void RoundTrip_AllFields_Preserved() var restored = SettingsData.FromJson(json); Assert.NotNull(restored); + Assert.Equal(original.SettingsSchemaVersion, restored.SettingsSchemaVersion); Assert.Equal(original.GatewayUrl, restored.GatewayUrl); Assert.Equal(original.UseSshTunnel, restored.UseSshTunnel); Assert.Equal(original.SshTunnelUser, restored.SshTunnelUser); @@ -111,6 +115,9 @@ public void RoundTrip_AllFields_Preserved() Assert.Equal(original.SkippedUpdateTag, restored.SkippedUpdateTag); Assert.Equal(original.NotifyChatResponses, restored.NotifyChatResponses); Assert.Equal(original.PreferStructuredCategories, restored.PreferStructuredCategories); + Assert.Equal(original.SystemRunSandboxEnabled, restored.SystemRunSandboxEnabled); + Assert.Equal(original.SystemRunBlockHostFallbackWhenMxcUnavailable, restored.SystemRunBlockHostFallbackWhenMxcUnavailable); + Assert.Equal(original.SystemRunAllowOutbound, restored.SystemRunAllowOutbound); Assert.NotNull(restored.UserRules); Assert.Single(restored.UserRules); Assert.Equal("build.*fail", restored.UserRules[0].Pattern); @@ -176,6 +183,9 @@ public void MissingFields_UseDefaults() Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.NotifyChatResponses); Assert.True(settings.PreferStructuredCategories); + Assert.True(settings.SystemRunSandboxEnabled); + Assert.False(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + Assert.False(settings.SystemRunAllowOutbound); // HubNavPaneOpen defaults to true (NavView starts expanded for new // installs and for any settings file that predates the field). Assert.True(settings.HubNavPaneOpen); @@ -194,6 +204,95 @@ public void HubNavPaneOpen_DefaultsTrue_ForEmptyJson() Assert.True(settings!.HubNavPaneOpen); } + [Fact] + public void SettingsManager_PreservesLegacySandboxFallbackDefault() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "settings.json"), """ + { + "SystemRunSandboxEnabled": true, + "SystemRunBlockHostFallbackWhenMxcUnavailable": false + } + """); + + var settings = new SettingsManager(dir); + + Assert.True(settings.SystemRunSandboxEnabled); + Assert.False(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + + settings.Save(); + + using var saved = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "settings.json"))); + Assert.Equal(1, saved.RootElement.GetProperty(nameof(SettingsData.SettingsSchemaVersion)).GetInt32()); + Assert.False(saved.RootElement.GetProperty(nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)).GetBoolean()); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void SettingsManager_PreservesVersionedSandboxFallbackCompatibility() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "settings.json"), """ + { + "SettingsSchemaVersion": 1, + "SystemRunSandboxEnabled": true, + "SystemRunBlockHostFallbackWhenMxcUnavailable": false + } + """); + + var settings = new SettingsManager(dir); + + Assert.True(settings.SystemRunSandboxEnabled); + Assert.False(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void SettingsManager_PreservesVersionedStrictFallbackBlockingOptIn() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "settings.json"), """ + { + "SettingsSchemaVersion": 1, + "SystemRunSandboxEnabled": true, + "SystemRunBlockHostFallbackWhenMxcUnavailable": true + } + """); + + var settings = new SettingsManager(dir); + + Assert.True(settings.SystemRunSandboxEnabled); + Assert.True(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + [Fact] public void BackwardCompatibility_OldSettingsWithoutNewFields() {