From b4292d81f2f749f26be96018ad1ac4233d63444e Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Thu, 18 Jun 2026 14:52:45 +0100 Subject: [PATCH 01/37] Support MXC SDK 0.7 on Windows 26200 --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 17 ++ src/OpenClaw.Shared/Mxc/MxcConfig.cs | 2 +- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 160 ++++++++++-------- src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 43 +++-- .../Mxc/Golden/sdk-config-balanced.json | 2 +- .../Mxc/Golden/sdk-config-custom.json | 2 +- .../Mxc/Golden/sdk-config-locked-down.json | 2 +- .../Mxc/Golden/sdk-config-permissive.json | 2 +- .../Mxc/MxcAvailabilityTests.cs | 25 +++ .../Mxc/MxcCommandRunnerTests.cs | 19 +++ .../Mxc/MxcConfigBuilderTests.cs | 75 ++++++-- .../Mxc/MxcPolicyBuilderTests.cs | 126 ++++++++++---- 12 files changed, 342 insertions(+), 133 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 04a6491c7..bbc3968e7 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -91,6 +91,23 @@ public async Task RunAsync(CommandRequest request, CancellationTo }; } + if (request.Env is { Count: > 0 }) + { + 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, + }; + } + var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request); 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..26b69b8c3 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -11,12 +11,14 @@ 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. +/// — grants existing +/// user-writable $PATH directories as readonly so command-line tools +/// can be read from inside the sandbox without asking the DACL fallback to +/// mutate 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. 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,7 +27,8 @@ 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 { @@ -52,6 +55,11 @@ public static MxcConfig Build( var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); + if (request.Env is { Count: > 0 }) + { + throw new NotSupportedException( + "Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."); + } // commandLine — shell-quoted. var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv); @@ -69,7 +77,6 @@ public static MxcConfig Build( 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(); @@ -90,10 +97,11 @@ public static MxcConfig Build( roFromPolicy = FilterOutDenied(roFromPolicy, denied); rwFromPolicy = FilterOutDenied(rwFromPolicy, denied); - // 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); + // env — intentionally omitted when empty. MXC 0.7 processcontainer + // currently fails process creation when a non-empty process.env array is + // supplied, so custom env requests are rejected above rather than + // silently ignored. + var env = BuildEnv(request.Env); // timeout — caller-supplied or default. var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : DefaultProcessTimeoutMs; @@ -182,6 +190,7 @@ public static List ResolvePathDirsForReadonly(string? pathEnvVar = null) foreach (var dir in pathDirs) { if (IsDriveRoot(dir)) continue; + if (IsProtectedSystemPath(dir)) continue; try { if (!Directory.Exists(dir)) continue; @@ -212,76 +221,54 @@ private static bool IsDriveRoot(string dir) } } - private static void AddCompatibilityReadonlyPaths(List readonlyPaths, IEnumerable grantedPaths) + private static bool IsProtectedSystemPath(string dir) { - foreach (var path in grantedPaths) - AddCompatibilityReadonlyPath(readonlyPaths, path); + if (!OperatingSystem.IsWindows()) + return false; - AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows)); - AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty); - } + var normalized = NormalizePath(dir); + if (string.IsNullOrWhiteSpace(normalized)) + return false; - private static void AddCompatibilityReadonlyPath(List readonlyPaths, string path) - { - string? root; - try { root = Path.GetPathRoot(Path.GetFullPath(path)); } - catch { return; } + foreach (var root in ProtectedSystemRoots()) + { + var protectedRoot = NormalizePath(root); + if (!string.IsNullOrWhiteSpace(protectedRoot) && + IsSameOrNested(normalized, protectedRoot)) + { + return true; + } + } - if (string.IsNullOrWhiteSpace(root)) - return; + return false; + } - if (!readonlyPaths.Contains(root, StringComparer.OrdinalIgnoreCase)) - readonlyPaths.Add(root); + 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.ProgramFiles); + yield return Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + yield return Environment.GetEnvironmentVariable("ProgramW6432") ?? string.Empty; + yield return Environment.GetEnvironmentVariable("ProgramData") ?? string.Empty; } /// /// 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%. + /// Current MXC 0.7 Windows processcontainer rejects non-empty process env + /// entries at CreateProcessW. Keep the serialized field absent for + /// normal requests and fail explicitly for env-bearing requests. /// - public static IReadOnlyList BuildEnv( - IReadOnlyDictionary? requestEnv, - string scratchDir, - IReadOnlyList? pathDirs = null) + public static IReadOnlyList? BuildEnv(IReadOnlyDictionary? requestEnv) { - // 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 (requestEnv is null || requestEnv.Count == 0) + return null; - if (requestEnv is not null) - { - foreach (var (name, value) in requestEnv) - { - 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; - } - } - - env["TEMP"] = scratchDir; - env["TMP"] = scratchDir; - env["TMPDIR"] = scratchDir; - if (pathDirs is { Count: > 0 }) - env["PATH"] = string.Join(Path.PathSeparator, pathDirs); - - return env.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); + 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) @@ -395,8 +382,8 @@ public static string Build(string shell, string command, IReadOnlyList a return normalized switch { "cmd" => BuildCmd(command, argv), - "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : "powershell.exe", command, argv), - _ => BuildPowershell("powershell.exe", command, argv), + "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : ResolveWindowsPowerShellExe(), command, argv), + _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv), }; } @@ -404,7 +391,8 @@ private static string BuildCmd(string command, IReadOnlyList 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 \""); + var sb = new StringBuilder(QuoteProcessPath(ResolveCmdExe())); + sb.Append(" /S /C \""); sb.Append(command); foreach (var a in argv) { @@ -427,7 +415,37 @@ 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 string ResolveCmdExe() + { + var comSpec = Environment.GetEnvironmentVariable("ComSpec"); + if (!string.IsNullOrWhiteSpace(comSpec)) + return comSpec; + + 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 QuoteProcessPath(string path) + { + if (path.Length > 0 && path.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return path; + + return "\"" + path.Replace("\"", "\\\"") + "\""; } private static string QuoteForCmd(string arg) diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 2503ec839..8d454f2d4 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -25,11 +25,11 @@ namespace OpenClaw.Shared.Mxc; 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. @@ -47,26 +47,26 @@ public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsD var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var sshPath = Path.Combine(userProfile, ".ssh"); - if (!string.IsNullOrWhiteSpace(sshPath)) - deniedPaths.Add(sshPath); + AddDeniedPathIfExists(deniedPaths, sshPath); // 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. + // With the MXC 0.7 AppContainer+DACL fallback, nonexistent deny paths are + // not a no-op: wxc-exec attempts to apply DACLs and fails before running + // the command. Only emit deny roots that exist for this invocation. 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")); + AddDeniedPathIfExists(deniedPaths, Path.Combine(localAppData, "Google", "Chrome", "User Data")); + AddDeniedPathIfExists(deniedPaths, Path.Combine(localAppData, "Microsoft", "Edge", "User Data")); + AddDeniedPathIfExists(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")); + AddDeniedPathIfExists(deniedPaths, Path.Combine(appData, "Mozilla", "Firefox", "Profiles")); + AddDeniedPathIfExists(deniedPaths, Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine")); } var readonlyPaths = new List(); @@ -116,6 +116,23 @@ public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsD TimeoutMs: settings.SandboxTimeoutMs > 0 ? settings.SandboxTimeoutMs : null); } + private static void AddDeniedPathIfExists(List deniedPaths, string path) + { + if (string.IsNullOrWhiteSpace(path)) + return; + + try + { + if (Directory.Exists(path)) + deniedPaths.Add(path); + } + catch + { + // If the host cannot even probe this path, avoid making the whole + // sandbox launch fail while preparing DACLs for an unverified path. + } + } + /// /// Remove any allow-list entry that overlaps a denied path. /// Case-insensitive (NTFS semantics) and tolerant of trailing slashes. 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..5751f0008 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs @@ -345,4 +345,29 @@ public void Probe_WhenProbeReportsDaclTier_ReportsDegraded() try { File.Delete(fakeExe); } catch { /* best-effort */ } } } + + [Theory] + [InlineData(26200, 9999, "Windows build 26200 is not an MXC isolation_session supported build")] + [InlineData(26300, 8552, "Windows UBR 8552 below MXC isolation_session minimum 8553")] + [InlineData(26301, 9999, "Windows build 26301 is not an MXC isolation_session supported build")] + public void GetIsolationSessionUnsupportedReason_RejectsUnsupportedBuilds( + int build, + int ubr, + string expectedReason) + { + var reason = MxcAvailability.GetIsolationSessionUnsupportedReason(build, ubr); + + Assert.NotNull(reason); + Assert.Contains(expectedReason, reason); + } + + [Theory] + [InlineData(26300, 8553)] + [InlineData(26300, 9999)] + public void GetIsolationSessionUnsupportedReason_AllowsSdkSupportedBuilds(int build, int ubr) + { + var reason = MxcAvailability.GetIsolationSessionUnsupportedReason(build, ubr); + + Assert.Null(reason); + } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 70a199b98..cf3db9d2d 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -84,6 +84,25 @@ public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost() Assert.Null(executor.LastRequest); } + [Fact] + public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() + { + 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_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() { diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 92895f93f..9cd194d95 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -10,7 +10,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. /// /// @@ -206,7 +206,7 @@ public void Build_AddsScratchDirToReadwritePaths() } [Fact] - public void Build_OverridesTempEnvVarsToScratch() + public void Build_RejectsExplicitEnvironmentUntilBackendSupportsIt() { var request = RequestFor(BalancedPolicy()) with { @@ -217,11 +217,10 @@ 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(() => + MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "")); + Assert.Contains("Explicit environment variables", ex.Message); } [Fact] @@ -283,13 +282,13 @@ public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() } [Fact] - public void Build_SynthesizesPathEnvFromGrantedPathDirs() + public void Build_GrantsPathDirsWithoutSynthesizingProcessEnv() { 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!); + Assert.Null(config.Process.Env); Assert.Contains(tempDir, config.Filesystem!.ReadonlyPaths!); } finally @@ -300,7 +299,7 @@ public void Build_SynthesizesPathEnvFromGrantedPathDirs() } [Fact] - public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal() + public void Build_DoesNotAddDriveRootCompatibilityGrant() { var policy = new SandboxPolicy( Version: MxcPolicyBuilder.SupportedPolicyVersion, @@ -314,7 +313,7 @@ public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal() TimeoutMs: 30_000); var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); - Assert.Contains("C:\\", config.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain("C:\\", config.Filesystem!.ReadonlyPaths!); } [Fact] @@ -351,6 +350,20 @@ public void ResolvePathDirsForReadonly_SkipsDriveRoots() Assert.Empty(dirs); } + [Fact] + public void ResolvePathDirsForReadonly_SkipsProtectedSystemDirs() + { + if (!OperatingSystem.IsWindows()) + return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrWhiteSpace(programFiles)) + return; + + var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: programFiles); + Assert.DoesNotContain(programFiles, dirs, StringComparer.OrdinalIgnoreCase); + } + [Fact] public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied() { @@ -385,6 +398,46 @@ public void Build_TimeoutHonorsRequestValue() Assert.Equal(12_345, config.Process.TimeoutMs); } + [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 = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + + var expected = Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine( + Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir") + ?? string.Empty, + "System32", + "cmd.exe"); + if (string.IsNullOrWhiteSpace(expected) || expected.StartsWith("System32", StringComparison.OrdinalIgnoreCase)) + expected = "cmd.exe"; + + Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(" /S /C \"echo hi\"", config.Process.CommandLine, StringComparison.Ordinal); + } + + [Fact] + public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"powershell"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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); + } + // ---- helpers for tolerant JSON comparison ---- private static void AssertJsonEqual(JsonObjectNode expected, JsonObjectNode actual, string path) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs index cfbad2e07..ae6546ea3 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs @@ -43,8 +43,11 @@ 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"); + if (Directory.Exists(expected)) + Assert.Contains(policy.Filesystem.DeniedPaths!, p => string.Equals(p, expected, StringComparison.OrdinalIgnoreCase)); + else + Assert.DoesNotContain(policy.Filesystem.DeniedPaths!, p => p.EndsWith(".ssh", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -179,20 +182,28 @@ 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). + // Existing browser/profile roots should be denied. Missing roots are not + // emitted because the MXC 0.7 AppContainer+DACL fallback fails if asked + // to mutate DACLs for nonexistent paths. 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)); + AssertExistingPathPolicy(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Google", "Chrome", "User Data")); + AssertExistingPathPolicy(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "Edge", "User Data")); + AssertExistingPathPolicy(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Mozilla", "Firefox", "Profiles")); + AssertExistingPathPolicy(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "BraveSoftware", "Brave-Browser", "User Data")); + AssertExistingPathPolicy(denied, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", "Windows", "PowerShell", "PSReadLine")); } [Fact] @@ -200,22 +211,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 +241,55 @@ 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] @@ -277,6 +309,34 @@ public void ForSystemRun_CustomFolder_NotOverlappingDeny_StillGranted() Assert.Contains("D:\\code\\my-project", policy.Filesystem!.ReadwritePaths!); } + private static void AssertExistingPathPolicy(IReadOnlyList denied, string path) + { + if (Directory.Exists(path)) + Assert.Contains(denied, p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + else + Assert.DoesNotContain(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); From 41fdf7b6f362e051cdf68eceae99bb201fd20ad8 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Thu, 18 Jun 2026 15:12:02 +0100 Subject: [PATCH 02/37] Emit explicit empty MXC process environment --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 12 +++++++----- .../Mxc/MxcConfigBuilderTests.cs | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 26b69b8c3..bb79521df 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -255,17 +255,19 @@ private static IEnumerable ProtectedSystemRoots() } /// - /// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit. + /// Build the env array (KEY=VALUE strings) for the wxc-exec sandbox. /// /// - /// Current MXC 0.7 Windows processcontainer rejects non-empty process env - /// entries at CreateProcessW. Keep the serialized field absent for - /// normal requests and fail explicitly for env-bearing requests. + /// 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, and fail explicitly for + /// env-bearing requests. /// public static IReadOnlyList? BuildEnv(IReadOnlyDictionary? requestEnv) { if (requestEnv is null || requestEnv.Count == 0) - return null; + return Array.Empty(); throw new NotSupportedException( "Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."); diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 9cd194d95..7550b83da 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -107,9 +107,10 @@ 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. + // no cwd, no PATH-resolved tool dirs. We pass an empty PATH and no + // caller env. The C# config still emits env: [] to make the sandbox env + // boundary explicit; the harness stripped process.* (commandLine, cwd, + // env, timeout), so do the same on the C# side before comparing. var request = RequestFor(policy); var config = MxcConfigBuilder.Build( request, @@ -282,13 +283,14 @@ public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() } [Fact] - public void Build_GrantsPathDirsWithoutSynthesizingProcessEnv() + public void Build_GrantsPathDirsAndEmitsEmptyProcessEnv() { 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.Null(config.Process.Env); + Assert.NotNull(config.Process.Env); + Assert.Empty(config.Process.Env); Assert.Contains(tempDir, config.Filesystem!.ReadonlyPaths!); } finally From 90a4d3c9266f0b072ffc144b0924e0eb5e69b483 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Thu, 18 Jun 2026 19:48:21 +0100 Subject: [PATCH 03/37] Harden MXC system run behavior --- .../Capabilities/SystemCapability.cs | 11 +- src/OpenClaw.Shared/ICommandRunner.cs | 12 + src/OpenClaw.Shared/LocalCommandRunner.cs | 57 ++++- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 66 +++-- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 233 ++++++++++++++---- src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 43 ++-- src/OpenClaw.Shared/Mxc/SandboxPolicy.cs | 9 +- .../OpenClaw.Tray.WinUI.csproj | 2 + .../Pages/SandboxPage.xaml.cs | 31 ++- .../Services/NodeService.cs | 10 +- .../Mxc/MxcCommandRunnerIntegrationTests.cs | 7 +- .../Mxc/MxcCommandRunnerTests.cs | 109 +++++--- .../Mxc/MxcConfigBuilderTests.cs | 176 +++++++++++-- .../Mxc/MxcPolicyBuilderTests.cs | 81 ++++-- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 165 +++++++++++++ .../InstallerIssAssertionTests.cs | 4 + 16 files changed, 832 insertions(+), 184 deletions(-) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 3b682d378..1e691d356 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -400,14 +400,15 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var fullCommand = args != null ? FormatExecCommand([command!, ..args]) : command; + var effectiveShell = _commandRunner.ResolveEffectiveShell(shell); - 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); + var approval = _approvalPolicy.Evaluate(fullCommand, effectiveShell); + var approvalCheck = await EnsureApprovedAsync(fullCommand, effectiveShell, approval, sessionKey, correlationId); if (!approvalCheck.Allowed) { Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); @@ -418,7 +419,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) approvalCheck.PromptDecisionKind != null || IsExactAllowRuleForCommand(approval, fullCommand); - var parseResult = ExecShellWrapperParser.Expand(fullCommand, shell); + var parseResult = ExecShellWrapperParser.Expand(fullCommand, effectiveShell); if (!string.IsNullOrWhiteSpace(parseResult.Error)) { Logger.Warn($"system.run DENIED: {fullCommand} ({parseResult.Error})"); @@ -453,7 +454,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) { Command = command, Args = args, - Shell = shell, + Shell = effectiveShell, Cwd = cwd, TimeoutMs = timeoutMs, Env = env diff --git a/src/OpenClaw.Shared/ICommandRunner.cs b/src/OpenClaw.Shared/ICommandRunner.cs index 1930bb5e7..60af22b81 100644 --- a/src/OpenClaw.Shared/ICommandRunner.cs +++ b/src/OpenClaw.Shared/ICommandRunner.cs @@ -57,6 +57,18 @@ 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) + { + return string.IsNullOrWhiteSpace(requestedShell) + ? "powershell" + : requestedShell.Trim(); + } /// Execute a command and return the result. Task RunAsync(CommandRequest request, CancellationToken ct = default); diff --git a/src/OpenClaw.Shared/LocalCommandRunner.cs b/src/OpenClaw.Shared/LocalCommandRunner.cs index 2643deeee..4ee40e6b7 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) { @@ -234,7 +236,8 @@ private static void ValidateDirectExecutable(string executable) private static (string fileName, string arguments) BuildProcessArgs(CommandRequest request) { - var shell = request.Shell ?? "powershell"; + var defaultShell = string.IsNullOrWhiteSpace(request.Shell); + var shell = ResolveEffectiveShellName(request.Shell); var command = request.Command; var isCmd = shell.Equals("cmd", StringComparison.OrdinalIgnoreCase); @@ -249,8 +252,54 @@ 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"); + 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) + { + if (!string.IsNullOrWhiteSpace(requestedShell)) + return requestedShell.Trim(); + + return ResolveOnPath("pwsh.exe") is not null ? "pwsh" : "powershell"; + } + + private static string? ResolveOnPath(string executableName) + { + var path = 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/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index bbc3968e7..fd1cabf5a 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -13,13 +13,14 @@ namespace OpenClaw.Shared.Mxc; /// /// Honors : /// -/// true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable. -/// false — bypass MXC; route through the host runner. +/// true (default) — sandbox via MXC; deny when MXC is unavailable. +/// false — explicit operator opt-out; route through the host runner. /// /// public sealed class MxcCommandRunner : ICommandRunner { public string Name => "mxc"; + private const string DefaultSandboxShell = "powershell"; private readonly ISandboxExecutor _executor; private readonly ICommandRunner _hostFallback; @@ -47,6 +48,16 @@ public MxcCommandRunner( _logger = logger ?? NullLogger.Instance; } + public string ResolveEffectiveShell(string? requestedShell) + { + if (!string.IsNullOrWhiteSpace(requestedShell)) + return requestedShell.Trim(); + + return _settingsProvider().SystemRunSandboxEnabled + ? DefaultSandboxShell + : _hostFallback.ResolveEffectiveShell(requestedShell); + } + public async Task RunAsync(CommandRequest request, CancellationToken ct = default) { var settings = _settingsProvider(); @@ -57,19 +68,24 @@ public async Task RunAsync(CommandRequest request, CancellationTo return await _hostFallback.RunAsync(request, 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. + // Fail closed by default: if the operator enabled sandboxing, we must + // not silently downgrade to uncontained host execution when MXC is + // unavailable or disappears at runtime. if (!_isSandboxAvailable()) { - _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); + const string message = + "Sandboxed system.run is enabled, but MXC is unavailable on this host. " + + "Update Windows or repair the MXC install, or explicitly disable sandboxing " + + "if uncontained host execution is acceptable."; + _logger.Warn("[mxc] system.run denied: sandbox enabled but MXC unavailable"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = message, + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; } // A direct-argv request reaching the sandbox cannot be honored: the sandbox @@ -146,15 +162,21 @@ 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 return a typed deny. _invalidateAvailability?.Invoke(); - _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); + _logger.Warn($"[mxc] system.run denied: sandbox became unavailable at runtime: {ex.Message}"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = + "Sandboxed system.run is enabled, but MXC became unavailable at runtime: " + + $"{ex.Message}. Repair MXC or explicitly disable sandboxing if uncontained " + + "host execution is acceptable.", + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; } catch (OperationCanceledException) { @@ -187,7 +209,7 @@ private static JsonElement SerializeArgs(CommandRequest request) var payload = new { command = request.Command, - shell = request.Shell ?? "powershell", + shell = request.Shell ?? DefaultSandboxShell, args = request.Args ?? Array.Empty(), cwd = request.Cwd, env = request.Env, @@ -211,7 +233,7 @@ private void LogSandboxRequest( "[mxc] system.run sandbox request " + $"executor={_executor.Name}; contained={_executor.IsContained}; " + $"sandboxSettingsJson={settingsJson}; " + - $"shell={commandRequest.Shell ?? "powershell"}; " + + $"shell={commandRequest.Shell ?? DefaultSandboxShell}; " + $"commandLength={commandRequest.Command?.Length ?? 0}; " + $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " + $"envKeys=[{string.Join(",", commandRequest.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " + diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index bb79521df..461dd5c08 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -11,12 +11,13 @@ namespace OpenClaw.Shared.Mxc; /// /// Translates (from the Sandbox page) and the /// agent's request into the JSON shape wxc-exec consumes. -/// — grants existing -/// user-writable $PATH directories as readonly so command-line tools -/// can be read from inside the sandbox without asking the DACL fallback to -/// mutate protected system directories. +/// — reconstructs 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. Explicit process.env injection is intentionally +/// 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 @@ -32,6 +33,12 @@ namespace OpenClaw.Shared.Mxc; /// public static class MxcConfigBuilder { + // MXC processcontainer default stays on Windows PowerShell 5.1 for now. + // PowerShell 7 (pwsh) 7.6 currently requires a root-drive readonly grant + // on this MXC 0.7 AppContainer+DACL tier; request.Cwd grants the working + // directory but does not satisfy that root-drive startup probe. + private const string DefaultShell = "powershell"; + /// /// Default per-process timeout when the caller doesn't supply one. /// @@ -61,17 +68,24 @@ public static MxcConfig Build( "Explicit environment variables are not supported by the Windows MXC 0.7 processcontainer backend."); } - // commandLine — shell-quoted. - var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv); - - // 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(pathEnvVar); foreach (var dir in pathDirs) + { + if (!IsBackendSafeReadonlyGrant(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(args.Shell, args.Command, args.Argv, scratchDir, pathDirs); + var shellRequiresWindowsUi = ShellCommandLine.RequiresWindowsUi(args.Shell); + var allowWindows = policy?.Ui?.AllowWindows == true || shellRequiresWindowsUi; // readwrite = UI grants + scratch dir. var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList(); @@ -79,7 +93,13 @@ public static MxcConfig Build( rwFromPolicy.Add(scratchDir); // 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(ShouldEmitDeniedPathToBackend) + .ToList(); // cwd auto-grant — AppContainer does not auto-grant the working // directory. Give ungranted cwd read access so shells can start, but @@ -89,18 +109,17 @@ 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 — intentionally omitted when empty. MXC 0.7 processcontainer - // currently fails process creation when a non-empty process.env array is - // supplied, so custom env requests are rejected above rather than - // silently ignored. + // 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. @@ -119,14 +138,18 @@ 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", + // PowerShell initializes desktop/USER handle state even for + // non-interactive commands. MXC's documented workaround is to relax + // handle/atom isolation to desktop while keeping clipboard, + // injection, system settings, IME, and desktop control locked down. + Isolation = shellRequiresWindowsUi ? "desktop" : "container", DesktopSystemControl = false, SystemSettings = "none", Ime = false, @@ -155,7 +178,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, @@ -171,11 +194,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 @@ -190,7 +213,6 @@ public static List ResolvePathDirsForReadonly(string? pathEnvVar = null) foreach (var dir in pathDirs) { if (IsDriveRoot(dir)) continue; - if (IsProtectedSystemPath(dir)) continue; try { if (!Directory.Exists(dir)) continue; @@ -221,6 +243,13 @@ private static bool IsDriveRoot(string dir) } } + private static bool IsBackendSafeReadonlyGrant(string dir) + { + if (IsDriveRoot(dir)) return false; + if (IsProtectedSystemPath(dir)) return false; + return true; + } + private static bool IsProtectedSystemPath(string dir) { if (!OperatingSystem.IsWindows()) @@ -248,21 +277,23 @@ 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.GetEnvironmentVariable("ProgramW6432") ?? string.Empty; - yield return Environment.GetEnvironmentVariable("ProgramData") ?? string.Empty; + yield return Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFiles); + yield return Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFilesX86); } /// - /// Build the env array (KEY=VALUE strings) for the wxc-exec sandbox. + /// 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, and fail explicitly for - /// env-bearing requests. + /// host-environment inheritance semantics. PATH and scratch temp variables + /// are set by the shell command line bootstrap instead. /// public static IReadOnlyList? BuildEnv(IReadOnlyDictionary? requestEnv) { @@ -292,6 +323,47 @@ private static List FilterOutDenied(List allowed, List d .ToList(); } + private static bool ShouldEmitDeniedPathToBackend(string path) + { + 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; + } + + return true; + } + + 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); @@ -350,12 +422,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,23 +450,44 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList< /// internal static class ShellCommandLine { - public static string Build(string shell, string command, IReadOnlyList argv) + public static bool RequiresWindowsUi(string? shell) { - var normalized = (shell ?? "powershell").Trim().ToLowerInvariant(); + var normalized = (shell ?? "cmd").Trim().ToLowerInvariant(); + return normalized != "cmd"; + } + + public static string Build( + string shell, + string command, + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) + { + var normalized = (shell ?? "cmd").Trim().ToLowerInvariant(); return normalized switch { - "cmd" => BuildCmd(command, argv), - "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : ResolveWindowsPowerShellExe(), command, argv), - _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv), + "cmd" => BuildCmd(command, argv, scratchDir, pathDirs), + "pwsh" or "powershell" => BuildPowershell( + normalized == "pwsh" ? "pwsh.exe" : ResolveWindowsPowerShellExe(), + command, + argv, + scratchDir, + pathDirs), + _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv, scratchDir, pathDirs), }; } - private static string BuildCmd(string command, IReadOnlyList argv) + private static string BuildCmd( + string command, + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) { // cmd /S /C " [args]" — /S strips outer quotes so cmd treats // everything after /C as the command line verbatim. var sb = new StringBuilder(QuoteProcessPath(ResolveCmdExe())); sb.Append(" /S /C \""); + AppendCmdEnvironmentBootstrap(sb, scratchDir, pathDirs); sb.Append(command); foreach (var a in argv) { @@ -405,11 +498,18 @@ private static string BuildCmd(string command, IReadOnlyList argv) return sb.ToString(); } - private static string BuildPowershell(string exe, string command, IReadOnlyList argv) + 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(' '); @@ -420,6 +520,50 @@ private static string BuildPowershell(string exe, string command, IReadOnlyList< 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 comSpec = Environment.GetEnvironmentVariable("ComSpec"); @@ -469,4 +613,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 8d454f2d4..072766f2f 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -10,16 +10,19 @@ 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 policy; the config builder may relax UI +/// for PowerShell-family shells that need desktop isolation to start under MXC. /// /// public static class MxcPolicyBuilder @@ -42,31 +45,31 @@ public static class MxcPolicyBuilder public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsDirectoryPath) { var deniedPaths = new List(); - if (!string.IsNullOrWhiteSpace(settingsDirectoryPath)) - deniedPaths.Add(settingsDirectoryPath); + AddDeniedPathIfExists(deniedPaths, settingsDirectoryPath); var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var sshPath = Path.Combine(userProfile, ".ssh"); - AddDeniedPathIfExists(deniedPaths, 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. - // With the MXC 0.7 AppContainer+DACL fallback, nonexistent deny paths are - // not a no-op: wxc-exec attempts to apply DACLs and fails before running - // the command. Only emit deny roots that exist for this invocation. + // 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)) { - AddDeniedPathIfExists(deniedPaths, Path.Combine(localAppData, "Google", "Chrome", "User Data")); - AddDeniedPathIfExists(deniedPaths, Path.Combine(localAppData, "Microsoft", "Edge", "User Data")); - AddDeniedPathIfExists(deniedPaths, 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)) { - AddDeniedPathIfExists(deniedPaths, Path.Combine(appData, "Mozilla", "Firefox", "Profiles")); - AddDeniedPathIfExists(deniedPaths, 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(); @@ -133,6 +136,12 @@ private static void AddDeniedPathIfExists(List deniedPaths, string path) } } + 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..ce6733b0a 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -41,15 +41,14 @@ 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. +/// fails closed instead of silently downgrading to host execution. When the toggle +/// is false, system.run runs on the host as an explicit operator opt-out. /// public enum SandboxMode { - /// Use MXC when available; otherwise fall back uncontained with a warning. + /// Use MXC when available; otherwise deny sandboxed command execution. Enabled, - /// Bypass MXC entirely. + /// Bypass MXC entirely and run on the host. Disabled, } diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index c8f29ad05..24277954f 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -88,6 +88,8 @@ x64 arm64 x64 + 0.7.0 + $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\package.json $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\bin\$(MxcArch)\ 0.7.0 $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\package.json diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs index be1615472..1ab2c3895 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -191,10 +191,12 @@ 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 — commands blocked" + toggle visible + /// 4. Unavailable + OFF → ⚠ "Sandbox is off — host execution" + toggle visible + /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner + /// fails closed rather than falling back to uncontained host execution. + /// The toggle stays visible so host execution remains an explicit operator + /// opt-out rather than a silent fallback. /// private void UpdateSandboxStatusCard() { @@ -217,9 +219,18 @@ 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) + { + SandboxStatusTitle.Text = "Node Sandbox unavailable — commands blocked"; + SandboxStatusSubtext.Text = "Containment isn't available on this PC, so sandboxed commands are blocked. Turn off Node Sandbox only if uncontained host execution is acceptable."; + } + 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; } @@ -301,7 +312,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\nSandboxed commands are blocked on this machine while the sandbox toggle remains on. 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 +322,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, so sandboxed commands are blocked while the sandbox toggle remains on. " + "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,7 +331,7 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava } else { - UnavailableActionBar.Title = "Sandbox unavailable — commands run uncontained"; + UnavailableActionBar.Title = "Sandbox unavailable — commands blocked"; UnavailableActionMessage.Text = reasonText; UnavailablePrimaryButton.Visibility = Visibility.Collapsed; } diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 622b76b19..465ac98b6 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -567,8 +567,8 @@ 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 denying sandboxed commands when MXC is unavailable. Host execution is + /// only used when the operator explicitly disables sandboxing. /// private ICommandRunner BuildSystemRunRunner() { @@ -597,11 +597,11 @@ 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 + // !_isSandboxAvailable() guard will deny calls while sandboxing + // remains enabled; 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})"); + _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable, sandboxed commands will be blocked: {reason})"); } var settingsDirectory = SettingsManager.SettingsDirectoryPath; diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index 4ffba897c..a94e46710 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}"); } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index cf3db9d2d..d7e6a89c5 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -34,11 +34,30 @@ private static MxcCommandRunner NewRunner( } [Fact] - public async Task RunAsync_SandboxEnabled_FallsBackToHostWhenExecutorIsUnavailable() + public void ResolveEffectiveShell_DefaultsToSandboxPowerShell_WhenSandboxEnabled() { - // 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 fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner(new FakeSandboxExecutor(), fallback, NewSettings(sandboxEnabled: true)); + + Assert.Equal("powershell", runner.ResolveEffectiveShell(null)); + Assert.Equal("cmd", runner.ResolveEffectiveShell(" cmd ")); + } + + [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 ")); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_DeniesWhenExecutorIsUnavailable() + { + // Sandboxed execution must fail closed. Host execution is only allowed + // when the operator explicitly disables the sandbox toggle. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; var fallback = new FakeCommandRunner { @@ -48,9 +67,9 @@ public async Task RunAsync_SandboxEnabled_FallsBackToHostWhenExecutorIsUnavailab var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host-ran", result.Stdout); - Assert.NotNull(fallback.LastRequest); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("became unavailable", result.Stderr); + Assert.Null(fallback.LastRequest); } [Fact] @@ -104,13 +123,10 @@ public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() } [Fact] - public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() + public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOff() { - // 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. + // Explicit sandbox opt-out means host execution is intentional, even on + // hosts where MXC is unavailable. var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner { @@ -131,10 +147,10 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() } [Fact] - public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() + public async Task RunAsync_MxcUnavailable_Denies_WithSandboxToggleOn() { - // With sandboxing enabled, unavailable MXC is detected before the - // executor path and routes to the host fallback. + // With sandbox enabled, unavailable MXC must not silently downgrade to + // uncontained host execution. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" }; var fallback = new FakeCommandRunner { @@ -148,9 +164,9 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); - Assert.NotNull(fallback.LastRequest); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("MXC is unavailable", result.Stderr); + Assert.Null(fallback.LastRequest); Assert.Null(executor.LastRequest); } @@ -192,6 +208,29 @@ public async Task RunAsync_Success_MapsSandboxResultIntoCommandResult() Assert.Equal(5000, executor.LastRequest.TimeoutMs); } + [Fact] + public async Task RunAsync_DefaultShell_UsesWindowsPowerShellForMxcProcessContainer() + { + 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("powershell", args.GetProperty("shell").GetString()); + } + [Fact] public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() { @@ -219,12 +258,12 @@ public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() } [Fact] - public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndFallsBack() + public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndDenies() { // 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). + // command re-probes), but still denies this call while sandboxing is + // enabled. Host fallback requires explicit operator opt-out. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "wxc-exec went missing" }; var fallback = new FakeCommandRunner { @@ -242,10 +281,10 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("became unavailable", result.Stderr); Assert.Equal(1, invalidationCount); - Assert.NotNull(fallback.LastRequest); + Assert.Null(fallback.LastRequest); } [Fact] @@ -359,6 +398,15 @@ 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) + { + return string.IsNullOrWhiteSpace(requestedShell) + ? EffectiveShellForNull + : requestedShell.Trim(); + } + public Task RunAsync(CommandRequest request, CancellationToken ct = default) { LastRequest = request; @@ -449,10 +497,9 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout() } [Fact] - public async Task RunAsync_UnavailableExecutor_FallsBackToHost() + public async Task RunAsync_UnavailableExecutor_DeniesWithoutHostFallback() { - // Issue #494: executor reports unavailable at runtime → fall back to - // host runner with a warning, not a -1 deny. + // Runtime MXC loss is a sandbox failure, not permission to run on host. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, @@ -466,8 +513,8 @@ public async Task RunAsync_UnavailableExecutor_FallsBackToHost() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); - Assert.NotNull(fallback.LastRequest); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("became unavailable", result.Stderr); + Assert.Null(fallback.LastRequest); } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 7550b83da..918ba36e0 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; @@ -107,11 +108,14 @@ public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod) }; // The golden test reproduces the exact harness recipe: no commandLine, - // no cwd, no PATH-resolved tool dirs. We pass an empty PATH and no + // 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; the harness stripped process.* (commandLine, cwd, - // env, timeout), so do the same on the C# side before comparing. - var request = RequestFor(policy); + // 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 does not inherit the PowerShell-specific UI exception. + using var argsDoc = JsonDocument.Parse("""{"shell":"cmd"}"""); + var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; var config = MxcConfigBuilder.Build( request, scratchDir: P.Scratch, @@ -264,15 +268,48 @@ public void Build_DoesNotAutoGrantCwd_WhenOverlapsDenied() } [Fact] - public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() + public void Build_FiltersHostProfileDeniedPathsBeforeBackendEmissionButStillFiltersAllows() { - // 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 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 = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, 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 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,15 +320,24 @@ public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() } [Fact] - public void Build_GrantsPathDirsAndEmitsEmptyProcessEnv() + 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); + using var argsDoc = JsonDocument.Parse("""{"command":"git --version","shell":"cmd"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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 { @@ -338,22 +384,22 @@ public void Build_DoesNotTreatReadwriteChildAsCoveringParentCwd() } [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 ResolvePathDirsForReadonly_SkipsProtectedSystemDirs() + public void ResolvePathDirsForShellPath_KeepsProtectedDirsInPathOnly() { if (!OperatingSystem.IsWindows()) return; @@ -362,8 +408,27 @@ public void ResolvePathDirsForReadonly_SkipsProtectedSystemDirs() if (string.IsNullOrWhiteSpace(programFiles)) return; - var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: programFiles); - Assert.DoesNotContain(programFiles, dirs, StringComparer.OrdinalIgnoreCase); + 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 = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: programFiles); + + Assert.Contains($"set \"PATH={programFiles}\"", config.Process.CommandLine); + Assert.DoesNotContain(programFiles, config.Filesystem!.ReadonlyPaths!, StringComparer.OrdinalIgnoreCase); } [Fact] @@ -400,6 +465,26 @@ public void Build_TimeoutHonorsRequestValue() Assert.Equal(12_345, config.Process.TimeoutMs); } + [Fact] + public void Build_DefaultShell_UsesWindowsPowerShellAndEnablesWindowsUi() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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.AppContainer!.Ui!.Isolation); + } + [Fact] public void Build_CmdShell_UsesResolvedCmdExe() { @@ -419,7 +504,24 @@ public void Build_CmdShell_UsesResolvedCmdExe() expected = "cmd.exe"; Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); - Assert.Contains(" /S /C \"echo hi\"", config.Process.CommandLine, StringComparison.Ordinal); + 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.AppContainer!.Ui!.Isolation); + } + + [Fact] + public void Build_PwshShell_UsesPwshAndEnablesWindowsUi() + { + using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); + var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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.AppContainer!.Ui!.Isolation); } [Fact] @@ -438,10 +540,46 @@ public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() 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.AppContainer!.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 request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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 { } + } } // ---- 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 ae6546ea3..129a3a45c 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_SkipsMissingSettingsDirectoryPath() + { + 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.DoesNotContain(policy.Filesystem.DeniedPaths!, p => + string.Equals(p, missingSettingsDir, StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -44,10 +68,7 @@ public void ForSystemRun_DeniesSshDirectoryByDefault() Assert.NotNull(policy.Filesystem); Assert.NotNull(policy.Filesystem!.DeniedPaths); var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); - if (Directory.Exists(expected)) - Assert.Contains(policy.Filesystem.DeniedPaths!, p => string.Equals(p, expected, StringComparison.OrdinalIgnoreCase)); - else - Assert.DoesNotContain(policy.Filesystem.DeniedPaths!, p => p.EndsWith(".ssh", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(policy.Filesystem.DeniedPaths!, p => string.Equals(p, expected, StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -182,30 +203,52 @@ public void ForSystemRun_TimeoutMsZero_TreatedAsUnset() [Fact] public void ForSystemRun_BrowserProfileDirectories_AreDenied() { - // Existing browser/profile roots should be denied. Missing roots are not - // emitted because the MXC 0.7 AppContainer+DACL fallback fails if asked - // to mutate DACLs for nonexistent paths. + // 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!; - AssertExistingPathPolicy(denied, Path.Combine( + AssertDeniedPath(denied, Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Google", "Chrome", "User Data")); - AssertExistingPathPolicy(denied, Path.Combine( + AssertDeniedPath(denied, Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "Edge", "User Data")); - AssertExistingPathPolicy(denied, Path.Combine( + AssertDeniedPath(denied, Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Mozilla", "Firefox", "Profiles")); - AssertExistingPathPolicy(denied, Path.Combine( + AssertDeniedPath(denied, Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BraveSoftware", "Brave-Browser", "User Data")); - AssertExistingPathPolicy(denied, Path.Combine( + 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] public void ForSystemRun_CustomFolder_PointingAtDeniedPath_FilteredOut() { @@ -309,12 +352,12 @@ public void ForSystemRun_CustomFolder_NotOverlappingDeny_StillGranted() Assert.Contains("D:\\code\\my-project", policy.Filesystem!.ReadwritePaths!); } - private static void AssertExistingPathPolicy(IReadOnlyList denied, string path) + private static void AssertDeniedPath(IReadOnlyList denied, string path) { - if (Directory.Exists(path)) - Assert.Contains(denied, p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); - else - Assert.DoesNotContain(denied, p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(path)) + return; + + Assert.Contains(denied, p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); } private static string CreateTempDeniedDir() diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index 67144f9e8..34869e9b3 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -508,6 +508,50 @@ 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_WithPromptPolicy_PromptsOnceForShellWrapper_WhenUserApprovesOnce() { @@ -707,6 +751,14 @@ private class FakeCommandRunner : ICommandRunner 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 ResolveEffectiveShell(string? requestedShell) + { + return string.IsNullOrWhiteSpace(requestedShell) + ? EffectiveShellForNull + : requestedShell.Trim(); + } public Task RunAsync(CommandRequest request, CancellationToken ct = default) { @@ -740,6 +792,119 @@ public Task RequestAsync( } } +[CollectionDefinition("EnvironmentMutation", DisableParallelization = true)] +public sealed class EnvironmentMutationTestCollection +{ + public const string Name = "EnvironmentMutation"; +} + +[Collection(EnvironmentMutationTestCollection.Name)] +public class LocalCommandRunnerTests +{ + [Fact] + public void BuildProcessArgs_DefaultShellUsesPwshWhenAvailableOnPath() + { + var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-pwsh-path-" + Guid.NewGuid().ToString("N"))).FullName; + var fakePwsh = Path.Combine(tempDir, "pwsh.exe"); + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); + try + { + File.WriteAllBytes(fakePwsh, Array.Empty()); + Environment.SetEnvironmentVariable("PATH", tempDir); + Environment.SetEnvironmentVariable("Path", tempDir); + + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + }); + + Assert.Equal(fakePwsh, fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + Environment.SetEnvironmentVariable("Path", originalPathMixedCase); + // 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 originalPath = Environment.GetEnvironmentVariable("PATH"); + var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); + try + { + Environment.SetEnvironmentVariable("PATH", string.Empty); + Environment.SetEnvironmentVariable("Path", string.Empty); + + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + }); + + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + Environment.SetEnvironmentVariable("Path", originalPathMixedCase); + } + } + + [Fact] + public void BuildProcessArgs_ExplicitPwshDoesNotFallback() + { + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); + try + { + Environment.SetEnvironmentVariable("PATH", string.Empty); + Environment.SetEnvironmentVariable("Path", string.Empty); + + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest + { + Command = "Write-Output hi", + Shell = "pwsh", + }); + + Assert.Equal("pwsh.exe", fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + Environment.SetEnvironmentVariable("Path", originalPathMixedCase); + } + } + + [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. diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index fd8f299f6..df9d44d9c 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -145,8 +145,12 @@ public void MxcSdk_IsRestoredCopiedValidatedAndIncludedInInstallerPayload() 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("0.7.0", trayProject); + Assert.Contains("MxcSdkInstalledVersion", trayProject); Assert.Contains("RestoreMxcNodeBridge", trayProject); Assert.Contains("npm ci --no-audit --no-fund", trayProject); + Assert.Contains("'$(MxcSdkInstalledVersion)' != '$(MxcSdkExpectedVersion)'", trayProject); Assert.Contains("CopyWxcExecToOutput", trayProject); Assert.Contains("CopyWxcExecToPublish", trayProject); Assert.Contains("ValidateWxcExecShipped", trayProject); From 0e745d0e4e8db4bd294b8b57b20cc746ccaf224e Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Thu, 18 Jun 2026 22:07:26 +0100 Subject: [PATCH 04/37] fix: keep MXC sandbox UI contained by default --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 2 +- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 25 +++------- .../Mxc/MxcCommandRunnerTests.cs | 8 +-- .../Mxc/MxcConfigBuilderTests.cs | 49 +++++++++++++------ 4 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index fd1cabf5a..fc23dde01 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -20,7 +20,7 @@ namespace OpenClaw.Shared.Mxc; public sealed class MxcCommandRunner : ICommandRunner { public string Name => "mxc"; - private const string DefaultSandboxShell = "powershell"; + private const string DefaultSandboxShell = "cmd"; private readonly ISandboxExecutor _executor; private readonly ICommandRunner _hostFallback; diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 461dd5c08..3fc32b0dc 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -33,11 +33,11 @@ namespace OpenClaw.Shared.Mxc; /// public static class MxcConfigBuilder { - // MXC processcontainer default stays on Windows PowerShell 5.1 for now. - // PowerShell 7 (pwsh) 7.6 currently requires a root-drive readonly grant - // on this MXC 0.7 AppContainer+DACL tier; request.Cwd grants the working - // directory but does not satisfy that root-drive startup probe. - private const string DefaultShell = "powershell"; + // 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 it must not silently + // relax UI containment. + private const string DefaultShell = "cmd"; /// /// Default per-process timeout when the caller doesn't supply one. @@ -84,8 +84,7 @@ public static MxcConfig Build( // 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(args.Shell, args.Command, args.Argv, scratchDir, pathDirs); - var shellRequiresWindowsUi = ShellCommandLine.RequiresWindowsUi(args.Shell); - var allowWindows = policy?.Ui?.AllowWindows == true || shellRequiresWindowsUi; + var allowWindows = policy?.Ui?.AllowWindows == true; // readwrite = UI grants + scratch dir. var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList(); @@ -145,11 +144,7 @@ public static MxcConfig Build( var processContainerUi = new MxcBaseProcessUi { - // PowerShell initializes desktop/USER handle state even for - // non-interactive commands. MXC's documented workaround is to relax - // handle/atom isolation to desktop while keeping clipboard, - // injection, system settings, IME, and desktop control locked down. - Isolation = shellRequiresWindowsUi ? "desktop" : "container", + Isolation = allowWindows ? "desktop" : "container", DesktopSystemControl = false, SystemSettings = "none", Ime = false, @@ -450,12 +445,6 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList< /// internal static class ShellCommandLine { - public static bool RequiresWindowsUi(string? shell) - { - var normalized = (shell ?? "cmd").Trim().ToLowerInvariant(); - return normalized != "cmd"; - } - public static string Build( string shell, string command, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index d7e6a89c5..e97c7a938 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -34,12 +34,12 @@ private static MxcCommandRunner NewRunner( } [Fact] - public void ResolveEffectiveShell_DefaultsToSandboxPowerShell_WhenSandboxEnabled() + public void ResolveEffectiveShell_DefaultsToSandboxCmd_WhenSandboxEnabled() { var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; var runner = NewRunner(new FakeSandboxExecutor(), fallback, NewSettings(sandboxEnabled: true)); - Assert.Equal("powershell", runner.ResolveEffectiveShell(null)); + Assert.Equal("cmd", runner.ResolveEffectiveShell(null)); Assert.Equal("cmd", runner.ResolveEffectiveShell(" cmd ")); } @@ -209,7 +209,7 @@ public async Task RunAsync_Success_MapsSandboxResultIntoCommandResult() } [Fact] - public async Task RunAsync_DefaultShell_UsesWindowsPowerShellForMxcProcessContainer() + public async Task RunAsync_DefaultShell_UsesCmdForMxcProcessContainer() { var executor = new FakeSandboxExecutor { @@ -228,7 +228,7 @@ public async Task RunAsync_DefaultShell_UsesWindowsPowerShellForMxcProcessContai Assert.NotNull(executor.LastRequest); var args = executor.LastRequest!.Args; - Assert.Equal("powershell", args.GetProperty("shell").GetString()); + Assert.Equal("cmd", args.GetProperty("shell").GetString()); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 918ba36e0..72bb1c824 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -113,7 +113,7 @@ public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod) // 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 does not inherit the PowerShell-specific UI exception. + // 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 = MxcConfigBuilder.Build( @@ -466,21 +466,42 @@ public void Build_TimeoutHonorsRequestValue() } [Fact] - public void Build_DefaultShell_UsesWindowsPowerShellAndEnablesWindowsUi() + public void Build_DefaultShell_UsesCmdAndPreservesUiDeny() { - using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi"}"""); + using var argsDoc = JsonDocument.Parse("""{"command":"echo hi"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; var config = MxcConfigBuilder.Build(request, P.Scratch, 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"); + var expected = Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine( + Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetEnvironmentVariable("windir") + ?? string.Empty, + "System32", + "cmd.exe"); + if (string.IsNullOrWhiteSpace(expected) || expected.StartsWith("System32", StringComparison.OrdinalIgnoreCase)) + expected = "cmd.exe"; Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); - Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); + 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.AppContainer!.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 = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + Assert.False(config.Ui!.Disable); Assert.Equal("desktop", config.AppContainer!.Ui!.Isolation); } @@ -511,7 +532,7 @@ public void Build_CmdShell_UsesResolvedCmdExe() } [Fact] - public void Build_PwshShell_UsesPwshAndEnablesWindowsUi() + public void Build_PwshShell_UsesPwshAndPreservesUiDeny() { using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; @@ -520,8 +541,8 @@ public void Build_PwshShell_UsesPwshAndEnablesWindowsUi() 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.AppContainer!.Ui!.Isolation); + Assert.True(config.Ui!.Disable); + Assert.Equal("container", config.AppContainer!.Ui!.Isolation); } [Fact] @@ -540,8 +561,8 @@ public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() 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.AppContainer!.Ui!.Isolation); + Assert.True(config.Ui!.Disable); + Assert.Equal("container", config.AppContainer!.Ui!.Isolation); } [Fact] From 8966b6f48d1ed9360fdeff4d5d0ca088272c8451 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Fri, 19 Jun 2026 10:05:02 +0100 Subject: [PATCH 05/37] fix: preserve MXC fallback compatibility --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 112 ++++++++----- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 23 ++- src/OpenClaw.Shared/Mxc/SandboxPolicy.cs | 7 +- src/OpenClaw.Shared/SettingsData.cs | 15 +- .../Pages/SandboxPage.xaml.cs | 36 +++-- .../Services/NodeService.cs | 20 ++- .../Services/SettingsManager.cs | 5 +- .../Mxc/MxcCommandRunnerTests.cs | 148 +++++++++++++++--- .../Mxc/MxcConfigBuilderTests.cs | 28 ++++ .../SettingsRoundTripTests.cs | 9 ++ 10 files changed, 313 insertions(+), 90 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index fc23dde01..40902a57e 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -13,8 +13,9 @@ namespace OpenClaw.Shared.Mxc; /// /// Honors : /// -/// true (default) — sandbox via MXC; deny when MXC is unavailable. -/// false — explicit operator opt-out; route through the host runner. +/// true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable. +/// true with — deny when MXC is unavailable. +/// false — bypass MXC; route through the host runner. /// /// public sealed class MxcCommandRunner : ICommandRunner @@ -53,39 +54,25 @@ public string ResolveEffectiveShell(string? requestedShell) if (!string.IsNullOrWhiteSpace(requestedShell)) return requestedShell.Trim(); - return _settingsProvider().SystemRunSandboxEnabled - ? DefaultSandboxShell - : _hostFallback.ResolveEffectiveShell(requestedShell); + var settings = _settingsProvider(); + if (!settings.SystemRunSandboxEnabled) + return _hostFallback.ResolveEffectiveShell(requestedShell); + + if (_isSandboxAvailable() || settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return DefaultSandboxShell; + + return _hostFallback.ResolveEffectiveShell(requestedShell); } public async Task RunAsync(CommandRequest request, CancellationToken ct = default) { var settings = _settingsProvider(); + var effectiveShell = ResolveEffectiveShell(request.Shell); if (!settings.SystemRunSandboxEnabled) { _logger.Info("[mxc] sandbox=disabled; routing system.run through host runner"); - return await _hostFallback.RunAsync(request, ct); - } - - // Fail closed by default: if the operator enabled sandboxing, we must - // not silently downgrade to uncontained host execution when MXC is - // unavailable or disappears at runtime. - if (!_isSandboxAvailable()) - { - const string message = - "Sandboxed system.run is enabled, but MXC is unavailable on this host. " + - "Update Windows or repair the MXC install, or explicitly disable sandboxing " + - "if uncontained host execution is acceptable."; - _logger.Warn("[mxc] system.run denied: sandbox enabled but MXC unavailable"); - return new CommandResult - { - Stdout = string.Empty, - Stderr = message, - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - }; + return await RunHostFallbackAsync(request, effectiveShell, ct); } // A direct-argv request reaching the sandbox cannot be honored: the sandbox @@ -124,6 +111,23 @@ public async Task RunAsync(CommandRequest request, CancellationTo }; } + 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 on unsupported + // hosts. Operators that require fail-closed containment enable + // SystemRunBlockHostFallbackWhenMxcUnavailable. + _logger.Warn( + "[mxc] system.run UNCONTAINED: sandbox unavailable on this host; " + + "routing through host runner for compatibility."); + return await RunHostFallbackAsync(request, effectiveShell, ct); + } + var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request); @@ -162,21 +166,20 @@ 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 return a typed deny. + // top-level !_isSandboxAvailable() branch will use the compatibility + // fallback until MXC is available again. _invalidateAvailability?.Invoke(); - _logger.Warn($"[mxc] system.run denied: sandbox became unavailable at runtime: {ex.Message}"); - return new CommandResult - { - Stdout = string.Empty, - Stderr = - "Sandboxed system.run is enabled, but MXC became unavailable at runtime: " + - $"{ex.Message}. Repair MXC or explicitly disable sandboxing if uncontained " + - "host execution is acceptable.", - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - }; + 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 became unavailable at runtime ({ex.Message}); " + + "routing through host runner for compatibility."); + return await RunHostFallbackAsync(request, effectiveShell, ct); } catch (OperationCanceledException) { @@ -204,6 +207,36 @@ public async Task RunAsync(CommandRequest request, CancellationTo } } + 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) + { + if (!string.IsNullOrWhiteSpace(request.Shell)) + return _hostFallback.RunAsync(request, ct); + + var fallbackRequest = new CommandRequest + { + Command = request.Command, + Args = request.Args, + Shell = effectiveShell, + Cwd = request.Cwd, + TimeoutMs = request.TimeoutMs, + Env = request.Env, + }; + return _hostFallback.RunAsync(fallbackRequest, ct); + } + private static JsonElement SerializeArgs(CommandRequest request) { var payload = new @@ -247,6 +280,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/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 3fc32b0dc..84632539f 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -457,7 +457,7 @@ public static string Build( { "cmd" => BuildCmd(command, argv, scratchDir, pathDirs), "pwsh" or "powershell" => BuildPowershell( - normalized == "pwsh" ? "pwsh.exe" : ResolveWindowsPowerShellExe(), + normalized == "pwsh" ? ResolvePwshExe(pathDirs) : ResolveWindowsPowerShellExe(), command, argv, scratchDir, @@ -575,6 +575,27 @@ private static string ResolveWindowsPowerShellExe() : 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) diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs index ce6733b0a..47fde15ef 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -41,12 +41,13 @@ public enum ClipboardPolicy /// /// When is true, system.run /// is contained via MXC AppContainer. When MXC is unavailable on the host, system.run -/// fails closed instead of silently downgrading to host execution. When the toggle -/// is false, system.run runs on the host as an explicit operator opt-out. +/// falls back to host execution for compatibility unless +/// is enabled. +/// When the toggle is false, system.run runs on the host without attempting MXC. /// public enum SandboxMode { - /// Use MXC when available; otherwise deny sandboxed command execution. + /// Use MXC when available; otherwise run through the host fallback. Enabled, /// Bypass MXC entirely and run on the host. diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index 5c857c00f..d2ebaf2cf 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -126,13 +126,20 @@ 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 falls back to host + /// execution when MXC is unavailable. 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 existing fallback requested for compatibility. + /// + 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 1ab2c3895..6fc0692f6 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -191,19 +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 + ON → ⚠ "Sandbox unavailable — commands blocked" + toggle visible + /// 3. Unavailable + ON → ⚠ "Sandbox unavailable — host fallback" + toggle visible /// 4. Unavailable + OFF → ⚠ "Sandbox is off — host execution" + toggle visible /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner - /// fails closed rather than falling back to uncontained host execution. - /// The toggle stays visible so host execution remains an explicit operator - /// opt-out rather than a silent fallback. + /// preserves the compatibility host fallback by default, or blocks commands + /// when strict fallback blocking is 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) { @@ -221,10 +221,15 @@ private void UpdateSandboxStatusCard() SandboxStatusIcon.Text = "⚠"; SandboxEnabledToggle.Visibility = Visibility.Visible; - if (enabled) + if (enabled && blockHostFallback) { SandboxStatusTitle.Text = "Node Sandbox unavailable — commands blocked"; - SandboxStatusSubtext.Text = "Containment isn't available on this PC, so sandboxed commands are blocked. Turn off Node Sandbox only if uncontained host execution is acceptable."; + 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 { @@ -271,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) @@ -297,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) { @@ -312,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\nSandboxed commands are blocked on this machine while the sandbox toggle remains on. 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"; @@ -322,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 sandboxed commands are blocked while the sandbox toggle remains on. " + + $"{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"; @@ -331,8 +341,10 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava } else { - UnavailableActionBar.Title = "Sandbox unavailable — commands blocked"; - 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 465ac98b6..f0de1d470 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 - /// by denying sandboxed commands when MXC is unavailable. Host execution is - /// only used when the operator explicitly disables sandboxing. + /// by attempting MXC containment when available, falling back to host + /// execution 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 deny calls while sandboxing - // remains enabled; the executor is constructed only to satisfy - // the constructor contract and is never invoked. + // !_isSandboxAvailable() guard will either use the compatibility + // host fallback or block, 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, sandboxed commands will be blocked: {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..57b926e3a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -143,8 +143,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. 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 }; } @@ -266,6 +268,7 @@ public void Load() SkippedUpdateTag = "", PreferredGatewayId = null, SystemRunSandboxEnabled = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, SystemRunAllowOutbound = false, SandboxClipboard = SandboxClipboardMode.None, SandboxDocumentsAccess = null, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index e97c7a938..2360a0670 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, }; } @@ -54,10 +57,38 @@ public void ResolveEffectiveShell_DelegatesToHost_WhenSandboxDisabled() } [Fact] - public async Task RunAsync_SandboxEnabled_DeniesWhenExecutorIsUnavailable() + public void ResolveEffectiveShell_DelegatesToHost_WhenMxcUnavailable() + { + var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; + var runner = NewRunner( + new FakeSandboxExecutor(), + fallback, + NewSettings(sandboxEnabled: true), + sandboxAvailable: false); + + Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); + Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + } + + [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 ")); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailable() { - // Sandboxed execution must fail closed. Host execution is only allowed - // when the operator explicitly disables the sandbox toggle. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; var fallback = new FakeCommandRunner { @@ -67,9 +98,10 @@ public async Task RunAsync_SandboxEnabled_DeniesWhenExecutorIsUnavailable() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("became unavailable", result.Stderr); - Assert.Null(fallback.LastRequest); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host-ran", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("cmd", fallback.LastRequest!.Shell); } [Fact] @@ -122,6 +154,29 @@ public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() Assert.Null(fallback.LastRequest); } + [Fact] + public async Task RunAsync_SandboxEnabled_MxcUnavailable_RejectsCustomEnvBeforeHostFallback() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner(); + var runner = NewRunner( + executor, + fallback, + NewSettings(sandboxEnabled: true), + 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() { @@ -147,10 +202,8 @@ public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOff() } [Fact] - public async Task RunAsync_MxcUnavailable_Denies_WithSandboxToggleOn() + public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOn() { - // With sandbox enabled, unavailable MXC must not silently downgrade to - // uncontained host execution. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" }; var fallback = new FakeCommandRunner { @@ -164,10 +217,32 @@ public async Task RunAsync_MxcUnavailable_Denies_WithSandboxToggleOn() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + 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("MXC is unavailable", result.Stderr); - Assert.Null(fallback.LastRequest); + Assert.Contains("host fallback is blocked", result.Stderr); Assert.Null(executor.LastRequest); + Assert.Null(fallback.LastRequest); } [Fact] @@ -234,7 +309,7 @@ public async Task RunAsync_DefaultShell_UsesCmdForMxcProcessContainer() [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 { @@ -258,12 +333,11 @@ public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() } [Fact] - public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndDenies() + 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), but still denies this call while sandboxing is - // enabled. Host fallback requires explicit operator opt-out. + // 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 { @@ -281,8 +355,34 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.Equal(1, invalidationCount); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("cmd", fallback.LastRequest!.Shell); + } + + [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("became unavailable", result.Stderr); + Assert.Contains("host fallback is blocked", result.Stderr); Assert.Equal(1, invalidationCount); Assert.Null(fallback.LastRequest); } @@ -305,7 +405,7 @@ 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); } @@ -497,9 +597,8 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout() } [Fact] - public async Task RunAsync_UnavailableExecutor_DeniesWithoutHostFallback() + public async Task RunAsync_UnavailableExecutor_FallsBackToHost() { - // Runtime MXC loss is a sandbox failure, not permission to run on host. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, @@ -513,8 +612,9 @@ public async Task RunAsync_UnavailableExecutor_DeniesWithoutHostFallback() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("became unavailable", result.Stderr); - Assert.Null(fallback.LastRequest); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("cmd", fallback.LastRequest!.Shell); } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 72bb1c824..2886212a4 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -545,6 +545,34 @@ public void Build_PwshShell_UsesPwshAndPreservesUiDeny() Assert.Equal("container", config.AppContainer!.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 request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + + var config = MxcConfigBuilder.Build(request, P.Scratch, 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() { diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 7eb049397..8bfc38d75 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 } @@ -111,6 +114,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 +182,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); From 4e3d663da8b2a5d9314ea9a55b75c52f75232910 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:58:30 +0100 Subject: [PATCH 06/37] test: avoid process-wide PATH mutation --- src/OpenClaw.Shared/LocalCommandRunner.cs | 16 +++-- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 63 ++++--------------- 2 files changed, 22 insertions(+), 57 deletions(-) diff --git a/src/OpenClaw.Shared/LocalCommandRunner.cs b/src/OpenClaw.Shared/LocalCommandRunner.cs index 4ee40e6b7..64d7d489f 100644 --- a/src/OpenClaw.Shared/LocalCommandRunner.cs +++ b/src/OpenClaw.Shared/LocalCommandRunner.cs @@ -234,10 +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 defaultShell = string.IsNullOrWhiteSpace(request.Shell); - var shell = ResolveEffectiveShellName(request.Shell); + var shell = ResolveEffectiveShellName(request.Shell, pathEnvVar); var command = request.Command; var isCmd = shell.Equals("cmd", StringComparison.OrdinalIgnoreCase); @@ -253,7 +253,7 @@ private static (string fileName, string arguments) BuildProcessArgs(CommandReque return ("cmd.exe", $"/C {command}"); if (shell.Equals("pwsh", StringComparison.OrdinalIgnoreCase)) { - var pwshPath = ResolveOnPath("pwsh.exe"); + var pwshPath = ResolveOnPath("pwsh.exe", pathEnvVar); if (pwshPath is not null || !defaultShell) return (pwshPath ?? "pwsh.exe", $"-NoProfile -NonInteractive -Command {command}"); } @@ -262,16 +262,20 @@ private static (string fileName, string arguments) BuildProcessArgs(CommandReque } 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(); - return ResolveOnPath("pwsh.exe") is not null ? "pwsh" : "powershell"; + return ResolveOnPath("pwsh.exe", pathEnvVar) is not null ? "pwsh" : "powershell"; } - private static string? ResolveOnPath(string executableName) + private static string? ResolveOnPath(string executableName, string? pathEnvVar = null) { - var path = Environment.GetEnvironmentVariable("PATH") + var path = pathEnvVar + ?? Environment.GetEnvironmentVariable("PATH") ?? Environment.GetEnvironmentVariable("Path"); if (string.IsNullOrWhiteSpace(path)) return null; diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index 34869e9b3..a84311730 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -792,13 +792,6 @@ public Task RequestAsync( } } -[CollectionDefinition("EnvironmentMutation", DisableParallelization = true)] -public sealed class EnvironmentMutationTestCollection -{ - public const string Name = "EnvironmentMutation"; -} - -[Collection(EnvironmentMutationTestCollection.Name)] public class LocalCommandRunnerTests { [Fact] @@ -806,26 +799,20 @@ public void BuildProcessArgs_DefaultShellUsesPwshWhenAvailableOnPath() { var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-pwsh-path-" + Guid.NewGuid().ToString("N"))).FullName; var fakePwsh = Path.Combine(tempDir, "pwsh.exe"); - var originalPath = Environment.GetEnvironmentVariable("PATH"); - var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); try { File.WriteAllBytes(fakePwsh, Array.Empty()); - Environment.SetEnvironmentVariable("PATH", tempDir); - Environment.SetEnvironmentVariable("Path", tempDir); var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest { Command = "Write-Output hi", - }); + }, pathEnvVar: tempDir); Assert.Equal(fakePwsh, fileName); Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); } finally { - Environment.SetEnvironmentVariable("PATH", originalPath); - Environment.SetEnvironmentVariable("Path", originalPathMixedCase); // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide assertion failures. try { Directory.Delete(tempDir, recursive: true); } catch { } } @@ -834,52 +821,26 @@ public void BuildProcessArgs_DefaultShellUsesPwshWhenAvailableOnPath() [Fact] public void BuildProcessArgs_DefaultShellFallsBackToWindowsPowerShellWhenPwshMissing() { - var originalPath = Environment.GetEnvironmentVariable("PATH"); - var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); - try + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest { - Environment.SetEnvironmentVariable("PATH", string.Empty); - Environment.SetEnvironmentVariable("Path", string.Empty); - - var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest - { - Command = "Write-Output hi", - }); + Command = "Write-Output hi", + }, pathEnvVar: string.Empty); - Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); - Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); - } - finally - { - Environment.SetEnvironmentVariable("PATH", originalPath); - Environment.SetEnvironmentVariable("Path", originalPathMixedCase); - } + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); } [Fact] public void BuildProcessArgs_ExplicitPwshDoesNotFallback() { - var originalPath = Environment.GetEnvironmentVariable("PATH"); - var originalPathMixedCase = Environment.GetEnvironmentVariable("Path"); - try + var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest { - Environment.SetEnvironmentVariable("PATH", string.Empty); - Environment.SetEnvironmentVariable("Path", string.Empty); - - var (fileName, arguments) = LocalCommandRunner.BuildProcessArgs(new CommandRequest - { - Command = "Write-Output hi", - Shell = "pwsh", - }); + Command = "Write-Output hi", + Shell = "pwsh", + }, pathEnvVar: string.Empty); - Assert.Equal("pwsh.exe", fileName); - Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); - } - finally - { - Environment.SetEnvironmentVariable("PATH", originalPath); - Environment.SetEnvironmentVariable("Path", originalPathMixedCase); - } + Assert.Equal("pwsh.exe", fileName); + Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); } [Fact] From c76409e072026b4de990d020035f55f825ea6cf1 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:58:47 +0100 Subject: [PATCH 07/37] fix: harden MXC policy path grants --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 20 +++- src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 19 +--- .../Mxc/MxcConfigBuilderTests.cs | 94 +++++++++++++------ .../Mxc/MxcPolicyBuilderTests.cs | 32 ++++++- 4 files changed, 115 insertions(+), 50 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 84632539f..08556b7d5 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -55,10 +55,12 @@ public static MxcConfig Build( SandboxExecutionRequest request, string scratchDir, string? containerId = null, - string? pathEnvVar = null) + string? pathEnvVar = null, + Func? deniedPathExists = null) { if (request is null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir)); + deniedPathExists ??= PathExists; var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); @@ -97,7 +99,7 @@ public static MxcConfig Build( // cannot be prepared and make the sandbox fail before command launch. var deniedForFiltering = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList(); var deniedForBackend = deniedForFiltering - .Where(ShouldEmitDeniedPathToBackend) + .Where(path => ShouldEmitDeniedPathToBackend(path, deniedPathExists)) .ToList(); // cwd auto-grant — AppContainer does not auto-grant the working @@ -318,7 +320,7 @@ private static List FilterOutDenied(List allowed, List d .ToList(); } - private static bool ShouldEmitDeniedPathToBackend(string path) + private static bool ShouldEmitDeniedPathToBackend(string path, Func pathExists) { var normalized = NormalizePath(path); if (string.IsNullOrWhiteSpace(normalized)) @@ -331,9 +333,21 @@ private static bool ShouldEmitDeniedPathToBackend(string path) 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); diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 072766f2f..558ff676a 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -45,7 +45,7 @@ public static class MxcPolicyBuilder public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsDirectoryPath) { var deniedPaths = new List(); - AddDeniedPathIfExists(deniedPaths, settingsDirectoryPath); + AddDeniedPath(deniedPaths, settingsDirectoryPath); var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrWhiteSpace(userProfile)) @@ -119,23 +119,6 @@ public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsD TimeoutMs: settings.SandboxTimeoutMs > 0 ? settings.SandboxTimeoutMs : null); } - private static void AddDeniedPathIfExists(List deniedPaths, string path) - { - if (string.IsNullOrWhiteSpace(path)) - return; - - try - { - if (Directory.Exists(path)) - deniedPaths.Add(path); - } - catch - { - // If the host cannot even probe this path, avoid making the whole - // sandbox launch fail while preparing DACLs for an unverified path. - } - } - private static void AddDeniedPath(List deniedPaths, string path) { if (!string.IsNullOrWhiteSpace(path)) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 2886212a4..6e1ab5d66 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -42,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( @@ -92,6 +94,19 @@ 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) => + MxcConfigBuilder.Build( + request, + scratchDir, + containerId, + pathEnvVar, + deniedPathExists ?? DeniedPathExists); + [Theory] [InlineData("locked-down", "LockedDown")] [InlineData("balanced", "Balanced")] @@ -116,7 +131,7 @@ public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod) // 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 = MxcConfigBuilder.Build( + var config = BuildConfig( request, scratchDir: P.Scratch, containerId: GoldenContainerId, @@ -177,8 +192,8 @@ private static string ResolveGoldenPath(string preset) public void Build_OutboundOn_AddsInternetClientCapability() { var policy = BalancedPolicy(); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); - Assert.Contains("internetClient", config.ProcessContainer!.Capabilities!); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); + Assert.Contains("internetClient", config.AppContainer!.Capabilities!); Assert.Equal("allow", config.Network!.DefaultPolicy); } @@ -186,8 +201,8 @@ public void Build_OutboundOn_AddsInternetClientCapability() public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks() { var policy = LockedDownPolicy(); - var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); - Assert.DoesNotContain("internetClient", config.ProcessContainer!.Capabilities!); + var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); + Assert.DoesNotContain("internetClient", config.AppContainer!.Capabilities!); Assert.Equal("block", config.Network!.DefaultPolicy); } @@ -199,14 +214,14 @@ 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!); } @@ -224,7 +239,7 @@ public void Build_RejectsExplicitEnvironmentUntilBackendSupportsIt() }; var ex = Assert.Throws(() => - MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "")); + BuildConfig(request, pathEnvVar: "")); Assert.Contains("Explicit environment variables", ex.Message); } @@ -232,7 +247,7 @@ public void Build_RejectsExplicitEnvironmentUntilBackendSupportsIt() 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!); } @@ -242,7 +257,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!); @@ -253,7 +268,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!); } @@ -263,7 +278,7 @@ 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!); } @@ -292,7 +307,7 @@ public void Build_FiltersHostProfileDeniedPathsBeforeBackendEmissionButStillFilt 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(chromeProfile, config.Filesystem!.ReadwritePaths!, StringComparer.OrdinalIgnoreCase); Assert.DoesNotContain(userProfile, config.Filesystem.ReadwritePaths!, StringComparer.OrdinalIgnoreCase); @@ -301,6 +316,31 @@ public void Build_FiltersHostProfileDeniedPathsBeforeBackendEmissionButStillFilt Assert.Contains(settingsDeny, config.Filesystem.DeniedPaths!, StringComparer.OrdinalIgnoreCase); } + [Fact] + public void Build_FiltersMissingDeniedPathsBeforeBackendEmissionButStillFiltersAllows() + { + 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() { @@ -328,7 +368,7 @@ public void Build_BootstrapsShellPathAndGrantsBackendSafePathDirsReadonly() using var argsDoc = JsonDocument.Parse("""{"command":"git --version","shell":"cmd"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: tempDir); + var config = BuildConfig(request, pathEnvVar: tempDir); Assert.NotNull(config.Process.Env); Assert.Empty(config.Process.Env); @@ -360,7 +400,7 @@ public void Build_DoesNotAddDriveRootCompatibilityGrant() 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("C:\\", config.Filesystem!.ReadonlyPaths!); } @@ -378,7 +418,7 @@ 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!); } @@ -425,7 +465,7 @@ public void Build_BootstrapsProtectedPathDirsWithoutGrantingThem() using var argsDoc = JsonDocument.Parse("""{"command":"tool --version","shell":"cmd"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: programFiles); + var config = BuildConfig(request, pathEnvVar: programFiles); Assert.Contains($"set \"PATH={programFiles}\"", config.Process.CommandLine); Assert.DoesNotContain(programFiles, config.Filesystem!.ReadonlyPaths!, StringComparer.OrdinalIgnoreCase); @@ -445,7 +485,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!); } @@ -453,7 +493,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); } @@ -461,7 +501,7 @@ 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); } @@ -471,7 +511,7 @@ public void Build_DefaultShell_UsesCmdAndPreservesUiDeny() using var argsDoc = JsonDocument.Parse("""{"command":"echo hi"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); var expected = Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine( @@ -500,7 +540,7 @@ public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolatio }; var request = RequestFor(policy) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); Assert.False(config.Ui!.Disable); Assert.Equal("desktop", config.AppContainer!.Ui!.Isolation); @@ -512,7 +552,7 @@ 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 = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); var expected = Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine( @@ -537,7 +577,7 @@ public void Build_PwshShell_UsesPwshAndPreservesUiDeny() using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); Assert.StartsWith("pwsh.exe", config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); @@ -557,7 +597,7 @@ public void Build_PwshShell_ResolvesPwshFromPathBeforeClearingProcessEnvironment using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: binDir); + var config = BuildConfig(request, pathEnvVar: binDir); var expectedPrefix = pwshPath.IndexOfAny(new[] { ' ', '\t', '"' }) < 0 ? pwshPath @@ -579,7 +619,7 @@ public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"powershell"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var config = BuildConfig(request, pathEnvVar: ""); var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? Environment.GetEnvironmentVariable("windir"); @@ -605,7 +645,7 @@ public void Build_PowerShellShell_QuotesPathBootstrapValue() using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output $env:PATH","shell":"powershell"}"""); var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; - var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: pathEnv); + var config = BuildConfig(request, pathEnvVar: pathEnv); var script = DecodePowershellEncodedCommand(config.Process.CommandLine); Assert.Contains("$env:PATH = '" + pathEnv.Replace("'", "''") + "';", script); diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs index 129a3a45c..dfe9a3443 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcPolicyBuilderTests.cs @@ -46,7 +46,7 @@ public void ForSystemRun_DeniesSettingsDirectoryPath() } [Fact] - public void ForSystemRun_SkipsMissingSettingsDirectoryPath() + public void ForSystemRun_DeniesMissingSettingsDirectoryPath() { var settings = new SettingsData(); var missingSettingsDir = Path.Combine(Path.GetTempPath(), "openclaw-missing-settings-" + Guid.NewGuid().ToString("N")); @@ -55,7 +55,7 @@ public void ForSystemRun_SkipsMissingSettingsDirectoryPath() Assert.NotNull(policy.Filesystem); Assert.NotNull(policy.Filesystem!.DeniedPaths); - Assert.DoesNotContain(policy.Filesystem.DeniedPaths!, p => + Assert.Contains(policy.Filesystem.DeniedPaths!, p => string.Equals(p, missingSettingsDir, StringComparison.OrdinalIgnoreCase)); } @@ -335,6 +335,34 @@ public void ForSystemRun_CustomFolder_ParentOfDeniedPath_FilteredOut() } } + [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] public void ForSystemRun_CustomFolder_NotOverlappingDeny_StillGranted() { From 4676dc80135087ee7f2b64830786c1d46758d309 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:54:13 +0100 Subject: [PATCH 08/37] fix: keep MXC fallback shell approvals aligned --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 72 +++++++++++++++- .../Mxc/MxcCommandRunnerTests.cs | 84 ++++++++++++++++--- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 40902a57e..72c73a5bc 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -128,6 +128,42 @@ public async Task RunAsync(CommandRequest request, CancellationTo return await RunHostFallbackAsync(request, effectiveShell, ct); } + if (request.Env is { Count: > 0 }) + { + _invalidateAvailability?.Invoke(); + 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: custom env requires host fallback, but sandbox is unavailable and host fallback is blocked by settings"); + + _logger.Warn( + "[mxc] system.run UNCONTAINED: custom env is unsupported by MXC processcontainer " + + "and MXC is unavailable after re-probe; routing through host runner for compatibility."); + var hostShell = ResolveHostFallbackShell(request.Shell); + if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) + return DenyFallbackShellMismatch(effectiveShell, hostShell); + + return await RunHostFallbackAsync(request, hostShell, ct); + } + + 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, + }; + } + var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request); @@ -179,7 +215,11 @@ public async Task RunAsync(CommandRequest request, CancellationTo _logger.Warn( $"[mxc] system.run UNCONTAINED: sandbox became unavailable at runtime ({ex.Message}); " + "routing through host runner for compatibility."); - return await RunHostFallbackAsync(request, effectiveShell, ct); + var hostShell = ResolveHostFallbackShell(request.Shell); + if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) + return DenyFallbackShellMismatch(effectiveShell, hostShell); + + return await RunHostFallbackAsync(request, hostShell, ct); } catch (OperationCanceledException) { @@ -237,6 +277,36 @@ private Task RunHostFallbackAsync(CommandRequest request, string return _hostFallback.RunAsync(fallbackRequest, ct); } + private string ResolveHostFallbackShell(string? requestedShell) => + string.IsNullOrWhiteSpace(requestedShell) + ? _hostFallback.ResolveEffectiveShell(requestedShell) + : requestedShell.Trim(); + + private static bool FallbackWouldChangeApprovedShell( + CommandRequest request, + string approvedShell, + string hostFallbackShell) => + string.IsNullOrWhiteSpace(request.Shell) + && !string.Equals(approvedShell, hostFallbackShell, StringComparison.OrdinalIgnoreCase); + + 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}'. 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) { var payload = new diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 2360a0670..4a417f7bc 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -96,12 +96,29 @@ public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailable() }; var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); - 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("cmd", fallback.LastRequest!.Shell); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_SandboxEnabled_DeniesRuntimeFallbackWhenOmittedShellWouldChangeAfterApproval() + { + 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 result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("pre-approved shell", result.Stderr); + Assert.Null(fallback.LastRequest); } [Fact] @@ -155,10 +172,13 @@ public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() } [Fact] - public async Task RunAsync_SandboxEnabled_MxcUnavailable_RejectsCustomEnvBeforeHostFallback() + public async Task RunAsync_SandboxEnabled_MxcUnavailable_PreservesCustomEnvOnHostFallback() { var executor = new FakeSandboxExecutor(); - var fallback = new FakeCommandRunner(); + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; var runner = NewRunner( executor, fallback, @@ -171,10 +191,12 @@ public async Task RunAsync_SandboxEnabled_MxcUnavailable_RejectsCustomEnvBeforeH Env = new Dictionary { ["FOO"] = "bar" }, }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("custom environment variables", result.Stderr); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); Assert.Null(executor.LastRequest); - Assert.Null(fallback.LastRequest); + Assert.NotNull(fallback.LastRequest); + Assert.NotNull(fallback.LastRequest!.Env); + Assert.Equal("bar", fallback.LastRequest.Env["FOO"]); } [Fact] @@ -353,13 +375,53 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa 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("cmd", fallback.LastRequest!.Shell); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [Fact] + public async Task RunAsync_CustomEnv_ReprobesAvailabilityAndFallsBackWhenMxcBecameUnavailable() + { + var executor = new FakeSandboxExecutor(); + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; + var sandboxAvailable = true; + var invalidationCount = 0; + var runner = new MxcCommandRunner( + executor, + fallback, + () => NewSettings(sandboxEnabled: true), + () => "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(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.Equal(1, invalidationCount); + Assert.Null(executor.LastRequest); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + Assert.NotNull(fallback.LastRequest.Env); + Assert.Equal("bar", fallback.LastRequest.Env["FOO"]); } [Fact] @@ -610,11 +672,11 @@ public async Task RunAsync_UnavailableExecutor_FallsBackToHost() }; var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); - 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("cmd", fallback.LastRequest!.Shell); + Assert.Equal("powershell", fallback.LastRequest!.Shell); } } From 580eb91fea7662c42a2f1db02663011c55dd7d0b Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:24:52 +0100 Subject: [PATCH 09/37] fix: make MXC sandbox fallback fail closed by default --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 9 ++- src/OpenClaw.Shared/Mxc/SandboxPolicy.cs | 6 +- src/OpenClaw.Shared/SettingsData.cs | 21 ++++-- .../Pages/SandboxPage.xaml.cs | 12 ++-- .../Services/NodeService.cs | 12 ++-- .../Services/SettingsManager.cs | 39 +++++++++-- .../Mxc/MxcCommandRunnerTests.cs | 51 +++++++++++---- .../SettingsRoundTripTests.cs | 64 ++++++++++++++++++- 8 files changed, 168 insertions(+), 46 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 72c73a5bc..ebc5d020f 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -13,8 +13,8 @@ namespace OpenClaw.Shared.Mxc; /// /// Honors : /// -/// true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable. -/// true with — deny when MXC is unavailable. +/// true (default) — sandbox via MXC when available; deny when MXC is unavailable. +/// true with set to false — fall back uncontained when MXC is unavailable. /// false — bypass MXC; route through the host runner. /// /// @@ -119,9 +119,8 @@ public async Task RunAsync(CommandRequest request, CancellationTo "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 on unsupported - // hosts. Operators that require fail-closed containment enable - // SystemRunBlockHostFallbackWhenMxcUnavailable. + // Compatibility opt-out: keep pre-MXC host execution only when the + // operator explicitly disables fail-closed sandbox-unavailable behavior. _logger.Warn( "[mxc] system.run UNCONTAINED: sandbox unavailable on this host; " + "routing through host runner for compatibility."); diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs index 47fde15ef..8a72c06ee 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -41,13 +41,13 @@ 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 host execution for compatibility unless -/// is enabled. +/// blocks by default and falls back to host execution for compatibility only when +/// is disabled. /// When the toggle is false, system.run runs on the host without attempting MXC. /// public enum SandboxMode { - /// Use MXC when available; otherwise run through the host fallback. + /// Use MXC when available; otherwise block unless compatibility fallback is explicitly enabled. Enabled, /// Bypass MXC entirely and run on the host. diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index d2ebaf2cf..c6fe176d7 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,19 +132,20 @@ public record class SettingsData // ── MXC sandbox ───────────────────────────────────────────────────── /// /// Master switch for system.run containment. When true (default), - /// system.run uses MXC containment when available and falls back to host - /// execution when MXC is unavailable. 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. + /// system.run uses MXC containment when available and blocks when MXC is + /// unavailable unless host fallback has been explicitly allowed. 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 existing fallback requested for compatibility. + /// instead of using the compatibility host fallback. Default true + /// keeps the sandbox toggle fail-closed when containment is unavailable. /// - public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get; set; } = false; + public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get; set; } = true; /// /// When sandboxed, allow system.run commands to reach the public internet. diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs index 6fc0692f6..e8a4fd9cb 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -191,17 +191,17 @@ 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 + ON → ⚠ "Sandbox unavailable — host fallback" + toggle visible + /// 3. Unavailable + ON → ⚠ "Sandbox unavailable — commands blocked" + toggle visible /// 4. Unavailable + OFF → ⚠ "Sandbox is off — host execution" + toggle visible - /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner - /// preserves the compatibility host fallback by default, or blocks commands - /// when strict fallback blocking is enabled. + /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner blocks + /// by default and uses the compatibility host fallback only when strict + /// fallback blocking has been explicitly disabled. /// private void UpdateSandboxStatusCard() { var availability = _cachedAvailability; var enabled = SandboxEnabledToggle.IsOn; - var blockHostFallback = CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false; + var blockHostFallback = CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? true; UpdateUnavailableActionBar(availability, enabled); @@ -303,7 +303,7 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava var isSetupIssue = !availability.IsWxcExecResolvable; var blockHostFallback = sandboxEnabled - && (CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false); + && (CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? true); 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. "; diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index f0de1d470..97430cca9 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -567,8 +567,8 @@ private void DetachClientHandlers(WindowsNodeClient client) /// Build the for system.run. Returns an /// wrapping . /// The runner honors - /// by attempting MXC containment when available, falling back to host - /// execution when MXC is unavailable unless strict fallback blocking is + /// by attempting MXC containment when available, blocking by default when + /// MXC is unavailable unless compatibility host fallback is explicitly /// enabled, and rejecting unsupported sandbox request features while /// sandboxing remains enabled. /// @@ -599,12 +599,12 @@ private ICommandRunner BuildSystemRunRunner() else { // MXC unavailable on this host. The runner's top-level - // !_isSandboxAvailable() guard will either use the compatibility - // host fallback or block, depending on settings. The executor is + // !_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); - var unavailableMode = (_settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false) + var unavailableMode = (_settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? true) ? "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})"); @@ -635,7 +635,7 @@ private SettingsData SnapshotSettings() return new SettingsData { SystemRunSandboxEnabled = true, - SystemRunBlockHostFallbackWhenMxcUnavailable = false, + SystemRunBlockHostFallbackWhenMxcUnavailable = true, SystemRunAllowOutbound = false, }; diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 57b926e3a..972cfcb5c 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,9 +144,9 @@ 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 uses MXC when available and falls back to host execution when 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 blocks when unavailable unless compatibility fallback is explicitly 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. + /// When true, sandbox-enabled system.run blocks instead of using the compatibility host fallback if MXC is unavailable. Default true. 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 }; } @@ -201,7 +202,7 @@ public void Load() var loaded = SettingsData.FromJson(json); if (loaded != null) { - _data = NormalizeLoadedData(loaded); + _data = NormalizeLoadedData(loaded, json); } } } @@ -215,6 +216,7 @@ public void Load() private static SettingsData CreateDefaultData() => new() { + SettingsSchemaVersion = CurrentSettingsSchemaVersion, GatewayUrl = "ws://localhost:18789", UseSshTunnel = false, SshTunnelUser = "", @@ -268,7 +270,7 @@ public void Load() SkippedUpdateTag = "", PreferredGatewayId = null, SystemRunSandboxEnabled = true, - SystemRunBlockHostFallbackWhenMxcUnavailable = false, + SystemRunBlockHostFallbackWhenMxcUnavailable = true, SystemRunAllowOutbound = false, SandboxClipboard = SandboxClipboardMode.None, SandboxDocumentsAccess = null, @@ -279,11 +281,18 @@ 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 isLegacySettingsFile = !JsonHasProperty(rawJson, nameof(SettingsData.SettingsSchemaVersion)); + var legacySerializedFallbackDefault = + isLegacySettingsFile && + JsonHasProperty(rawJson, nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)) && + !loaded.SystemRunBlockHostFallbackWhenMxcUnavailable; + var data = loaded with { + SettingsSchemaVersion = CurrentSettingsSchemaVersion, GatewayUrl = loaded.GatewayUrl ?? defaults.GatewayUrl, SshTunnelUser = loaded.SshTunnelUser ?? defaults.SshTunnelUser, SshTunnelHost = loaded.SshTunnelHost ?? defaults.SshTunnelHost, @@ -305,6 +314,9 @@ 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 = legacySerializedFallbackDefault + ? defaults.SystemRunBlockHostFallbackWhenMxcUnavailable + : loaded.SystemRunBlockHostFallbackWhenMxcUnavailable, SandboxTimeoutMs = loaded.SandboxTimeoutMs > 0 ? loaded.SandboxTimeoutMs : defaults.SandboxTimeoutMs, SandboxMaxOutputBytes = loaded.SandboxMaxOutputBytes > 0 ? loaded.SandboxMaxOutputBytes : defaults.SandboxMaxOutputBytes, McpOnlyMode = null @@ -327,6 +339,23 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded) private static bool IsValidPort(int port) => port is >= 1 and <= 65535; + private static bool JsonHasProperty(string? json, string propertyName) + { + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty(propertyName, out _); + } + catch (JsonException) + { + return false; + } + } + private static List CloneSandboxCustomFolders(IEnumerable? folders) => folders is null ? new List() diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 4a417f7bc..d97eb80d9 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -9,7 +9,7 @@ public class MxcCommandRunnerTests { private static SettingsData NewSettings( bool sandboxEnabled = true, - bool blockHostFallbackWhenMxcUnavailable = false) + bool blockHostFallbackWhenMxcUnavailable = true) { return new SettingsData { @@ -57,13 +57,15 @@ public void ResolveEffectiveShell_DelegatesToHost_WhenSandboxDisabled() } [Fact] - public void ResolveEffectiveShell_DelegatesToHost_WhenMxcUnavailable() + public void ResolveEffectiveShell_DelegatesToHost_WhenMxcUnavailableAndCompatibilityFallbackEnabled() { var fallback = new FakeCommandRunner { EffectiveShellForNull = "pwsh" }; var runner = NewRunner( new FakeSandboxExecutor(), fallback, - NewSettings(sandboxEnabled: true), + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), sandboxAvailable: false); Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); @@ -87,14 +89,19 @@ public void ResolveEffectiveShell_UsesSandboxShell_WhenStrictFallbackBlockingEna } [Fact] - public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailable() + public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailableAndCompatibilityFallbackEnabled() { 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", Shell = "powershell" }); @@ -112,7 +119,12 @@ public async Task RunAsync_SandboxEnabled_DeniesRuntimeFallbackWhenOmittedShellW { 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" }); @@ -182,7 +194,9 @@ public async Task RunAsync_SandboxEnabled_MxcUnavailable_PreservesCustomEnvOnHos var runner = NewRunner( executor, fallback, - NewSettings(sandboxEnabled: true), + NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), sandboxAvailable: false); var result = await runner.RunAsync(new CommandRequest @@ -224,7 +238,7 @@ public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOff() } [Fact] - public async Task RunAsync_MxcUnavailable_RoutesToHost_WithSandboxToggleOn() + public async Task RunAsync_MxcUnavailable_RoutesToHost_WhenCompatibilityFallbackEnabled() { var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" }; var fallback = new FakeCommandRunner @@ -234,7 +248,9 @@ public async Task RunAsync_MxcUnavailable_RoutesToHost_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" }); @@ -369,7 +385,9 @@ 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++, @@ -397,7 +415,9 @@ public async Task RunAsync_CustomEnv_ReprobesAvailabilityAndFallsBackWhenMxcBeca var runner = new MxcCommandRunner( executor, fallback, - () => NewSettings(sandboxEnabled: true), + () => NewSettings( + sandboxEnabled: true, + blockHostFallbackWhenMxcUnavailable: false), () => "C:\\test\\settings", () => sandboxAvailable, invalidateAvailability: () => @@ -659,7 +679,7 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout() } [Fact] - public async Task RunAsync_UnavailableExecutor_FallsBackToHost() + public async Task RunAsync_UnavailableExecutor_FallsBackToHost_WhenCompatibilityFallbackEnabled() { var executor = new FakeSandboxExecutor { @@ -670,7 +690,12 @@ 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", Shell = "powershell" }); diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 8bfc38d75..3da9874e1 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -69,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); @@ -183,7 +184,7 @@ public void MissingFields_UseDefaults() Assert.True(settings.NotifyChatResponses); Assert.True(settings.PreferStructuredCategories); Assert.True(settings.SystemRunSandboxEnabled); - Assert.False(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + Assert.True(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). @@ -203,6 +204,67 @@ public void HubNavPaneOpen_DefaultsTrue_ForEmptyJson() Assert.True(settings!.HubNavPaneOpen); } + [Fact] + public void SettingsManager_MigratesLegacySandboxFallbackDefaultToFailClosed() + { + 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.True(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.True(saved.RootElement.GetProperty(nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)).GetBoolean()); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void SettingsManager_PreservesVersionedSandboxFallbackOptIn() + { + 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 BackwardCompatibility_OldSettingsWithoutNewFields() { From e5df21b19a6d85678b7729750c003f7959cb57c5 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:01:27 +0100 Subject: [PATCH 10/37] fix: preserve MXC host fallback by default --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 8 ++-- src/OpenClaw.Shared/Mxc/SandboxPolicy.cs | 6 +-- src/OpenClaw.Shared/SettingsData.cs | 11 +++--- .../Pages/SandboxPage.xaml.cs | 12 +++--- .../Services/NodeService.cs | 8 ++-- .../Services/SettingsManager.cs | 33 ++-------------- .../Mxc/MxcCommandRunnerTests.cs | 2 +- .../SettingsRoundTripTests.cs | 38 ++++++++++++++++--- 8 files changed, 61 insertions(+), 57 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index ebc5d020f..4ac0cb9ba 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -13,8 +13,8 @@ namespace OpenClaw.Shared.Mxc; /// /// Honors : /// -/// true (default) — sandbox via MXC when available; deny when MXC is unavailable. -/// true with set to false — fall back uncontained when MXC is unavailable. +/// 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. /// /// @@ -119,8 +119,8 @@ public async Task RunAsync(CommandRequest request, CancellationTo "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 opt-out: keep pre-MXC host execution only when the - // operator explicitly disables fail-closed sandbox-unavailable behavior. + // 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; " + "routing through host runner for compatibility."); diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs index 8a72c06ee..0f4c1c353 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -41,13 +41,13 @@ public enum ClipboardPolicy /// /// When is true, system.run /// is contained via MXC AppContainer. When MXC is unavailable on the host, system.run -/// blocks by default and falls back to host execution for compatibility only when -/// is disabled. +/// 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 block unless compatibility fallback is explicitly enabled. + /// Use MXC when available; otherwise use compatibility fallback unless strict blocking is enabled. Enabled, /// Bypass MXC entirely and run on the host. diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index c6fe176d7..e0314dae7 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -132,8 +132,8 @@ public record class SettingsData // ── MXC sandbox ───────────────────────────────────────────────────── /// /// Master switch for system.run containment. When true (default), - /// system.run uses MXC containment when available and blocks when MXC is - /// unavailable unless host fallback has been explicitly allowed. Unsupported + /// 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. @@ -142,10 +142,11 @@ public record class SettingsData /// /// When sandboxing is enabled but MXC is unavailable, block system.run - /// instead of using the compatibility host fallback. Default true - /// keeps the sandbox toggle fail-closed when containment is unavailable. + /// 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; } = true; + public bool SystemRunBlockHostFallbackWhenMxcUnavailable { get; set; } = false; /// /// When sandboxed, allow system.run commands to reach the public internet. diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs index e8a4fd9cb..8d8e0c2a7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -191,17 +191,17 @@ 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 + ON → ⚠ "Sandbox unavailable — commands blocked" + toggle visible + /// 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 blocks - /// by default and uses the compatibility host fallback only when strict - /// fallback blocking has been explicitly disabled. + /// 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 ?? true; + var blockHostFallback = CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? false; UpdateUnavailableActionBar(availability, enabled); @@ -303,7 +303,7 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava var isSetupIssue = !availability.IsWxcExecResolvable; var blockHostFallback = sandboxEnabled - && (CurrentApp.Settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? true); + && (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. "; diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 97430cca9..ba9733f19 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -567,8 +567,8 @@ private void DetachClientHandlers(WindowsNodeClient client) /// Build the for system.run. Returns an /// wrapping . /// The runner honors - /// by attempting MXC containment when available, blocking by default when - /// MXC is unavailable unless compatibility host fallback is explicitly + /// 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. /// @@ -604,7 +604,7 @@ private ICommandRunner BuildSystemRunRunner() // constructed only to satisfy the constructor contract and is never // invoked. var reason = string.Join("; ", peeked.UnsupportedReasons); - var unavailableMode = (_settings?.SystemRunBlockHostFallbackWhenMxcUnavailable ?? true) + 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})"); @@ -635,7 +635,7 @@ private SettingsData SnapshotSettings() return new SettingsData { SystemRunSandboxEnabled = true, - SystemRunBlockHostFallbackWhenMxcUnavailable = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, SystemRunAllowOutbound = false, }; diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 972cfcb5c..52acbdc8c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -144,9 +144,9 @@ 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 uses MXC when available and blocks when unavailable unless compatibility fallback is explicitly enabled. 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 true. + /// 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 }; } @@ -270,7 +270,7 @@ public void Load() SkippedUpdateTag = "", PreferredGatewayId = null, SystemRunSandboxEnabled = true, - SystemRunBlockHostFallbackWhenMxcUnavailable = true, + SystemRunBlockHostFallbackWhenMxcUnavailable = false, SystemRunAllowOutbound = false, SandboxClipboard = SandboxClipboardMode.None, SandboxDocumentsAccess = null, @@ -284,12 +284,6 @@ public void Load() private static SettingsData NormalizeLoadedData(SettingsData loaded, string? rawJson = null) { var defaults = CreateDefaultData(); - var isLegacySettingsFile = !JsonHasProperty(rawJson, nameof(SettingsData.SettingsSchemaVersion)); - var legacySerializedFallbackDefault = - isLegacySettingsFile && - JsonHasProperty(rawJson, nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)) && - !loaded.SystemRunBlockHostFallbackWhenMxcUnavailable; - var data = loaded with { SettingsSchemaVersion = CurrentSettingsSchemaVersion, @@ -314,9 +308,7 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded, string? raw PreferredGatewayId = loaded.PreferredGatewayId ?? defaults.PreferredGatewayId, UserRules = loaded.UserRules != null ? new List(loaded.UserRules) : new(), SandboxCustomFolders = CloneSandboxCustomFolders(loaded.SandboxCustomFolders), - SystemRunBlockHostFallbackWhenMxcUnavailable = legacySerializedFallbackDefault - ? defaults.SystemRunBlockHostFallbackWhenMxcUnavailable - : loaded.SystemRunBlockHostFallbackWhenMxcUnavailable, + SystemRunBlockHostFallbackWhenMxcUnavailable = loaded.SystemRunBlockHostFallbackWhenMxcUnavailable, SandboxTimeoutMs = loaded.SandboxTimeoutMs > 0 ? loaded.SandboxTimeoutMs : defaults.SandboxTimeoutMs, SandboxMaxOutputBytes = loaded.SandboxMaxOutputBytes > 0 ? loaded.SandboxMaxOutputBytes : defaults.SandboxMaxOutputBytes, McpOnlyMode = null @@ -339,23 +331,6 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded, string? raw private static bool IsValidPort(int port) => port is >= 1 and <= 65535; - private static bool JsonHasProperty(string? json, string propertyName) - { - if (string.IsNullOrWhiteSpace(json)) - return false; - - try - { - using var document = JsonDocument.Parse(json); - return document.RootElement.ValueKind == JsonValueKind.Object && - document.RootElement.TryGetProperty(propertyName, out _); - } - catch (JsonException) - { - return false; - } - } - private static List CloneSandboxCustomFolders(IEnumerable? folders) => folders is null ? new List() diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index d97eb80d9..6882fb2ab 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -9,7 +9,7 @@ public class MxcCommandRunnerTests { private static SettingsData NewSettings( bool sandboxEnabled = true, - bool blockHostFallbackWhenMxcUnavailable = true) + bool blockHostFallbackWhenMxcUnavailable = false) { return new SettingsData { diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 3da9874e1..a45a4a53a 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -184,7 +184,7 @@ public void MissingFields_UseDefaults() Assert.True(settings.NotifyChatResponses); Assert.True(settings.PreferStructuredCategories); Assert.True(settings.SystemRunSandboxEnabled); - Assert.True(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + 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). @@ -205,7 +205,7 @@ public void HubNavPaneOpen_DefaultsTrue_ForEmptyJson() } [Fact] - public void SettingsManager_MigratesLegacySandboxFallbackDefaultToFailClosed() + public void SettingsManager_PreservesLegacySandboxFallbackDefault() { var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); @@ -222,13 +222,13 @@ public void SettingsManager_MigratesLegacySandboxFallbackDefaultToFailClosed() var settings = new SettingsManager(dir); Assert.True(settings.SystemRunSandboxEnabled); - Assert.True(settings.SystemRunBlockHostFallbackWhenMxcUnavailable); + 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.True(saved.RootElement.GetProperty(nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)).GetBoolean()); + Assert.False(saved.RootElement.GetProperty(nameof(SettingsData.SystemRunBlockHostFallbackWhenMxcUnavailable)).GetBoolean()); } finally { @@ -238,7 +238,7 @@ public void SettingsManager_MigratesLegacySandboxFallbackDefaultToFailClosed() } [Fact] - public void SettingsManager_PreservesVersionedSandboxFallbackOptIn() + public void SettingsManager_PreservesVersionedSandboxFallbackCompatibility() { var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); @@ -265,6 +265,34 @@ public void SettingsManager_PreservesVersionedSandboxFallbackOptIn() } } + [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() { From 9e36c56c391e2f00eeec56f80fd0ff9fbd3bc1b8 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:18:52 +0100 Subject: [PATCH 11/37] fix: preserve cmd bootstrap env expansion --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 81 +++++++++++++++++-- .../Mxc/MxcConfigBuilderTests.cs | 32 ++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 08556b7d5..8fc7fffe8 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -459,6 +459,8 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList< /// internal static class ShellCommandLine { + private static readonly string[] CmdBootstrapTempEnvNames = ["TEMP", "TMP", "TMPDIR"]; + public static string Build( string shell, string command, @@ -487,12 +489,25 @@ private static string BuildCmd( IReadOnlyList pathDirs) { // cmd /S /C " [args]" — /S strips outer quotes so cmd treats - // everything after /C as the command line verbatim. + // 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(command); - foreach (var a in argv) + sb.Append(rewrittenCommand); + foreach (var a in rewrittenArgv) { sb.Append(' '); sb.Append(QuoteForCmd(a)); @@ -501,6 +516,59 @@ private static string BuildCmd( return sb.ToString(); } + 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, @@ -622,10 +690,9 @@ 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("\"", "\"\"") + "\""; diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 6e1ab5d66..457ba802a 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -386,6 +386,38 @@ public void Build_BootstrapsShellPathAndGrantsBackendSafePathDirsReadonly() } } + [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 + { + // 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_DoesNotAddDriveRootCompatibilityGrant() { From f85c5e269ac586a235f2a809fa1fd93fe289ef6b Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:39:26 +0100 Subject: [PATCH 12/37] refactor: keep MXC config test knobs internal --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 26 +++++++++++++------ .../Mxc/MxcConfigBuilderTests.cs | 7 ++--- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 8fc7fffe8..a597ada4a 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -49,18 +49,20 @@ 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, - Func? deniedPathExists = null) + MxcConfigBuildContext context) { if (request is null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir)); - deniedPathExists ??= PathExists; + if (context is null) throw new ArgumentNullException(nameof(context)); + var deniedPathExists = context.DeniedPathExists ?? PathExists; var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); @@ -75,7 +77,7 @@ public static MxcConfig Build( // 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 = ResolvePathDirsForShellPath(pathEnvVar); + var pathDirs = ResolvePathDirsForShellPath(context.PathEnvVar); foreach (var dir in pathDirs) { if (!IsBackendSafeReadonlyGrant(dir)) continue; @@ -155,7 +157,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 @@ -451,6 +453,14 @@ private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement arg private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); } +internal sealed record MxcConfigBuildContext( + string? ContainerId = null, + string? PathEnvVar = null, + Func? DeniedPathExists = 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 diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 457ba802a..210ecac17 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -103,9 +103,10 @@ private static MxcConfig BuildConfig( MxcConfigBuilder.Build( request, scratchDir, - containerId, - pathEnvVar, - deniedPathExists ?? DeniedPathExists); + new MxcConfigBuildContext( + ContainerId: containerId, + PathEnvVar: pathEnvVar, + DeniedPathExists: deniedPathExists ?? DeniedPathExists)); [Theory] [InlineData("locked-down", "LockedDown")] From 67553c7b59378f8aa2f2cd06543c97070c999130 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:50:55 +0100 Subject: [PATCH 13/37] fix: harden MXC diagnostics and cmd resolution --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 26 +++++++-- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 4 -- .../Mxc/MxcCommandRunnerTests.cs | 54 +++++++++++++++---- .../Mxc/MxcConfigBuilderTests.cs | 54 +++++++++++-------- 4 files changed, 98 insertions(+), 40 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 4ac0cb9ba..565a6b68a 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -329,19 +329,35 @@ private void LogSandboxRequest( 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}; " + + $"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={commandRequest.Shell ?? DefaultSandboxShell}; " + $"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) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index a597ada4a..9a130eb0b 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -647,10 +647,6 @@ private static void AppendPowershellSet(StringBuilder sb, string name, string va private static string ResolveCmdExe() { - var comSpec = Environment.GetEnvironmentVariable("ComSpec"); - if (!string.IsNullOrWhiteSpace(comSpec)) - return comSpec; - var systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? Environment.GetEnvironmentVariable("windir"); return string.IsNullOrWhiteSpace(systemRoot) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 6882fb2ab..980139c88 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -634,7 +634,7 @@ public async Task RunAsync_PassesMaxOutputBytesToExecutor() } [Fact] - public async Task RunAsync_LogsSandboxSettingsSnapshotAndPolicy() + public async Task RunAsync_LogsRedactedSandboxSettingsAndPolicySummary() { var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner(); @@ -652,14 +652,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] diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 210ecac17..e74b024b2 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -108,6 +108,15 @@ private static MxcConfig BuildConfig( PathEnvVar: pathEnvVar, DeniedPathExists: deniedPathExists ?? DeniedPathExists)); + 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")] @@ -546,17 +555,7 @@ public void Build_DefaultShell_UsesCmdAndPreservesUiDeny() var config = BuildConfig(request, pathEnvVar: ""); - var expected = Environment.GetEnvironmentVariable("ComSpec") - ?? Path.Combine( - Environment.GetEnvironmentVariable("SystemRoot") - ?? Environment.GetEnvironmentVariable("windir") - ?? string.Empty, - "System32", - "cmd.exe"); - if (string.IsNullOrWhiteSpace(expected) || expected.StartsWith("System32", StringComparison.OrdinalIgnoreCase)) - expected = "cmd.exe"; - - Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + 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); @@ -587,23 +586,34 @@ public void Build_CmdShell_UsesResolvedCmdExe() var config = BuildConfig(request, pathEnvVar: ""); - var expected = Environment.GetEnvironmentVariable("ComSpec") - ?? Path.Combine( - Environment.GetEnvironmentVariable("SystemRoot") - ?? Environment.GetEnvironmentVariable("windir") - ?? string.Empty, - "System32", - "cmd.exe"); - if (string.IsNullOrWhiteSpace(expected) || expected.StartsWith("System32", StringComparison.OrdinalIgnoreCase)) - expected = "cmd.exe"; - - Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); + 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.AppContainer!.Ui!.Isolation); } + [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_UsesPwshAndPreservesUiDeny() { From 95d9d47af376642bdb5748c0dbaeeb6b69a58e6c Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:00:44 +0100 Subject: [PATCH 14/37] fix: normalize unsupported system.run shells --- src/OpenClaw.Shared/ICommandRunner.cs | 13 ++++- src/OpenClaw.Shared/LocalCommandRunner.cs | 10 +++- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 39 +++++++------ .../Mxc/MxcCommandRunnerTests.cs | 47 ++++++++++++++- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 57 ++++++++++++++++++- 5 files changed, 139 insertions(+), 27 deletions(-) diff --git a/src/OpenClaw.Shared/ICommandRunner.cs b/src/OpenClaw.Shared/ICommandRunner.cs index 60af22b81..7597fa5c9 100644 --- a/src/OpenClaw.Shared/ICommandRunner.cs +++ b/src/OpenClaw.Shared/ICommandRunner.cs @@ -65,9 +65,16 @@ public interface ICommandRunner /// string ResolveEffectiveShell(string? requestedShell) { - return string.IsNullOrWhiteSpace(requestedShell) - ? "powershell" - : requestedShell.Trim(); + 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. diff --git a/src/OpenClaw.Shared/LocalCommandRunner.cs b/src/OpenClaw.Shared/LocalCommandRunner.cs index 64d7d489f..0b52cd056 100644 --- a/src/OpenClaw.Shared/LocalCommandRunner.cs +++ b/src/OpenClaw.Shared/LocalCommandRunner.cs @@ -267,7 +267,15 @@ internal static string ResolveEffectiveShellName(string? requestedShell) private static string ResolveEffectiveShellName(string? requestedShell, string? pathEnvVar) { if (!string.IsNullOrWhiteSpace(requestedShell)) - return requestedShell.Trim(); + { + return requestedShell.Trim().ToLowerInvariant() switch + { + "cmd" => "cmd", + "pwsh" => "pwsh", + "powershell" => "powershell", + _ => "powershell", + }; + } return ResolveOnPath("pwsh.exe", pathEnvVar) is not null ? "pwsh" : "powershell"; } diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 565a6b68a..2cd5f7ceb 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -51,17 +51,17 @@ public MxcCommandRunner( public string ResolveEffectiveShell(string? requestedShell) { - if (!string.IsNullOrWhiteSpace(requestedShell)) - return requestedShell.Trim(); - var settings = _settingsProvider(); if (!settings.SystemRunSandboxEnabled) return _hostFallback.ResolveEffectiveShell(requestedShell); - if (_isSandboxAvailable() || settings.SystemRunBlockHostFallbackWhenMxcUnavailable) - return DefaultSandboxShell; + if (!_isSandboxAvailable() && !settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return _hostFallback.ResolveEffectiveShell(requestedShell); + + if (!string.IsNullOrWhiteSpace(requestedShell)) + return ResolveSandboxShell(requestedShell); - return _hostFallback.ResolveEffectiveShell(requestedShell); + return DefaultSandboxShell; } public async Task RunAsync(CommandRequest request, CancellationToken ct = default) @@ -165,7 +165,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). @@ -185,7 +185,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 @@ -261,9 +261,6 @@ private CommandResult DenySandboxUnavailable(string stderr, string logMessage) private Task RunHostFallbackAsync(CommandRequest request, string effectiveShell, CancellationToken ct) { - if (!string.IsNullOrWhiteSpace(request.Shell)) - return _hostFallback.RunAsync(request, ct); - var fallbackRequest = new CommandRequest { Command = request.Command, @@ -276,10 +273,17 @@ private Task RunHostFallbackAsync(CommandRequest request, string 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) => - string.IsNullOrWhiteSpace(requestedShell) - ? _hostFallback.ResolveEffectiveShell(requestedShell) - : requestedShell.Trim(); + _hostFallback.ResolveEffectiveShell(requestedShell); private static bool FallbackWouldChangeApprovedShell( CommandRequest request, @@ -306,12 +310,12 @@ private CommandResult DenyFallbackShellMismatch(string approvedShell, string hos }; } - private static JsonElement SerializeArgs(CommandRequest request) + private static JsonElement SerializeArgs(CommandRequest request, string effectiveShell) { var payload = new { command = request.Command, - shell = request.Shell ?? DefaultSandboxShell, + shell = effectiveShell, args = request.Args ?? Array.Empty(), cwd = request.Cwd, env = request.Env, @@ -325,6 +329,7 @@ private static JsonElement SerializeArgs(CommandRequest request) private void LogSandboxRequest( SandboxExecutionRequest sandboxRequest, CommandRequest commandRequest, + string effectiveShell, SettingsData settings, string settingsDirectoryPath, SandboxPolicy policy) @@ -340,7 +345,7 @@ private void LogSandboxRequest( $"downloads={settings.SandboxDownloadsAccess?.ToString() ?? ""},desktop={settings.SandboxDesktopAccess?.ToString() ?? ""}," + $"customFolderCount={settings.SandboxCustomFolders?.Count ?? 0},timeoutMs={settings.SandboxTimeoutMs},maxOutputBytes={settings.SandboxMaxOutputBytes}," + $"settingsDirectoryPath={(string.IsNullOrWhiteSpace(settingsDirectoryPath) ? "" : "")}}}; " + - $"shell={commandRequest.Shell ?? DefaultSandboxShell}; " + + $"shell={effectiveShell}; requestedShell={(string.IsNullOrWhiteSpace(commandRequest.Shell) ? "" : "")}; " + $"commandLength={commandRequest.Command?.Length ?? 0}; " + $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " + $"envKeys=[{string.Join(",", envKeys)}]; " + diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 980139c88..f6602b9e0 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -44,6 +44,7 @@ public void ResolveEffectiveShell_DefaultsToSandboxCmd_WhenSandboxEnabled() Assert.Equal("cmd", runner.ResolveEffectiveShell(null)); Assert.Equal("cmd", runner.ResolveEffectiveShell(" cmd ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); } [Fact] @@ -54,6 +55,7 @@ public void ResolveEffectiveShell_DelegatesToHost_WhenSandboxDisabled() Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); } [Fact] @@ -70,6 +72,7 @@ public void ResolveEffectiveShell_DelegatesToHost_WhenMxcUnavailableAndCompatibi Assert.Equal("pwsh", runner.ResolveEffectiveShell(null)); Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); } [Fact] @@ -86,6 +89,7 @@ public void ResolveEffectiveShell_UsesSandboxShell_WhenStrictFallbackBlockingEna Assert.Equal("cmd", runner.ResolveEffectiveShell(null)); Assert.Equal("powershell", runner.ResolveEffectiveShell(" powershell ")); + Assert.Equal("powershell", runner.ResolveEffectiveShell("bash")); } [Fact] @@ -584,9 +588,16 @@ private sealed class FakeCommandRunner : ICommandRunner public string ResolveEffectiveShell(string? requestedShell) { - return string.IsNullOrWhiteSpace(requestedShell) - ? EffectiveShellForNull - : requestedShell.Trim(); + 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) @@ -633,6 +644,36 @@ public async Task RunAsync_PassesMaxOutputBytesToExecutor() Assert.Equal(16L * 1024L * 1024L, executor.LastRequest!.MaxOutputBytes); } + [Fact] + 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()); + } + + [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() { diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index a84311730..b008c81c3 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -552,6 +552,50 @@ public async Task SystemRun_WithPolicy_EvaluatesImplicitShellUsingRunnerEffectiv } } + [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_WithPromptPolicy_PromptsOnceForShellWrapper_WhenUserApprovesOnce() { @@ -755,9 +799,16 @@ private class FakeCommandRunner : ICommandRunner public string ResolveEffectiveShell(string? requestedShell) { - return string.IsNullOrWhiteSpace(requestedShell) - ? EffectiveShellForNull - : requestedShell.Trim(); + 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) From 58ab4591721eaa20b6c943db059dde52ab74e002 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:37:00 +0100 Subject: [PATCH 15/37] fix: align system run prepare diagnostics --- .../Capabilities/SystemCapability.cs | 22 +++++++- .../Mxc/DirectAppContainerExecutor.cs | 53 +++++++++++++------ .../OpenClaw.Shared.Tests/CapabilityTests.cs | 43 +++++++++++++++ .../Mxc/DirectAppContainerExecutorTests.cs | 39 ++++++++++++++ 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 1e691d356..06e88fc13 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) { diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs index adf5d4337..b44764b8c 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.AppContainer?.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/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 353294de9..9ed29633e 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -370,6 +370,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 +766,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); + } } From ac671529f3fb68b227e935115533d58e62cf2d56 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:10:37 +0100 Subject: [PATCH 16/37] fix: fail closed on unsupported PowerShell MXC shells --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 12 +++++ src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 13 +++++ src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 4 +- .../Mxc/MxcCommandRunnerTests.cs | 18 +++++++ .../Mxc/MxcConfigBuilderTests.cs | 47 +++++++++++++++---- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 2cd5f7ceb..af38397bc 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -226,6 +226,18 @@ public async Task RunAsync(CommandRequest request, CancellationTo // caller sees the cancellation rather than a fake "exited 0" response. throw; } + catch (NotSupportedException ex) + { + _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 diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 9a130eb0b..b2250a39c 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -66,6 +66,13 @@ internal static MxcConfig Build( var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); + if (IsPowerShellFamilyShell(args.Shell) && policy?.Ui?.AllowWindows != true) + { + throw new NotSupportedException( + "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend. " + + "Use shell=\"cmd\", enable an approved UI policy, or disable sandboxing if uncontained host execution is acceptable."); + } + if (request.Env is { Count: > 0 }) { throw new NotSupportedException( @@ -450,6 +457,12 @@ 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 sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); } diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 558ff676a..c15546db3 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -21,8 +21,8 @@ namespace OpenClaw.Shared.Mxc; /// 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 in policy; the config builder may relax UI -/// for PowerShell-family shells that need desktop isolation to start under MXC. +/// ui — default-deny in policy; PowerShell-family shells must not +/// relax UI containment unless a caller supplies an approved UI policy. /// /// public static class MxcPolicyBuilder diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index f6602b9e0..21a839edb 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -495,6 +495,24 @@ public async Task RunAsync_GenericException_ReturnsDeny_DoesNotPropagate() Assert.Null(fallback.LastRequest); } + [Fact] + public async Task RunAsync_NotSupportedException_ReturnsExplicitDeny_DoesNotFallBack() + { + 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)); + + var result = await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = "powershell" }); + + Assert.Equal(-1, result.ExitCode); + Assert.Contains("PowerShell-family shells require UI access", result.Stderr); + Assert.DoesNotContain("unexpected", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Null(fallback.LastRequest); + } + [Fact] public async Task RunAsync_OperationCanceled_Propagates() { diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index e74b024b2..3d688f971 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -578,6 +578,19 @@ public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolatio Assert.Equal("desktop", config.AppContainer!.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_CmdShell_UsesResolvedCmdExe() { @@ -615,17 +628,21 @@ public void Build_CmdShell_IgnoresHostComSpec() } [Fact] - public void Build_PwshShell_UsesPwshAndPreservesUiDeny() + public void Build_PwshShell_WhenPolicyAllowsWindows_UsesPwshAndEnablesDesktopIsolation() { using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); - var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + 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.True(config.Ui!.Disable); - Assert.Equal("container", config.AppContainer!.Ui!.Isolation); + Assert.False(config.Ui!.Disable); + Assert.Equal("desktop", config.AppContainer!.Ui!.Isolation); } [Fact] @@ -638,7 +655,11 @@ public void Build_PwshShell_ResolvesPwshFromPathBeforeClearingProcessEnvironment var pwshPath = Path.Combine(binDir, "pwsh.exe"); File.WriteAllBytes(pwshPath, Array.Empty()); using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"pwsh"}"""); - var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + 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); @@ -660,7 +681,11 @@ public void Build_PwshShell_ResolvesPwshFromPathBeforeClearingProcessEnvironment public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() { using var argsDoc = JsonDocument.Parse("""{"command":"Write-Output hi","shell":"powershell"}"""); - var request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + 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: ""); @@ -672,8 +697,8 @@ public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() Assert.StartsWith(expected, config.Process.CommandLine, StringComparison.OrdinalIgnoreCase); Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); - Assert.True(config.Ui!.Disable); - Assert.Equal("container", config.AppContainer!.Ui!.Isolation); + Assert.False(config.Ui!.Disable); + Assert.Equal("desktop", config.AppContainer!.Ui!.Isolation); } [Fact] @@ -686,7 +711,11 @@ public void Build_PowerShellShell_QuotesPathBootstrapValue() 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 request = RequestFor(BalancedPolicy()) with { Args = argsDoc.RootElement.Clone() }; + 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); From 822173fe5abbf22f400362bdaac4f796b5777dcf Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:47:15 +0100 Subject: [PATCH 17/37] fix: preserve PowerShell MXC sandbox execution --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 12 ------------ src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 4 ++-- .../Mxc/MxcConfigBuilderTests.cs | 8 +++++--- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index b2250a39c..e414d0013 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -66,12 +66,6 @@ internal static MxcConfig Build( var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); - if (IsPowerShellFamilyShell(args.Shell) && policy?.Ui?.AllowWindows != true) - { - throw new NotSupportedException( - "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend. " + - "Use shell=\"cmd\", enable an approved UI policy, or disable sandboxing if uncontained host execution is acceptable."); - } if (request.Env is { Count: > 0 }) { @@ -457,12 +451,6 @@ 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 sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); } diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index c15546db3..bcb602957 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -21,8 +21,8 @@ namespace OpenClaw.Shared.Mxc; /// 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 in policy; PowerShell-family shells must not -/// relax UI containment unless a caller supplies an approved UI policy. +/// ui — default-deny in policy; shell selection must not relax UI +/// containment unless a caller supplies an approved UI policy. /// /// public static class MxcPolicyBuilder diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 3d688f971..0438ee34b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -581,14 +581,16 @@ public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolatio [Theory] [InlineData("powershell")] [InlineData("pwsh")] - public void Build_PowerShellFamilyShell_WhenUiDenied_FailsClosed(string shell) + public void Build_PowerShellFamilyShell_WhenUiDenied_PreservesContainerIsolation(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: "")); + var config = BuildConfig(request, pathEnvVar: ""); - Assert.Contains("PowerShell-family shells require UI access", ex.Message); + Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); + Assert.True(config.Ui!.Disable); + Assert.Equal("container", config.AppContainer!.Ui!.Isolation); } [Fact] From cdddc686410c19d2f166bd7f89a599d9e1812433 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:56:48 +0100 Subject: [PATCH 18/37] fix: align PowerShell MXC UI policy --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 19 ++++++++++++ src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 16 ++++++++-- src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 5 ++-- .../Mxc/MxcCommandRunnerTests.cs | 29 +++++++++++++++++++ .../Mxc/MxcConfigBuilderTests.cs | 8 ++--- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index af38397bc..86ad3c9f1 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -165,6 +165,7 @@ public async Task RunAsync(CommandRequest request, CancellationTo var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); + policy = ApplyShellRequiredPolicy(policy, effectiveShell); var argsJson = SerializeArgs(request, effectiveShell); // Compute the effective timeout: take the smaller of the agent-supplied @@ -294,6 +295,24 @@ private static string ResolveSandboxShell(string requestedShell) => _ => "powershell", }; + private static SandboxPolicy ApplyShellRequiredPolicy(SandboxPolicy policy, string effectiveShell) + { + if (!MxcShellRequiresWindowsUi(effectiveShell)) + return policy; + + var ui = policy.Ui ?? new UiPolicy(); + return policy with + { + Ui = ui with { AllowWindows = true }, + }; + } + + private static bool MxcShellRequiresWindowsUi(string shell) + { + var normalized = shell.Trim().ToLowerInvariant(); + return normalized is "powershell" or "pwsh"; + } + private string ResolveHostFallbackShell(string? requestedShell) => _hostFallback.ResolveEffectiveShell(requestedShell); diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index e414d0013..3b615d67d 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -35,8 +35,9 @@ 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 it must not silently - // relax UI containment. + // 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"; /// @@ -66,6 +67,11 @@ internal static MxcConfig Build( var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); + if (IsPowerShellFamilyShell(args.Shell) && policy?.Ui?.AllowWindows != true) + { + throw new NotSupportedException( + "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend."); + } if (request.Env is { Count: > 0 }) { @@ -451,6 +457,12 @@ 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 sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); } diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index bcb602957..93302fdc0 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -21,8 +21,9 @@ namespace OpenClaw.Shared.Mxc; /// 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 in policy; shell selection must not relax UI -/// containment unless a caller supplies an approved UI policy. +/// ui — default-deny in base policy. The command runner derives +/// per-shell UI requirements before config emission; PowerShell-family shells +/// need allowWindows on MXC 0.7. /// /// public static class MxcPolicyBuilder diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 21a839edb..7c501c4ec 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -673,6 +673,35 @@ public async Task RunAsync_SandboxRequestUsesNormalizedEffectiveShellForUnsuppor Assert.NotNull(executor.LastRequest); Assert.Equal("powershell", executor.LastRequest!.Args.GetProperty("shell").GetString()); + Assert.True(executor.LastRequest.Policy.Ui!.AllowWindows); + } + + [Theory] + [InlineData("powershell")] + [InlineData("pwsh")] + public async Task RunAsync_SandboxRequestAllowsWindowsForPowerShellFamilyShells(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.True(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] diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 0438ee34b..3d688f971 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -581,16 +581,14 @@ public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolatio [Theory] [InlineData("powershell")] [InlineData("pwsh")] - public void Build_PowerShellFamilyShell_WhenUiDenied_PreservesContainerIsolation(string shell) + 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 config = BuildConfig(request, pathEnvVar: ""); + var ex = Assert.Throws(() => BuildConfig(request, pathEnvVar: "")); - Assert.Contains(" -NoProfile -NonInteractive -EncodedCommand ", config.Process.CommandLine, StringComparison.Ordinal); - Assert.True(config.Ui!.Disable); - Assert.Equal("container", config.AppContainer!.Ui!.Isolation); + Assert.Contains("PowerShell-family shells require UI access", ex.Message); } [Fact] From f3c533283691404a51e157a194c2ab0d609953a9 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:58:38 +0100 Subject: [PATCH 19/37] test: add MXC filesystem access matrix --- .../Mxc/MxcCommandRunnerIntegrationTests.cs | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index a94e46710..7efbf37e7 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -182,6 +182,71 @@ 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 { } + } + } + private static bool HasSupportedSandboxPath(string path) { try @@ -204,5 +269,36 @@ 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("\"", "\"\"") + "\""; +} From 2f833066829d7eb133d80e14838e27f3bb8fb328 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:09:29 +0100 Subject: [PATCH 20/37] fix: preserve PR2 compatibility gates --- src/OpenClaw.Shared/LocalCommandRunner.cs | 2 +- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/LocalCommandRunner.cs b/src/OpenClaw.Shared/LocalCommandRunner.cs index 0b52cd056..94ea7cf90 100644 --- a/src/OpenClaw.Shared/LocalCommandRunner.cs +++ b/src/OpenClaw.Shared/LocalCommandRunner.cs @@ -277,7 +277,7 @@ private static string ResolveEffectiveShellName(string? requestedShell, string? }; } - return ResolveOnPath("pwsh.exe", pathEnvVar) is not null ? "pwsh" : "powershell"; + return "powershell"; } private static string? ResolveOnPath(string executableName, string? pathEnvVar = null) diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index b008c81c3..bd6918369 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -846,7 +846,7 @@ public Task RequestAsync( public class LocalCommandRunnerTests { [Fact] - public void BuildProcessArgs_DefaultShellUsesPwshWhenAvailableOnPath() + 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"); @@ -859,7 +859,7 @@ public void BuildProcessArgs_DefaultShellUsesPwshWhenAvailableOnPath() Command = "Write-Output hi", }, pathEnvVar: tempDir); - Assert.Equal(fakePwsh, fileName); + Assert.Equal(ExpectedWindowsPowerShellExe(), fileName); Assert.Contains("-NoProfile -NonInteractive -Command Write-Output hi", arguments); } finally From 7d7386644319ce6b02d510ccda4658e3a2cc7220 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:19:47 +0100 Subject: [PATCH 21/37] fix: bound MXC shell PATH bootstrap --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 36 ++++++++--- .../Mxc/MxcConfigBuilderTests.cs | 59 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 3b615d67d..2773b3a9b 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -11,10 +11,10 @@ namespace OpenClaw.Shared.Mxc; /// /// Translates (from the Sandbox page) and the /// agent's request into the JSON shape wxc-exec consumes. -/// — reconstructs 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. +/// — 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 bootstraps TEMP/TMP/TMPDIR inside the /// launched shell. Explicit process.env injection is intentionally @@ -482,6 +482,7 @@ internal sealed record MxcConfigBuildContext( /// internal static class ShellCommandLine { + private const int MaxShellBootstrapPathChars = 4096; private static readonly string[] CmdBootstrapTempEnvNames = ["TEMP", "TMP", "TMPDIR"]; public static string Build( @@ -492,19 +493,40 @@ public static string Build( IReadOnlyList pathDirs) { var normalized = (shell ?? "cmd").Trim().ToLowerInvariant(); + var bootstrapPathDirs = LimitPathDirsForCommandLine(pathDirs); return normalized switch { - "cmd" => BuildCmd(command, argv, scratchDir, pathDirs), + "cmd" => BuildCmd(command, argv, scratchDir, bootstrapPathDirs), "pwsh" or "powershell" => BuildPowershell( normalized == "pwsh" ? ResolvePwshExe(pathDirs) : ResolveWindowsPowerShellExe(), command, argv, scratchDir, - pathDirs), - _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv, scratchDir, pathDirs), + bootstrapPathDirs), + _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv, scratchDir, bootstrapPathDirs), }; } + 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, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 3d688f971..0d199331b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -730,6 +730,65 @@ public void Build_PowerShellShell_QuotesPathBootstrapValue() } } + [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) From 35b8a431d17a9da28bd60c6874dc8c9afd1af838 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:34:25 +0100 Subject: [PATCH 22/37] fix: keep MXC UI policy operator-controlled --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 19 ------------------- src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 6 +++--- .../Mxc/MxcCommandRunnerTests.cs | 6 +++--- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 86ad3c9f1..af38397bc 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -165,7 +165,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); - policy = ApplyShellRequiredPolicy(policy, effectiveShell); var argsJson = SerializeArgs(request, effectiveShell); // Compute the effective timeout: take the smaller of the agent-supplied @@ -295,24 +294,6 @@ private static string ResolveSandboxShell(string requestedShell) => _ => "powershell", }; - private static SandboxPolicy ApplyShellRequiredPolicy(SandboxPolicy policy, string effectiveShell) - { - if (!MxcShellRequiresWindowsUi(effectiveShell)) - return policy; - - var ui = policy.Ui ?? new UiPolicy(); - return policy with - { - Ui = ui with { AllowWindows = true }, - }; - } - - private static bool MxcShellRequiresWindowsUi(string shell) - { - var normalized = shell.Trim().ToLowerInvariant(); - return normalized is "powershell" or "pwsh"; - } - private string ResolveHostFallbackShell(string? requestedShell) => _hostFallback.ResolveEffectiveShell(requestedShell); diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 93302fdc0..61d260856 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -21,9 +21,9 @@ namespace OpenClaw.Shared.Mxc; /// 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 in base policy. The command runner derives -/// per-shell UI requirements before config emission; PowerShell-family shells -/// need allowWindows on MXC 0.7. +/// 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 diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 7c501c4ec..dd7499614 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -673,13 +673,13 @@ public async Task RunAsync_SandboxRequestUsesNormalizedEffectiveShellForUnsuppor Assert.NotNull(executor.LastRequest); Assert.Equal("powershell", executor.LastRequest!.Args.GetProperty("shell").GetString()); - Assert.True(executor.LastRequest.Policy.Ui!.AllowWindows); + Assert.False(executor.LastRequest.Policy.Ui!.AllowWindows); } [Theory] [InlineData("powershell")] [InlineData("pwsh")] - public async Task RunAsync_SandboxRequestAllowsWindowsForPowerShellFamilyShells(string shell) + public async Task RunAsync_SandboxRequestKeepsUiDeniedForPowerShellFamilyShells(string shell) { var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner(); @@ -688,7 +688,7 @@ public async Task RunAsync_SandboxRequestAllowsWindowsForPowerShellFamilyShells( await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = shell }); Assert.NotNull(executor.LastRequest); - Assert.True(executor.LastRequest!.Policy.Ui!.AllowWindows); + Assert.False(executor.LastRequest!.Policy.Ui!.AllowWindows); } [Fact] From bf1e9b8fa2dbfff2becb6ab35910ffb9eb0d558c Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:05:49 +0100 Subject: [PATCH 23/37] fix: preserve PowerShell fallback compatibility under MXC --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 22 ++++++++ .../Mxc/MxcCommandRunnerTests.cs | 51 +++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index af38397bc..8d7fa8a97 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -228,6 +228,25 @@ public async Task RunAsync(CommandRequest request, CancellationTo } catch (NotSupportedException ex) { + if (IsPowerShellUiUnsupported(ex)) + { + if (settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return DenySandboxUnavailable( + "Sandboxed system.run cannot execute PowerShell-family shells with the current MXC UI-deny policy, " + + "and host fallback is blocked by settings. Retry with shell='cmd', disable strict fallback blocking, " + + "or explicitly disable sandboxing if uncontained host execution is acceptable.", + $"[mxc] system.run denied: PowerShell-family shell unsupported by MXC UI-deny policy and host fallback is blocked by settings: {ex.Message}"); + + _logger.Warn( + $"[mxc] system.run UNCONTAINED: PowerShell-family shell unsupported by MXC UI-deny policy ({ex.Message}); " + + "routing through host runner for compatibility."); + var hostShell = ResolveHostFallbackShell(request.Shell); + if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) + return DenyFallbackShellMismatch(effectiveShell, hostShell); + + return await RunHostFallbackAsync(request, hostShell, ct); + } + _logger.Warn($"[mxc] system.run denied: unsupported sandbox request: {ex.Message}"); return new CommandResult { @@ -297,6 +316,9 @@ private static string ResolveSandboxShell(string requestedShell) => 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 static bool FallbackWouldChangeApprovedShell( CommandRequest request, string approvedShell, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index dd7499614..497611029 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -496,19 +496,64 @@ public async Task RunAsync_GenericException_ReturnsDeny_DoesNotPropagate() } [Fact] - public async Task RunAsync_NotSupportedException_ReturnsExplicitDeny_DoesNotFallBack() + public async Task RunAsync_PowerShellUiUnsupported_FallsBackWhenCompatibilityFallbackEnabled() + { + 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(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); + } + + [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)); + 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("PowerShell-family shells require UI access", result.Stderr); + Assert.Contains("cannot execute PowerShell-family shells", result.Stderr); + Assert.Contains("host fallback is blocked", 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); } From 1ffddf638cacadbbf72e1e050a6aef2f5aa74caf Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:15:17 +0100 Subject: [PATCH 24/37] fix: preserve omitted-shell fallback semantics --- .../Capabilities/SystemCapability.cs | 2 +- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 34 ------------------- .../Mxc/MxcCommandRunnerTests.cs | 9 ++--- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 33 ++++++++++++++++++ 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 06e88fc13..97680b934 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -474,7 +474,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) { Command = command, Args = args, - Shell = effectiveShell, + Shell = string.IsNullOrWhiteSpace(shell) ? null : shell.Trim(), Cwd = cwd, TimeoutMs = timeoutMs, Env = env diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 8d7fa8a97..c2e9220ce 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -142,9 +142,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo "[mxc] system.run UNCONTAINED: custom env is unsupported by MXC processcontainer " + "and MXC is unavailable after re-probe; routing through host runner for compatibility."); var hostShell = ResolveHostFallbackShell(request.Shell); - if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) - return DenyFallbackShellMismatch(effectiveShell, hostShell); - return await RunHostFallbackAsync(request, hostShell, ct); } @@ -215,9 +212,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo $"[mxc] system.run UNCONTAINED: sandbox became unavailable at runtime ({ex.Message}); " + "routing through host runner for compatibility."); var hostShell = ResolveHostFallbackShell(request.Shell); - if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) - return DenyFallbackShellMismatch(effectiveShell, hostShell); - return await RunHostFallbackAsync(request, hostShell, ct); } catch (OperationCanceledException) @@ -241,9 +235,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo $"[mxc] system.run UNCONTAINED: PowerShell-family shell unsupported by MXC UI-deny policy ({ex.Message}); " + "routing through host runner for compatibility."); var hostShell = ResolveHostFallbackShell(request.Shell); - if (FallbackWouldChangeApprovedShell(request, effectiveShell, hostShell)) - return DenyFallbackShellMismatch(effectiveShell, hostShell); - return await RunHostFallbackAsync(request, hostShell, ct); } @@ -319,31 +310,6 @@ private string ResolveHostFallbackShell(string? requestedShell) => private static bool IsPowerShellUiUnsupported(NotSupportedException ex) => ex.Message.Contains("PowerShell-family shells require UI access", StringComparison.OrdinalIgnoreCase); - private static bool FallbackWouldChangeApprovedShell( - CommandRequest request, - string approvedShell, - string hostFallbackShell) => - string.IsNullOrWhiteSpace(request.Shell) - && !string.Equals(approvedShell, hostFallbackShell, StringComparison.OrdinalIgnoreCase); - - 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}'. 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 diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 497611029..c9c227ef4 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -116,7 +116,7 @@ public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailableAndC } [Fact] - public async Task RunAsync_SandboxEnabled_DeniesRuntimeFallbackWhenOmittedShellWouldChangeAfterApproval() + public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToHostDefaultWhenExecutorIsUnavailable() { var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; var fallback = new FakeCommandRunner @@ -132,9 +132,10 @@ public async Task RunAsync_SandboxEnabled_DeniesRuntimeFallbackWhenOmittedShellW var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("pre-approved shell", result.Stderr); - Assert.Null(fallback.LastRequest); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host-ran", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Equal("powershell", fallback.LastRequest!.Shell); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index bd6918369..09af64b90 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -596,6 +596,39 @@ public async Task SystemRun_WithPolicy_NormalizesUnsupportedExplicitShellBeforeA } } + [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" } + } + }, + ExecApprovalAction.Deny); + var runner = new FakeCommandRunner { EffectiveShellForNull = "cmd" }; + 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); + } + [Fact] public async Task SystemRun_WithPromptPolicy_PromptsOnceForShellWrapper_WhenUserApprovesOnce() { From 772c4b0f43ae581be659c83d0ef703b8e8132f98 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:24:55 +0100 Subject: [PATCH 25/37] fix: require approval for MXC host fallback shell --- .../Capabilities/SystemCapability.cs | 120 ++++++++++++------ src/OpenClaw.Shared/ICommandRunner.cs | 20 +++ src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 68 +++++++++- .../Mxc/MxcCommandRunnerTests.cs | 30 ++++- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 52 +++++++- 5 files changed, 242 insertions(+), 48 deletions(-) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 97680b934..70ebbb718 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -420,51 +420,35 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var fullCommand = args != null ? FormatExecCommand([command!, ..args]) : command; - var effectiveShell = _commandRunner.ResolveEffectiveShell(shell); + 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={effectiveShell}, requestedShell={shell ?? "auto"}, timeout={timeoutMs}ms)"); // Check exec approval policy if (_approvalPolicy != null) { - var approval = _approvalPolicy.Evaluate(fullCommand, effectiveShell); - var approvalCheck = await EnsureApprovedAsync(fullCommand, effectiveShell, 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 approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( + fullCommand, + effectiveShell, + sessionKey, + correlationId); + if (approvalError != null) + return approvalError; - var parseResult = ExecShellWrapperParser.Expand(fullCommand, effectiveShell); - if (!string.IsNullOrWhiteSpace(parseResult.Error)) + if (!string.IsNullOrWhiteSpace(approvedHostFallbackShell) + && !string.Equals(approvedHostFallbackShell, effectiveShell, StringComparison.OrdinalIgnoreCase)) { - 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}"); - } + approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( + fullCommand, + approvedHostFallbackShell, + sessionKey, + correlationId); + if (approvalError != null) + return approvalError; } } @@ -474,10 +458,11 @@ private async Task HandleRunAsync(NodeInvokeRequest request) { Command = command, Args = args, - Shell = string.IsNullOrWhiteSpace(shell) ? null : shell.Trim(), + Shell = requestedShell, Cwd = cwd, TimeoutMs = timeoutMs, - Env = env + Env = env, + ApprovedHostFallbackShell = approvedHostFallbackShell }); return Success(new @@ -552,6 +537,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 7597fa5c9..a2109b1b6 100644 --- a/src/OpenClaw.Shared/ICommandRunner.cs +++ b/src/OpenClaw.Shared/ICommandRunner.cs @@ -35,6 +35,13 @@ public class CommandRequest /// Additional environment variables public Dictionary? Env { 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; } } /// @@ -80,3 +87,16 @@ string ResolveEffectiveShell(string? requestedShell) /// 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/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index c2e9220ce..df6404eb7 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -18,7 +18,7 @@ namespace OpenClaw.Shared.Mxc; /// 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"; @@ -64,6 +64,21 @@ public string ResolveEffectiveShell(string? 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(); @@ -141,7 +156,9 @@ public async Task RunAsync(CommandRequest request, CancellationTo _logger.Warn( "[mxc] system.run UNCONTAINED: custom env is unsupported by MXC processcontainer " + "and MXC is unavailable after re-probe; routing through host runner for compatibility."); - var hostShell = ResolveHostFallbackShell(request.Shell); + if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) + return deny!; + return await RunHostFallbackAsync(request, hostShell, ct); } @@ -211,7 +228,9 @@ public async Task RunAsync(CommandRequest request, CancellationTo _logger.Warn( $"[mxc] system.run UNCONTAINED: sandbox became unavailable at runtime ({ex.Message}); " + "routing through host runner for compatibility."); - var hostShell = ResolveHostFallbackShell(request.Shell); + if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) + return deny!; + return await RunHostFallbackAsync(request, hostShell, ct); } catch (OperationCanceledException) @@ -234,7 +253,9 @@ public async Task RunAsync(CommandRequest request, CancellationTo _logger.Warn( $"[mxc] system.run UNCONTAINED: PowerShell-family shell unsupported by MXC UI-deny policy ({ex.Message}); " + "routing through host runner for compatibility."); - var hostShell = ResolveHostFallbackShell(request.Shell); + if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) + return deny!; + return await RunHostFallbackAsync(request, hostShell, ct); } @@ -291,6 +312,7 @@ private Task RunHostFallbackAsync(CommandRequest request, string Cwd = request.Cwd, TimeoutMs = request.TimeoutMs, Env = request.Env, + ApprovedHostFallbackShell = request.ApprovedHostFallbackShell, }; return _hostFallback.RunAsync(fallbackRequest, ct); } @@ -310,6 +332,44 @@ private string ResolveHostFallbackShell(string? requestedShell) => private static bool IsPowerShellUiUnsupported(NotSupportedException ex) => ex.Message.Contains("PowerShell-family shells require UI access", StringComparison.OrdinalIgnoreCase); + 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 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 diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index c9c227ef4..8ef21643b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -116,7 +116,7 @@ public async Task RunAsync_SandboxEnabled_FallsBackWhenExecutorIsUnavailableAndC } [Fact] - public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToHostDefaultWhenExecutorIsUnavailable() + public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToApprovedHostDefaultWhenExecutorIsUnavailable() { var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; var fallback = new FakeCommandRunner @@ -130,7 +130,11 @@ public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToHostDefaultWhen sandboxEnabled: true, blockHostFallbackWhenMxcUnavailable: false)); - var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); + var result = await runner.RunAsync(new CommandRequest + { + Command = "echo hi", + ApprovedHostFallbackShell = "powershell", + }); Assert.Equal(0, result.ExitCode); Assert.Equal("host-ran", result.Stdout); @@ -138,6 +142,28 @@ public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToHostDefaultWhen 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_SandboxDisabled_AlwaysRoutesToHost() { diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index 09af64b90..29dce7f71 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -608,11 +608,15 @@ public async Task SystemRun_PreservesOmittedShellWhenCallingRunner() { Pattern = "echo hi", Action = ExecApprovalAction.Allow, - Shells = new[] { "cmd" } + Shells = new[] { "cmd", "powershell" } } }, ExecApprovalAction.Deny); - var runner = new FakeCommandRunner { EffectiveShellForNull = "cmd" }; + var runner = new FakeCommandRunner + { + EffectiveShellForNull = "cmd", + HostFallbackShellForApproval = "powershell" + }; var cap = new SystemCapability(logger); cap.SetCommandRunner(runner); cap.SetApprovalPolicy(policy); @@ -627,6 +631,44 @@ public async Task SystemRun_PreservesOmittedShellWhenCallingRunner() 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] @@ -822,13 +864,14 @@ 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) { @@ -844,6 +887,9 @@ public string ResolveEffectiveShell(string? requestedShell) }; } + public string? ResolveHostFallbackShellForApproval(string? requestedShell, string effectiveShell) => + HostFallbackShellForApproval; + public Task RunAsync(CommandRequest request, CancellationToken ct = default) { LastRequest = request; From d2d87df5827c25eec1e589a2aaa03d678fa66426 Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:42:34 +0100 Subject: [PATCH 26/37] fix: reject cmd line breaks in MXC command args --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 13 +++++++++++ .../Mxc/MxcConfigBuilderTests.cs | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 2773b3a9b..6a14cf96a 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -533,6 +533,10 @@ private static string BuildCmd( 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. If the payload // references env vars we bootstrap in this same /C line, rewrite just @@ -561,6 +565,15 @@ private static string BuildCmd( return sb.ToString(); } + 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, diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 0d199331b..17252523b 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -606,6 +606,28 @@ public void Build_CmdShell_UsesResolvedCmdExe() Assert.Equal("container", config.AppContainer!.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() { From 1e503cf41ba159244403c2387bcf9b126085bdfb Mon Sep 17 00:00:00 2001 From: TheAngryPit <7040636+TheAngryPit@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:40:50 +0100 Subject: [PATCH 27/37] fix: fail closed on unsupported PowerShell MXC shells --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 18 ++++-------------- .../Mxc/MxcCommandRunnerTests.cs | 10 ++++------ 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index df6404eb7..806bfe97e 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -243,20 +243,10 @@ public async Task RunAsync(CommandRequest request, CancellationTo { if (IsPowerShellUiUnsupported(ex)) { - if (settings.SystemRunBlockHostFallbackWhenMxcUnavailable) - return DenySandboxUnavailable( - "Sandboxed system.run cannot execute PowerShell-family shells with the current MXC UI-deny policy, " + - "and host fallback is blocked by settings. Retry with shell='cmd', disable strict fallback blocking, " + - "or explicitly disable sandboxing if uncontained host execution is acceptable.", - $"[mxc] system.run denied: PowerShell-family shell unsupported by MXC UI-deny policy and host fallback is blocked by settings: {ex.Message}"); - - _logger.Warn( - $"[mxc] system.run UNCONTAINED: PowerShell-family shell unsupported by MXC UI-deny policy ({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); + 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}"); diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 8ef21643b..de53b6f08 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -523,7 +523,7 @@ public async Task RunAsync_GenericException_ReturnsDeny_DoesNotPropagate() } [Fact] - public async Task RunAsync_PowerShellUiUnsupported_FallsBackWhenCompatibilityFallbackEnabled() + public async Task RunAsync_PowerShellUiUnsupported_DeniesEvenWhenCompatibilityFallbackEnabled() { var executor = new FakeSandboxExecutor { @@ -540,10 +540,9 @@ public async Task RunAsync_PowerShellUiUnsupported_FallsBackWhenCompatibilityFal var result = await runner.RunAsync(new CommandRequest { Command = "Write-Output hi", Shell = "powershell" }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); - Assert.NotNull(fallback.LastRequest); - Assert.Equal("powershell", fallback.LastRequest!.Shell); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("cannot execute PowerShell-family shells", result.Stderr); + Assert.Null(fallback.LastRequest); } [Fact] @@ -563,7 +562,6 @@ public async Task RunAsync_PowerShellUiUnsupported_DeniesWhenStrictFallbackBlock Assert.Equal(-1, result.ExitCode); Assert.Contains("cannot execute PowerShell-family shells", result.Stderr); - Assert.Contains("host fallback is blocked", result.Stderr); Assert.Null(fallback.LastRequest); } From c3fe89c751b474436e8ddf5977e7ec6ad7764594 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:16:44 +0100 Subject: [PATCH 28/37] Pin approved MXC shell through execution --- .../Capabilities/SystemCapability.cs | 1 + src/OpenClaw.Shared/ICommandRunner.cs | 7 ++++ src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 37 +++++++++++++++++++ .../OpenClaw.Shared.Tests/CapabilityTests.cs | 21 +++++++++++ .../Mxc/MxcCommandRunnerTests.cs | 24 ++++++++++++ 5 files changed, 90 insertions(+) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 70ebbb718..4e7910937 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -462,6 +462,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) Cwd = cwd, TimeoutMs = timeoutMs, Env = env, + ApprovedEffectiveShell = effectiveShell, ApprovedHostFallbackShell = approvedHostFallbackShell }); diff --git a/src/OpenClaw.Shared/ICommandRunner.cs b/src/OpenClaw.Shared/ICommandRunner.cs index a2109b1b6..78cfbfb76 100644 --- a/src/OpenClaw.Shared/ICommandRunner.cs +++ b/src/OpenClaw.Shared/ICommandRunner.cs @@ -36,6 +36,13 @@ 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 diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 806bfe97e..38a5c3b48 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -83,6 +83,8 @@ public async Task RunAsync(CommandRequest request, CancellationTo { var settings = _settingsProvider(); var effectiveShell = ResolveEffectiveShell(request.Shell); + if (!TryValidateApprovedEffectiveShell(request, effectiveShell, out var approvalDeny)) + return approvalDeny!; if (!settings.SystemRunSandboxEnabled) { @@ -302,6 +304,7 @@ private Task RunHostFallbackAsync(CommandRequest request, string Cwd = request.Cwd, TimeoutMs = request.TimeoutMs, Env = request.Env, + ApprovedEffectiveShell = request.ApprovedEffectiveShell, ApprovedHostFallbackShell = request.ApprovedHostFallbackShell, }; return _hostFallback.RunAsync(fallbackRequest, ct); @@ -322,6 +325,23 @@ private string ResolveHostFallbackShell(string? 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, @@ -342,6 +362,23 @@ private bool TryResolveApprovedHostFallbackShell( 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 DenyFallbackShellMismatch(string approvedShell, string hostFallbackShell) { var message = diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 9ed29633e..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() { diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index de53b6f08..5beff7ad0 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -133,6 +133,7 @@ public async Task RunAsync_SandboxEnabled_OmittedShellFallsBackToApprovedHostDef var result = await runner.RunAsync(new CommandRequest { Command = "echo hi", + ApprovedEffectiveShell = "cmd", ApprovedHostFallbackShell = "powershell", }); @@ -164,6 +165,29 @@ public async Task RunAsync_SandboxEnabled_DeniesOmittedShellFallbackWhenHostDefa 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] public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost() { From 51186f59426bf8caa8f808796c003e39d9da590a Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:33:20 +0100 Subject: [PATCH 29/37] test: isolate gateway client identities --- .../OpenClawGatewayClientTests.cs | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) 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", From f2e071cf5d8436d3890fbcaa06db8f9a2762b5cc Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:13:45 +0100 Subject: [PATCH 30/37] fix: deny sandbox env before host fallback --- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 71 ++++++------------- .../Mxc/MxcCommandRunnerTests.cs | 33 +++------ 2 files changed, 32 insertions(+), 72 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 38a5c3b48..2cff25712 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -111,22 +111,11 @@ public async Task RunAsync(CommandRequest request, CancellationTo }; } + // 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 }) - { - 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, - }; - } + return DenyCustomEnvUnsupported(); if (!_isSandboxAvailable()) { @@ -144,41 +133,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo return await RunHostFallbackAsync(request, effectiveShell, ct); } - if (request.Env is { Count: > 0 }) - { - _invalidateAvailability?.Invoke(); - 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: custom env requires host fallback, but sandbox is unavailable and host fallback is blocked by settings"); - - _logger.Warn( - "[mxc] system.run UNCONTAINED: custom env is unsupported by MXC processcontainer " + - "and MXC is unavailable after re-probe; routing through host runner for compatibility."); - if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) - return deny!; - - return await RunHostFallbackAsync(request, hostShell, ct); - } - - 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, - }; - } - var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request, effectiveShell); @@ -379,6 +333,23 @@ private CommandResult DenyEffectiveShellMismatch(string approvedShell, string ef }; } + 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 = diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 5beff7ad0..fbf8e05a8 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -239,13 +239,10 @@ public async Task RunAsync_SandboxEnabled_RejectsCustomEnvWithoutHostFallback() } [Fact] - public async Task RunAsync_SandboxEnabled_MxcUnavailable_PreservesCustomEnvOnHostFallback() + public async Task RunAsync_SandboxEnabled_MxcUnavailable_RejectsCustomEnvWithoutHostFallback() { var executor = new FakeSandboxExecutor(); - var fallback = new FakeCommandRunner - { - Result = new CommandResult { ExitCode = 0, Stdout = "host" }, - }; + var fallback = new FakeCommandRunner(); var runner = NewRunner( executor, fallback, @@ -260,12 +257,10 @@ public async Task RunAsync_SandboxEnabled_MxcUnavailable_PreservesCustomEnvOnHos Env = new Dictionary { ["FOO"] = "bar" }, }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); Assert.Null(executor.LastRequest); - Assert.NotNull(fallback.LastRequest); - Assert.NotNull(fallback.LastRequest!.Env); - Assert.Equal("bar", fallback.LastRequest.Env["FOO"]); + Assert.Null(fallback.LastRequest); } [Fact] @@ -458,13 +453,10 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa } [Fact] - public async Task RunAsync_CustomEnv_ReprobesAvailabilityAndFallsBackWhenMxcBecameUnavailable() + public async Task RunAsync_CustomEnv_RejectsBeforeReprobeOrHostFallback() { var executor = new FakeSandboxExecutor(); - var fallback = new FakeCommandRunner - { - Result = new CommandResult { ExitCode = 0, Stdout = "host" }, - }; + var fallback = new FakeCommandRunner(); var sandboxAvailable = true; var invalidationCount = 0; var runner = new MxcCommandRunner( @@ -489,14 +481,11 @@ public async Task RunAsync_CustomEnv_ReprobesAvailabilityAndFallsBackWhenMxcBeca Env = new Dictionary { ["FOO"] = "bar" }, }); - Assert.Equal(0, result.ExitCode); - Assert.Equal("host", result.Stdout); - Assert.Equal(1, invalidationCount); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("custom environment variables", result.Stderr); + Assert.Equal(0, invalidationCount); Assert.Null(executor.LastRequest); - Assert.NotNull(fallback.LastRequest); - Assert.Equal("powershell", fallback.LastRequest!.Shell); - Assert.NotNull(fallback.LastRequest.Env); - Assert.Equal("bar", fallback.LastRequest.Env["FOO"]); + Assert.Null(fallback.LastRequest); } [Fact] From 1ad95fd201ca5aa744967dec08ec42cb6e54d8b6 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:11:46 +0100 Subject: [PATCH 31/37] test: prove MXC env denial before fallback --- .../Mxc/MxcCommandRunnerIntegrationTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index 7efbf37e7..ffb35f0db 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -247,6 +247,48 @@ public async Task SystemRun_FilesystemAccessMatrix_EnforcesReadwriteAndReadonlyP } } + [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 @@ -301,4 +343,19 @@ private static void AssertMatrix( } 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."); + } + } } From 980ef5c31419f059655ac253e689809c565a3d67 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:27:32 +0100 Subject: [PATCH 32/37] fix: harden MXC processcontainer execution edge cases --- .../Mxc/DirectAppContainerExecutor.cs | 2 +- src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 39 ++++++++++--------- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 21 ++++++++-- .../Mxc/MxcConfigBuilderTests.cs | 25 ++++++++---- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 2 +- 5 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs index b44764b8c..d92640923 100644 --- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs +++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs @@ -246,7 +246,7 @@ internal static string BuildRedactedConfigSummary( $"cwd={(string.IsNullOrEmpty(config.Process.Cwd) ? "" : "")}; " + $"envKeys=[{string.Join(",", envKeys)}]; " + $"timeoutMs={config.Process.TimeoutMs?.ToString() ?? ""}; " + - $"capabilities=[{string.Join(",", config.AppContainer?.Capabilities ?? Array.Empty())}]; " + + $"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}; " + diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 2cff25712..f481c5342 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -92,25 +92,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo return await RunHostFallbackAsync(request, effectiveShell, ct); } - // A direct-argv request reaching the sandbox cannot be honored: the sandbox - // protocol only carries the legacy command/shell/args fields, so serializing - // would silently run something other than the approved argv. Fail closed until - // the sandbox transport carries argv faithfully. The host-fallback branches - // above keep working because the host runner does honor Argv. - if (request.Argv is not null) - { - _logger.Warn("[mxc] system.run BLOCKED: direct-argv request reached the sandbox, " + - "which has no argv transport yet. Failing closed rather than running the legacy fields."); - return new CommandResult - { - Stdout = string.Empty, - Stderr = "Sandboxed system.run cannot execute a direct-argv command yet.", - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - }; - } - // 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. @@ -133,6 +114,25 @@ public async Task RunAsync(CommandRequest request, CancellationTo return await RunHostFallbackAsync(request, effectiveShell, ct); } + // A direct-argv request reaching the sandbox cannot be honored: the sandbox + // protocol only carries the legacy command/shell/args fields, so serializing + // would silently run something other than the approved argv. Fail closed until + // the sandbox transport carries argv faithfully. The host-fallback branches + // above keep working because the host runner does honor Argv. + if (request.Argv is not null) + { + _logger.Warn("[mxc] system.run BLOCKED: direct-argv request reached the sandbox, " + + "which has no argv transport yet. Failing closed rather than running the legacy fields."); + return new CommandResult + { + Stdout = string.Empty, + Stderr = "Sandboxed system.run cannot execute a direct-argv command yet.", + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request, effectiveShell); @@ -254,6 +254,7 @@ private Task RunHostFallbackAsync(CommandRequest request, string { Command = request.Command, Args = request.Args, + Argv = request.Argv, Shell = effectiveShell, Cwd = request.Cwd, TimeoutMs = request.TimeoutMs, diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 6a14cf96a..54785da4b 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -67,7 +67,8 @@ internal static MxcConfig Build( var policy = request.Policy; var args = ParseSystemRunArgs(request.Args); - if (IsPowerShellFamilyShell(args.Shell) && policy?.Ui?.AllowWindows != true) + 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."); @@ -94,7 +95,7 @@ internal static MxcConfig Build( // 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(args.Shell, args.Command, args.Argv, scratchDir, pathDirs); + var commandLine = ShellCommandLine.Build(shell, args.Command, args.Argv, scratchDir, pathDirs); var allowWindows = policy?.Ui?.AllowWindows == true; // readwrite = UI grants + scratch dir. @@ -463,6 +464,19 @@ private static bool IsPowerShellFamilyShell(string shell) 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); } @@ -503,7 +517,8 @@ public static string Build( argv, scratchDir, bootstrapPathDirs), - _ => BuildPowershell(ResolveWindowsPowerShellExe(), command, argv, scratchDir, bootstrapPathDirs), + _ => throw new NotSupportedException( + $"Unsupported shell '{shell}' for the Windows MXC 0.7 processcontainer backend."), }; } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 17252523b..0a598989a 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -203,7 +203,7 @@ public void Build_OutboundOn_AddsInternetClientCapability() { var policy = BalancedPolicy(); var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); - Assert.Contains("internetClient", config.AppContainer!.Capabilities!); + Assert.Contains("internetClient", config.ProcessContainer!.Capabilities!); Assert.Equal("allow", config.Network!.DefaultPolicy); } @@ -212,7 +212,7 @@ public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks() { var policy = LockedDownPolicy(); var config = BuildConfig(RequestFor(policy), pathEnvVar: ""); - Assert.DoesNotContain("internetClient", config.AppContainer!.Capabilities!); + Assert.DoesNotContain("internetClient", config.ProcessContainer!.Capabilities!); Assert.Equal("block", config.Network!.DefaultPolicy); } @@ -559,7 +559,7 @@ public void Build_DefaultShell_UsesCmdAndPreservesUiDeny() 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.AppContainer!.Ui!.Isolation); + Assert.Equal("container", config.ProcessContainer!.Ui!.Isolation); } [Fact] @@ -575,7 +575,7 @@ public void Build_PowerShellShell_WhenPolicyAllowsWindows_EnablesDesktopIsolatio var config = BuildConfig(request, pathEnvVar: ""); Assert.False(config.Ui!.Disable); - Assert.Equal("desktop", config.AppContainer!.Ui!.Isolation); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); } [Theory] @@ -591,6 +591,17 @@ public void Build_PowerShellFamilyShell_WhenUiDenied_FailsClosed(string shell) 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() { @@ -603,7 +614,7 @@ public void Build_CmdShell_UsesResolvedCmdExe() 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.AppContainer!.Ui!.Isolation); + Assert.Equal("container", config.ProcessContainer!.Ui!.Isolation); } [Fact] @@ -664,7 +675,7 @@ public void Build_PwshShell_WhenPolicyAllowsWindows_UsesPwshAndEnablesDesktopIso 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.AppContainer!.Ui!.Isolation); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); } [Fact] @@ -720,7 +731,7 @@ public void Build_PowerShellShell_UsesResolvedWindowsPowerShellExe() 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.AppContainer!.Ui!.Isolation); + Assert.Equal("desktop", config.ProcessContainer!.Ui!.Isolation); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs index 29dce7f71..38d9c31d7 100644 --- a/tests/OpenClaw.Shared.Tests/SystemRunTests.cs +++ b/tests/OpenClaw.Shared.Tests/SystemRunTests.cs @@ -1271,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); } From 531167c40e416982b27a1258eb66700f2e0e4536 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:46:06 +0100 Subject: [PATCH 33/37] fix: preserve incremental MXC helper restore --- src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 24277954f..c8f29ad05 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -88,8 +88,6 @@ x64 arm64 x64 - 0.7.0 - $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\package.json $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\bin\$(MxcArch)\ 0.7.0 $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\package.json From 378786179514f38ae842424b2479e7c7f1ec7ebe Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:51:39 +0100 Subject: [PATCH 34/37] test: align MXC installer assertion with incremental restore --- tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index df9d44d9c..cf869e263 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -140,17 +140,19 @@ 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("0.7.0", trayProject); - Assert.Contains("MxcSdkInstalledVersion", trayProject); + 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(@"Outputs=""$(OpenClawRepoRoot)node_modules\.package-lock.json""", trayProject); Assert.Contains("npm ci --no-audit --no-fund", trayProject); - Assert.Contains("'$(MxcSdkInstalledVersion)' != '$(MxcSdkExpectedVersion)'", trayProject); Assert.Contains("CopyWxcExecToOutput", trayProject); Assert.Contains("CopyWxcExecToPublish", trayProject); Assert.Contains("ValidateWxcExecShipped", trayProject); From 83e752b091cd10b5db8561b3aff452f2f4f219e2 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:12:54 +0100 Subject: [PATCH 35/37] test: drop obsolete MXC build gate assertions --- .../Mxc/MxcAvailabilityTests.cs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs index 5751f0008..ffb72188f 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs @@ -346,28 +346,4 @@ public void Probe_WhenProbeReportsDaclTier_ReportsDegraded() } } - [Theory] - [InlineData(26200, 9999, "Windows build 26200 is not an MXC isolation_session supported build")] - [InlineData(26300, 8552, "Windows UBR 8552 below MXC isolation_session minimum 8553")] - [InlineData(26301, 9999, "Windows build 26301 is not an MXC isolation_session supported build")] - public void GetIsolationSessionUnsupportedReason_RejectsUnsupportedBuilds( - int build, - int ubr, - string expectedReason) - { - var reason = MxcAvailability.GetIsolationSessionUnsupportedReason(build, ubr); - - Assert.NotNull(reason); - Assert.Contains(expectedReason, reason); - } - - [Theory] - [InlineData(26300, 8553)] - [InlineData(26300, 9999)] - public void GetIsolationSessionUnsupportedReason_AllowsSdkSupportedBuilds(int build, int ubr) - { - var reason = MxcAvailability.GetIsolationSessionUnsupportedReason(build, ubr); - - Assert.Null(reason); - } } From 7b0b95fc818e07309291f09c96605c2aa6e6682b Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:25:45 +0100 Subject: [PATCH 36/37] fix: filter MXC readonly grants by DACL readiness --- src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 60 ++++++++++++++++++- .../Mxc/MxcConfigBuilderTests.cs | 34 ++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 54785da4b..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; @@ -64,6 +66,7 @@ internal static MxcConfig Build( 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); @@ -88,7 +91,7 @@ internal static MxcConfig Build( var pathDirs = ResolvePathDirsForShellPath(context.PathEnvVar); foreach (var dir in pathDirs) { - if (!IsBackendSafeReadonlyGrant(dir)) continue; + if (!readonlyGrantIsBackendSafe(dir)) continue; if (!roFromPolicy.Contains(dir, StringComparer.OrdinalIgnoreCase)) roFromPolicy.Add(dir); } @@ -254,9 +257,61 @@ private static bool IsBackendSafeReadonlyGrant(string dir) { if (IsDriveRoot(dir)) return false; if (IsProtectedSystemPath(dir)) return false; + if (!CanMxcDaclFallbackPreparePath(dir)) return false; return true; } + private static bool CanMxcDaclFallbackPreparePath(string dir) + { + 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 (rule.AccessControlType == AccessControlType.Allow) + allowed = true; + } + + return allowed; + } + catch + { + return false; + } + } + private static bool IsProtectedSystemPath(string dir) { if (!OperatingSystem.IsWindows()) @@ -483,7 +538,8 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList< internal sealed record MxcConfigBuildContext( string? ContainerId = null, string? PathEnvVar = null, - Func? DeniedPathExists = null) + Func? DeniedPathExists = null, + Func? ReadonlyGrantIsBackendSafe = null) { public static MxcConfigBuildContext Default { get; } = new(); } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs index 0a598989a..1750de1f5 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -99,14 +99,16 @@ private static MxcConfig BuildConfig( string scratchDir = P.Scratch, string? containerId = null, string? pathEnvVar = "", - Func? deniedPathExists = null) => + Func? deniedPathExists = null, + Func? readonlyGrantIsBackendSafe = null) => MxcConfigBuilder.Build( request, scratchDir, new MxcConfigBuildContext( ContainerId: containerId, PathEnvVar: pathEnvVar, - DeniedPathExists: deniedPathExists ?? DeniedPathExists)); + DeniedPathExists: deniedPathExists ?? DeniedPathExists, + ReadonlyGrantIsBackendSafe: readonlyGrantIsBackendSafe)); private static string ExpectedSystemCmdExe() { @@ -396,6 +398,34 @@ public void Build_BootstrapsShellPathAndGrantsBackendSafePathDirsReadonly() } } + [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() { From 442de02ab13dc1cf0ecfc1356205f592f18a5552 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:39:23 +0100 Subject: [PATCH 37/37] test: align MXC restore stamp assertion --- tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index cf869e263..a777cb4b2 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -151,7 +151,9 @@ public void MxcSdk_IsRestoredCopiedValidatedAndIncludedInInstallerPayload() Assert.Contains(@"""version"": ""0.7.0""", packageLock); Assert.Contains("RestoreMxcNodeBridge", trayProject); Assert.Contains(@"Inputs=""$(OpenClawRepoRoot)package-lock.json""", trayProject); - Assert.Contains(@"Outputs=""$(OpenClawRepoRoot)node_modules\.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);