Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b4292d8
Support MXC SDK 0.7 on Windows 26200
TheAngryPit Jun 18, 2026
41fdf7b
Emit explicit empty MXC process environment
TheAngryPit Jun 18, 2026
90a4d3c
Harden MXC system run behavior
TheAngryPit Jun 18, 2026
0e745d0
fix: keep MXC sandbox UI contained by default
TheAngryPit Jun 18, 2026
8966b6f
fix: preserve MXC fallback compatibility
TheAngryPit Jun 19, 2026
4e3d663
test: avoid process-wide PATH mutation
TheAngryPit Jun 19, 2026
c76409e
fix: harden MXC policy path grants
TheAngryPit Jun 19, 2026
4676dc8
fix: keep MXC fallback shell approvals aligned
TheAngryPit Jun 19, 2026
580eb91
fix: make MXC sandbox fallback fail closed by default
TheAngryPit Jun 19, 2026
e5df21b
fix: preserve MXC host fallback by default
TheAngryPit Jun 20, 2026
9e36c56
fix: preserve cmd bootstrap env expansion
TheAngryPit Jun 20, 2026
f85c5e2
refactor: keep MXC config test knobs internal
TheAngryPit Jun 20, 2026
67553c7
fix: harden MXC diagnostics and cmd resolution
TheAngryPit Jun 20, 2026
95d9d47
fix: normalize unsupported system.run shells
TheAngryPit Jun 20, 2026
58ab459
fix: align system run prepare diagnostics
TheAngryPit Jun 20, 2026
ac67152
fix: fail closed on unsupported PowerShell MXC shells
TheAngryPit Jun 20, 2026
822173f
fix: preserve PowerShell MXC sandbox execution
TheAngryPit Jun 20, 2026
cdddc68
fix: align PowerShell MXC UI policy
TheAngryPit Jun 20, 2026
f3c5332
test: add MXC filesystem access matrix
TheAngryPit Jun 20, 2026
2f83306
fix: preserve PR2 compatibility gates
TheAngryPit Jun 20, 2026
7d73866
fix: bound MXC shell PATH bootstrap
TheAngryPit Jun 20, 2026
35b8a43
fix: keep MXC UI policy operator-controlled
TheAngryPit Jun 20, 2026
bf1e9b8
fix: preserve PowerShell fallback compatibility under MXC
TheAngryPit Jun 20, 2026
1ffddf6
fix: preserve omitted-shell fallback semantics
TheAngryPit Jun 20, 2026
772c4b0
fix: require approval for MXC host fallback shell
TheAngryPit Jun 20, 2026
d2d87df
fix: reject cmd line breaks in MXC command args
TheAngryPit Jun 20, 2026
1e503cf
fix: fail closed on unsupported PowerShell MXC shells
TheAngryPit Jun 20, 2026
c3fe89c
Pin approved MXC shell through execution
TheAngryPit Jun 21, 2026
51186f5
test: isolate gateway client identities
TheAngryPit Jun 21, 2026
f2e071c
fix: deny sandbox env before host fallback
TheAngryPit Jun 21, 2026
1ad95fd
test: prove MXC env denial before fallback
TheAngryPit Jun 21, 2026
980ef5c
fix: harden MXC processcontainer execution edge cases
TheAngryPit Jun 24, 2026
531167c
fix: preserve incremental MXC helper restore
TheAngryPit Jun 24, 2026
3787861
test: align MXC installer assertion with incremental restore
TheAngryPit Jun 24, 2026
83e752b
test: drop obsolete MXC build gate assertions
TheAngryPit Jun 25, 2026
7b0b95f
fix: filter MXC readonly grants by DACL readiness
TheAngryPit Jun 25, 2026
442de02
test: align MXC restore stamp assertion
TheAngryPit Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 104 additions & 40 deletions src/OpenClaw.Shared/Capabilities/SystemCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
{
Expand Down Expand Up @@ -400,50 +420,35 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
var fullCommand = args != null
? FormatExecCommand([command!, ..args])
: command;
var requestedShell = string.IsNullOrWhiteSpace(shell) ? null : shell.Trim();
var effectiveShell = _commandRunner.ResolveEffectiveShell(requestedShell);
var approvedHostFallbackShell = _commandRunner is IHostFallbackAwareCommandRunner fallbackAwareRunner
? fallbackAwareRunner.ResolveHostFallbackShellForApproval(requestedShell, effectiveShell)
: null;

Logger.Info($"system.run: {fullCommand} (shell={shell ?? "auto"}, timeout={timeoutMs}ms)");
Logger.Info($"system.run: {fullCommand} (shell={effectiveShell}, requestedShell={shell ?? "auto"}, timeout={timeoutMs}ms)");

// Check exec approval policy
if (_approvalPolicy != null)
{
var approval = _approvalPolicy.Evaluate(fullCommand, shell);
var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId);
if (!approvalCheck.Allowed)
{
Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})");
return Error($"Command denied by exec policy: {approval.Reason}");
}

var outerApprovalCoversNestedTargets =
approvalCheck.PromptDecisionKind != null ||
IsExactAllowRuleForCommand(approval, fullCommand);

var parseResult = ExecShellWrapperParser.Expand(fullCommand, shell);
if (!string.IsNullOrWhiteSpace(parseResult.Error))
{
Logger.Warn($"system.run DENIED: {fullCommand} ({parseResult.Error})");
return Error($"Command denied by exec policy: {parseResult.Error}");
}
var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
effectiveShell,
sessionKey,
correlationId);
if (approvalError != null)
return approvalError;

foreach (var target in parseResult.Targets)
if (!string.IsNullOrWhiteSpace(approvedHostFallbackShell)
&& !string.Equals(approvedHostFallbackShell, effectiveShell, StringComparison.OrdinalIgnoreCase))
{
var innerApproval = _approvalPolicy.Evaluate(target.Command, target.Shell);
if (outerApprovalCoversNestedTargets && !IsExplicitDeny(innerApproval))
{
if (!innerApproval.Allowed)
{
Logger.Info(
$"system.run nested approval covered by approved wrapper: {target.Command} ({innerApproval.Reason})");
}
continue;
}

var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval, sessionKey, correlationId);
if (!innerApprovalCheck.Allowed)
{
Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})");
return Error($"Command denied by exec policy: {innerApproval.Reason}");
}
approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
approvedHostFallbackShell,
sessionKey,
correlationId);
if (approvalError != null)
return approvalError;
}
}

Expand All @@ -453,10 +458,12 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
{
Command = command,
Args = args,
Shell = shell,
Shell = requestedShell,
Cwd = cwd,
TimeoutMs = timeoutMs,
Env = env
Env = env,
ApprovedEffectiveShell = effectiveShell,
ApprovedHostFallbackShell = approvedHostFallbackShell
});

return Success(new
Expand Down Expand Up @@ -531,6 +538,63 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
return new ExecApprovalCheckResult(true, decision.Kind);
}

private async Task<NodeInvokeResponse?> 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;
Expand Down
46 changes: 46 additions & 0 deletions src/OpenClaw.Shared/ICommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ public class CommandRequest

/// <summary>Additional environment variables</summary>
public Dictionary<string, string>? Env { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? ApprovedEffectiveShell { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? ApprovedHostFallbackShell { get; set; }
}

/// <summary>
Expand All @@ -57,7 +71,39 @@ public interface ICommandRunner
{
/// <summary>Human-readable name of this runner (e.g., "local", "docker", "wsl")</summary>
string Name { get; }

/// <summary>
/// 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.
/// </summary>
string ResolveEffectiveShell(string? requestedShell)
{
if (string.IsNullOrWhiteSpace(requestedShell))
return "powershell";

return requestedShell.Trim().ToLowerInvariant() switch
{
"cmd" => "cmd",
"pwsh" => "pwsh",
"powershell" => "powershell",
_ => "powershell",
};
}

/// <summary>Execute a command and return the result.</summary>
Task<CommandResult> RunAsync(CommandRequest request, CancellationToken ct = default);
}

/// <summary>
/// Optional contract for runners that may preserve compatibility through an
/// uncontained host fallback with a shell different from their sandbox shell.
/// </summary>
public interface IHostFallbackAwareCommandRunner : ICommandRunner
{
/// <summary>
/// Returns the host fallback shell that needs separate approval, or null when
/// fallback cannot change the already-approved effective shell.
/// </summary>
string? ResolveHostFallbackShellForApproval(string? requestedShell, string effectiveShell);
}
71 changes: 66 additions & 5 deletions src/OpenClaw.Shared/LocalCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
namespace OpenClaw.Shared;

/// <summary>
/// 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.
/// </summary>
public class LocalCommandRunner : ICommandRunner
Expand All @@ -22,6 +22,8 @@ public LocalCommandRunner(IOpenClawLogger? logger = null)
{
_logger = logger ?? NullLogger.Instance;
}

public string ResolveEffectiveShell(string? requestedShell) => ResolveEffectiveShellName(requestedShell);

public async Task<CommandResult> RunAsync(CommandRequest request, CancellationToken ct = default)
{
Expand Down Expand Up @@ -232,9 +234,10 @@ private static void ValidateDirectExecutable(string executable)
$"Direct-argv mode cannot guarantee argv fidelity for batch scripts: {executable}", nameof(executable));
}

private static (string fileName, string arguments) BuildProcessArgs(CommandRequest request)
internal static (string fileName, string arguments) BuildProcessArgs(CommandRequest request, string? pathEnvVar = null)
{
var shell = request.Shell ?? "powershell";
var defaultShell = string.IsNullOrWhiteSpace(request.Shell);
var shell = ResolveEffectiveShellName(request.Shell, pathEnvVar);
var command = request.Command;
var isCmd = shell.Equals("cmd", StringComparison.OrdinalIgnoreCase);

Expand All @@ -249,8 +252,66 @@ private static (string fileName, string arguments) BuildProcessArgs(CommandReque
if (isCmd)
return ("cmd.exe", $"/C {command}");
if (shell.Equals("pwsh", StringComparison.OrdinalIgnoreCase))
return ("pwsh.exe", $"-NoProfile -NonInteractive -Command {command}");
return ("powershell.exe", $"-NoProfile -NonInteractive -Command {command}");
{
var pwshPath = ResolveOnPath("pwsh.exe", pathEnvVar);
if (pwshPath is not null || !defaultShell)
return (pwshPath ?? "pwsh.exe", $"-NoProfile -NonInteractive -Command {command}");
}

return (ResolveWindowsPowerShellExe(), $"-NoProfile -NonInteractive -Command {command}");
}

internal static string ResolveEffectiveShellName(string? requestedShell)
=> ResolveEffectiveShellName(requestedShell, pathEnvVar: null);

private static string ResolveEffectiveShellName(string? requestedShell, string? pathEnvVar)
{
if (!string.IsNullOrWhiteSpace(requestedShell))
{
return requestedShell.Trim().ToLowerInvariant() switch
{
"cmd" => "cmd",
"pwsh" => "pwsh",
"powershell" => "powershell",
_ => "powershell",
};
}

return "powershell";
}

private static string? ResolveOnPath(string executableName, string? pathEnvVar = null)
{
var path = pathEnvVar
?? Environment.GetEnvironmentVariable("PATH")
?? Environment.GetEnvironmentVariable("Path");
if (string.IsNullOrWhiteSpace(path))
return null;

foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
try
{
var candidate = Path.Combine(dir, executableName);
if (File.Exists(candidate))
return candidate;
}
catch
{
// Ignore malformed PATH entries.
}
}

return null;
}

private static string ResolveWindowsPowerShellExe()
{
var systemRoot = Environment.GetEnvironmentVariable("SystemRoot")
?? Environment.GetEnvironmentVariable("windir");
return string.IsNullOrWhiteSpace(systemRoot)
? "powershell.exe"
: Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}

/// <summary>
Expand Down
Loading
Loading