diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 3a5d31918..9df8b93e0 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -275,6 +275,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) var command = argv[0]; var rawCommand = GetStringArg(request.Args, "rawCommand"); + var commandPreview = GetStringArg(request.Args, "commandPreview"); var requestedShell = GetStringArg(request.Args, "shell"); var effectiveShell = _commandRunner?.ResolveEffectiveShell(requestedShell) ?? ResolveDefaultEffectiveShell(requestedShell); @@ -293,6 +294,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) argv, cwd, rawCommand, + commandPreview, requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(), effectiveShell, agentId, @@ -377,6 +379,8 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var shell = GetStringArg(request.Args, "shell"); var cwd = GetStringArg(request.Args, "cwd"); + var commandPreview = GetStringArg(request.Args, "commandPreview") + ?? GetNestedStringArg(request.Args, "systemRunPlan", "commandPreview"); var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); var timeoutMs = GetIntArg(request.Args, "timeoutMs", GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs)); @@ -434,6 +438,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( fullCommand, effectiveShell, + commandPreview, sessionKey, correlationId); if (approvalError != null) @@ -445,6 +450,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( fullCommand, approvedHostFallbackShell, + commandPreview, sessionKey, correlationId); if (approvalError != null) @@ -486,6 +492,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) private async Task EnsureApprovedAsync( string command, string? shell, + string? commandPreview, ExecApprovalResult approval, string? sessionKey, string correlationId, @@ -502,7 +509,9 @@ private async Task EnsureApprovedAsync( var decision = await _promptHandler.RequestAsync(new ExecApprovalPromptRequest { - Command = command, + Command = ExecApprovalPromptText.BuildLegacyDisplayCommand(command, commandPreview), + TechnicalCommand = command, + CommandPreview = commandPreview, Shell = shell, MatchedPattern = approval.MatchedPattern, Reason = approval.Reason ?? "Command requires approval", @@ -542,6 +551,7 @@ private async Task EnsureApprovedAsync( private async Task EnsureCommandAndNestedTargetsApprovedAsync( string fullCommand, string? shell, + string? commandPreview, string? sessionKey, string correlationId) { @@ -566,7 +576,13 @@ private async Task EnsureApprovedAsync( var approvalCheck = new ExecApprovalCheckResult(false, null); if (evaluateOuter) { - approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId); + approvalCheck = await EnsureApprovedAsync( + fullCommand, + shell, + commandPreview, + approval, + sessionKey, + correlationId); if (!approvalCheck.Allowed) { Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); @@ -597,6 +613,7 @@ private async Task EnsureApprovedAsync( var innerApprovalCheck = await EnsureApprovedAsync( target.Command, target.Shell, + commandPreview, innerApproval, sessionKey, correlationId); @@ -623,6 +640,24 @@ private static bool CanPersistExactAllowRule(string command) => !string.IsNullOrWhiteSpace(command) && command.IndexOfAny(['*', '?']) < 0; + private static string? GetNestedStringArg( + System.Text.Json.JsonElement args, + string objectName, + string propertyName) + { + if (args.ValueKind != System.Text.Json.JsonValueKind.Object || + !args.TryGetProperty(objectName, out var nested) || + nested.ValueKind != System.Text.Json.JsonValueKind.Object || + !nested.TryGetProperty(propertyName, out var value) || + value.ValueKind != System.Text.Json.JsonValueKind.String) + { + return null; + } + + var text = value.GetString(); + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } + private static bool IsExactAllowRuleForCommand(ExecApprovalResult approval, string command) => approval.Allowed && approval.Action == ExecApprovalAction.Allow && diff --git a/src/OpenClaw.Shared/ExecApprovalPrompt.cs b/src/OpenClaw.Shared/ExecApprovalPrompt.cs index 067312382..94d4e515a 100644 --- a/src/OpenClaw.Shared/ExecApprovalPrompt.cs +++ b/src/OpenClaw.Shared/ExecApprovalPrompt.cs @@ -14,6 +14,17 @@ public enum ExecApprovalPromptDecisionKind public sealed class ExecApprovalPromptRequest { public string Command { get; init; } = ""; + /// + /// Exact command when has been formatted for a + /// legacy presenter that does not understand . + /// + public string? TechnicalCommand { get; init; } + /// + /// Optional human-readable summary supplied through OpenClaw's canonical + /// commandPreview field. This is presentation context, not a policy + /// decision; the exact command remains authoritative and must stay visible. + /// + public string? CommandPreview { get; init; } public string? Shell { get; init; } public string? MatchedPattern { get; init; } public string Reason { get; init; } = ""; diff --git a/src/OpenClaw.Shared/ExecApprovalPromptText.cs b/src/OpenClaw.Shared/ExecApprovalPromptText.cs new file mode 100644 index 000000000..d1a9ab0ae --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovalPromptText.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; + +namespace OpenClaw.Shared; + +/// +/// Builds the native exec-approval explanation. The human-readable preview is +/// explicitly labelled as agent-supplied context; the exact command remains +/// visible and host policy remains authoritative. +/// +internal static class ExecApprovalPromptText +{ + internal static string Build( + ExecApprovalPromptRequest request, + bool german, + string displayName) + { + var command = Sanitize(request.TechnicalCommand ?? request.Command, 4_000); + var preview = Sanitize(request.CommandPreview, 1_200); + var reason = Sanitize(request.Reason, 400); + var shell = Sanitize(request.Shell, 80); + + if (german) + { + var summary = string.IsNullOrWhiteSpace(preview) + ? "Otti hat keine verständliche Beschreibung mitgesendet. Wenn du unsicher bist, lehne ab und lass die Anfrage neu formulieren." + : preview; + return + "Otti möchte etwas auf diesem Windows-PC ausführen.\r\n\r\n" + + "Worum es geht (von Otti beschrieben):\r\n" + + summary + + "\r\n\r\n" + + "Sicherheitsgrenze: Policy und Sandbox bleiben aktiv. Diese Beschreibung ersetzt nicht die technische Prüfung durch den Hub.\r\n\r\n" + + "Technische Details:\r\n" + + (string.IsNullOrWhiteSpace(command) ? "(kein Befehl angegeben)" : command) + + "\r\n" + + $"Shell: {(string.IsNullOrWhiteSpace(shell) ? "automatisch" : shell)}" + + "\r\n" + + $"Policy: {(string.IsNullOrWhiteSpace(reason) ? "Freigabe erforderlich" : reason)}"; + } + + var englishSummary = string.IsNullOrWhiteSpace(preview) + ? "The agent did not include a plain-language description. Deny if unsure and ask it to retry with a clearer summary." + : preview; + return + $"{displayName} needs approval before a remote agent can run something on this Windows machine.\r\n\r\n" + + "What this is for (described by the agent):\r\n" + + englishSummary + + "\r\n\r\n" + + "Security boundary: policy and sandbox enforcement remain active. This description does not replace the Hub's technical checks.\r\n\r\n" + + "Technical details:\r\n" + + (string.IsNullOrWhiteSpace(command) ? "(no command supplied)" : command) + + "\r\n" + + $"Shell: {(string.IsNullOrWhiteSpace(shell) ? "auto" : shell)}" + + "\r\n" + + $"Policy: {(string.IsNullOrWhiteSpace(reason) ? "Approval required" : reason)}"; + } + + /// + /// Compatibility bridge for installed presenters that only render the + /// legacy Command property. New presenters use CommandPreview and + /// TechnicalCommand separately. + /// + internal static string BuildLegacyDisplayCommand(string command, string? preview) + { + var safeCommand = Sanitize(command, 4_000); + var safePreview = Sanitize(preview, 1_200); + if (string.IsNullOrWhiteSpace(safePreview)) + return safeCommand; + + return safePreview + "\r\n\r\nTechnische Details:\r\n" + safeCommand; + } + + private static string Sanitize(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + var safe = new string(value + .Where(IsSafeDisplayCharacter) + .ToArray()) + .Trim(); + return safe.Length <= maxLength ? safe : safe[..(maxLength - 1)] + "…"; + } + + private static bool IsSafeDisplayCharacter(char ch) + { + if (ch == '\r' || ch == '\n' || ch == '\t') + return true; + return !char.IsControl(ch) && !IsBidirectionalControl(ch); + } + + private static bool IsBidirectionalControl(char ch) => + ch == '\u061C' || ch == '\u200E' || ch == '\u200F' || + (ch >= '\u202A' && ch <= '\u202E') || + (ch >= '\u2066' && ch <= '\u2069'); +} diff --git a/src/OpenClaw.Tray.WinUI/Services/ExecApprovalPromptService.cs b/src/OpenClaw.Tray.WinUI/Services/ExecApprovalPromptService.cs index 19bf14f76..93b5cfa99 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ExecApprovalPromptService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ExecApprovalPromptService.cs @@ -3,6 +3,7 @@ using OpenClaw.Shared; using System; using System.Collections.Generic; +using System.Globalization; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -15,7 +16,11 @@ public sealed class ExecApprovalPromptService : IExecApprovalPromptHandler private readonly IOpenClawLogger _logger; private readonly Func? _chatProviderProvider; private readonly Func? _inlineApprovalAvailable; - private static string NativePromptTitle => $"{AppIdentity.DisplayName} - Approve local command?"; + private static bool UseGerman => + string.Equals(CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, "de", StringComparison.OrdinalIgnoreCase); + private static string NativePromptTitle => UseGerman + ? $"{AppIdentity.DisplayName} – Zugriff freigeben?" + : $"{AppIdentity.DisplayName} - Approve local command?"; public ExecApprovalPromptService( DispatcherQueue dispatcherQueue, @@ -161,14 +166,7 @@ private void RaiseDecided( private static ExecApprovalPromptDecision ShowNativePrompt(ExecApprovalPromptRequest request) { - var text = - $"{AppIdentity.DisplayName} needs approval before a remote agent can run a local command on this Windows machine." + - "\r\n\r\n" + - request.Command + - "\r\n\r\n" + - $"Shell: {request.Shell ?? "auto"}" + - "\r\n" + - $"Reason: {request.Reason}"; + var text = ExecApprovalPromptText.Build(request, UseGerman, AppIdentity.DisplayName); try { @@ -184,9 +182,9 @@ private static ExecApprovalPromptDecision ShowMessageBoxFallback(string text) { var fallbackText = text + Environment.NewLine + Environment.NewLine + - "Yes = Allow once" + + (UseGerman ? "Ja = Einmal erlauben" : "Yes = Allow once") + Environment.NewLine + - "No or Cancel = Deny"; + (UseGerman ? "Nein oder Abbrechen = Ablehnen" : "No or Cancel = Deny"); var result = MessageBoxW( IntPtr.Zero, @@ -358,9 +356,9 @@ private void CreateControls(IntPtr hwnd) var totalButtonWidth = ButtonWidth * 3 + ButtonGap * 2; var firstLeft = WindowWidth - 20 - totalButtonWidth; - _allowOnceButtonHwnd = CreateChild(hwnd, "BUTTON", "Allow once", firstLeft, ButtonTop, ButtonWidth, ButtonHeight, ButtonStylePushButton, AllowOnceButtonId, font); - _alwaysAllowButtonHwnd = CreateChild(hwnd, "BUTTON", "Always allow", firstLeft + ButtonWidth + ButtonGap, ButtonTop, ButtonWidth, ButtonHeight, ButtonStylePushButton, AlwaysAllowButtonId, font); - _denyButtonHwnd = CreateChild(hwnd, "BUTTON", "Deny", firstLeft + (ButtonWidth + ButtonGap) * 2, ButtonTop, ButtonWidth, ButtonHeight, ButtonStyleDefaultPushButton, DenyButtonId, font); + _allowOnceButtonHwnd = CreateChild(hwnd, "BUTTON", UseGerman ? "Einmal erlauben" : "Allow once", firstLeft, ButtonTop, ButtonWidth, ButtonHeight, ButtonStylePushButton, AllowOnceButtonId, font); + _alwaysAllowButtonHwnd = CreateChild(hwnd, "BUTTON", UseGerman ? "Dauerhaft erlauben" : "Always allow", firstLeft + ButtonWidth + ButtonGap, ButtonTop, ButtonWidth, ButtonHeight, ButtonStylePushButton, AlwaysAllowButtonId, font); + _denyButtonHwnd = CreateChild(hwnd, "BUTTON", UseGerman ? "Ablehnen" : "Deny", firstLeft + (ButtonWidth + ButtonGap) * 2, ButtonTop, ButtonWidth, ButtonHeight, ButtonStyleDefaultPushButton, DenyButtonId, font); SetFocus(_denyButtonHwnd); } diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs index 82384c916..3d7fe8eda 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs @@ -1239,6 +1239,51 @@ public async Task SystemRun_PromptApproved_DoesNotFirePolicyAutoDecidedEvent() try { Directory.Delete(tempDir, true); } catch { } } } + + [Fact] + public async Task SystemRun_PromptReceivesCanonicalCommandPreview() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var policy = new ExecApprovalPolicy(tempDir, _logger); + policy.SetRules(System.Array.Empty(), ExecApprovalAction.Prompt); + + var cap = CreateCapability(policy); + var prompt = new CapturingAllowOncePromptHandler(); + cap.SetPromptHandler(prompt); + + var request = new NodeInvokeRequest + { + Command = "system.run", + Args = JsonDocument.Parse(""" + { + "command": "tasklist", + "systemRunPlan": { + "commandPreview": "Zweck: laufende Prozesse nur lesend prüfen." + } + } + """).RootElement + }; + + var result = await cap.ExecuteAsync(request); + + Assert.True(result.Ok); + Assert.NotNull(prompt.Request); + Assert.Equal( + "Zweck: laufende Prozesse nur lesend prüfen.", + prompt.Request!.CommandPreview); + Assert.Equal("tasklist", prompt.Request.TechnicalCommand); + Assert.StartsWith( + "Zweck: laufende Prozesse nur lesend prüfen.", + prompt.Request.Command); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } } internal class AllowOncePromptHandler : IExecApprovalPromptHandler @@ -1249,6 +1294,19 @@ public Task RequestAsync( => Task.FromResult(ExecApprovalPromptDecision.AllowOnce()); } +internal sealed class CapturingAllowOncePromptHandler : IExecApprovalPromptHandler +{ + public ExecApprovalPromptRequest? Request { get; private set; } + + public Task RequestAsync( + ExecApprovalPromptRequest request, + System.Threading.CancellationToken cancellationToken = default) + { + Request = request; + return Task.FromResult(ExecApprovalPromptDecision.AllowOnce()); + } +} + /// Mock command runner that always succeeds internal class MockCommandRunner : ICommandRunner { diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalPromptTextTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalPromptTextTests.cs new file mode 100644 index 000000000..37a691a77 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalPromptTextTests.cs @@ -0,0 +1,53 @@ +using OpenClaw.Shared; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public sealed class ExecApprovalPromptTextTests +{ + [Fact] + public void GermanPrompt_LeadsWithPreviewAndKeepsExactCommandVisible() + { + var text = ExecApprovalPromptText.Build(new ExecApprovalPromptRequest + { + Command = "powershell.exe -NoProfile -Command Get-ComputerInfo", + CommandPreview = "Zweck: Systemzustand lesen.\nRisiko: niedrig.\nEmpfehlung: Einmal erlauben.", + Shell = "powershell", + Reason = "No matching rule; default policy applied" + }, german: true, displayName: "OpenClaw Companion"); + + Assert.Contains("Worum es geht (von Otti beschrieben)", text); + Assert.Contains("Zweck: Systemzustand lesen.", text); + Assert.Contains("Technische Details", text); + Assert.Contains("powershell.exe -NoProfile -Command Get-ComputerInfo", text); + Assert.Contains("Policy und Sandbox bleiben aktiv", text); + } + + [Fact] + public void Prompt_StripsBidirectionalOverridesFromUntrustedText() + { + var text = ExecApprovalPromptText.Build(new ExecApprovalPromptRequest + { + Command = "safe.exe\u202Etxt.exe", + CommandPreview = "Read only\u2066spoof", + Reason = "approval" + }, german: false, displayName: "OpenClaw Companion"); + + Assert.Equal(-1, text.IndexOf('\u202E')); + Assert.Equal(-1, text.IndexOf('\u2066')); + Assert.Contains("safe.exetxt.exe", text); + } + + [Fact] + public void PromptWithoutPreview_TellsUserToDenyAndRetry() + { + var text = ExecApprovalPromptText.Build(new ExecApprovalPromptRequest + { + Command = "hostname", + Reason = "approval" + }, german: true, displayName: "OpenClaw Companion"); + + Assert.Contains("Wenn du unsicher bist, lehne ab", text); + Assert.Contains("hostname", text); + } +}