From a3c3fba8944a27da699c1f0e1ef17df135a84a68 Mon Sep 17 00:00:00 2001 From: Otti Date: Sun, 19 Jul 2026 19:04:39 +0200 Subject: [PATCH] feat: add semantic exec approval prompts --- .../Capabilities/SystemCapability.cs | 34 ++++++++- src/OpenClaw.Shared/ExecApprovalPrompt.cs | 6 ++ src/OpenClaw.Shared/ExecApprovalPromptText.cs | 69 +++++++++++++++++ .../ExecApprovals/CanonicalCommandIdentity.cs | 5 +- .../ExecApprovalContextDisplaySanitizer.cs | 45 +++++++++++ .../ExecApprovalV2InputValidator.cs | 20 ++++- .../ExecApprovalV2NormalizationStep.cs | 3 +- .../ExecApprovalV2PromptRequest.cs | 5 ++ .../ExecApprovalV2UiPromptHandler.cs | 20 ++++- .../ExecApprovals/ExecApprovalsCoordinator.cs | 1 + .../ExecApprovals/ValidatedRunRequest.cs | 5 +- .../Dialogs/ExecApprovalDialog.cs | 27 +++++++ .../Services/ExecApprovalPromptService.cs | 26 +++---- .../Strings/en-us/Resources.resw | 5 +- .../Strings/fr-fr/Resources.resw | 5 +- .../Strings/nl-nl/Resources.resw | 5 +- .../Strings/zh-cn/Resources.resw | 5 +- .../Strings/zh-tw/Resources.resw | 5 +- .../OpenClaw.Shared.Tests/CapabilityTests.cs | 13 +++- ...xecApprovalContextDisplaySanitizerTests.cs | 36 +++++++++ .../ExecApprovalPolicyTests.cs | 55 ++++++++++++++ .../ExecApprovalPromptTextTests.cs | 74 +++++++++++++++++++ .../ExecApprovalV2InputValidationTests.cs | 40 +++++++++- .../ExecApprovalV2NormalizationTests.cs | 14 +++- .../ExecApprovalV2PipelineIntegrationTests.cs | 24 ++++++ .../ExecApprovalV2UiPromptHandlerTests.cs | 30 +++++++- .../ExecApprovalDialogContractTests.cs | 26 +++++++ 27 files changed, 570 insertions(+), 33 deletions(-) create mode 100644 src/OpenClaw.Shared/ExecApprovalPromptText.cs create mode 100644 src/OpenClaw.Shared/ExecApprovals/ExecApprovalContextDisplaySanitizer.cs create mode 100644 tests/OpenClaw.Shared.Tests/ExecApprovalContextDisplaySanitizerTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/ExecApprovalPromptTextTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/ExecApprovalDialogContractTests.cs diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index eb60e621a..b2106b18f 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -301,6 +301,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) var command = argv[0]; var rawCommand = GetStringArg(request.Args, "rawCommand"); + var commandPreview = GetCommandPreviewArg(request.Args); var requestedShell = GetStringArg(request.Args, "shell"); var effectiveShell = _commandRunner?.ResolveEffectiveShell(requestedShell) ?? ResolveDefaultEffectiveShell(requestedShell); @@ -319,6 +320,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) argv, cwd, rawCommand, + commandPreview, requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(), effectiveShell, agentId, @@ -446,6 +448,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var shell = GetStringArg(request.Args, "shell"); var cwd = GetStringArg(request.Args, "cwd"); + var commandPreview = GetCommandPreviewArg(request.Args); var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); var timeoutMs = GetIntArg(request.Args, "timeoutMs", GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs)); @@ -510,6 +513,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( fullCommand, effectiveShell, + commandPreview, sessionKey, correlationId); if (approvalError != null) @@ -528,6 +532,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( fullCommand, approvedHostFallbackShell, + commandPreview, sessionKey, correlationId); if (approvalError != null) @@ -740,6 +745,7 @@ private async Task RunApprovedAsync( private async Task EnsureApprovedAsync( string command, string? shell, + string? commandPreview, ExecApprovalResult approval, string? sessionKey, string correlationId, @@ -757,6 +763,7 @@ private async Task EnsureApprovedAsync( var decision = await _promptHandler.RequestAsync(new ExecApprovalPromptRequest { Command = command, + CommandPreview = commandPreview, Shell = shell, MatchedPattern = approval.MatchedPattern, Reason = approval.Reason ?? "Command requires approval", @@ -796,6 +803,7 @@ private async Task EnsureApprovedAsync( private async Task EnsureCommandAndNestedTargetsApprovedAsync( string fullCommand, string? shell, + string? commandPreview, string? sessionKey, string correlationId) { @@ -820,7 +828,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})"); @@ -851,6 +865,7 @@ private async Task EnsureApprovedAsync( var innerApprovalCheck = await EnsureApprovedAsync( target.Command, target.Shell, + commandPreview, innerApproval, sessionKey, correlationId); @@ -877,6 +892,23 @@ private static bool CanPersistExactAllowRule(string command) => !string.IsNullOrWhiteSpace(command) && command.IndexOfAny(['*', '?']) < 0; + private string? GetCommandPreviewArg(System.Text.Json.JsonElement args) + { + var preview = GetStringArg(args, "commandPreview"); + if (!string.IsNullOrWhiteSpace(preview)) + return preview; + + if (args.ValueKind != System.Text.Json.JsonValueKind.Object || + !args.TryGetProperty("systemRunPlan", out var plan) || + plan.ValueKind != System.Text.Json.JsonValueKind.Object) + { + return null; + } + + preview = GetStringArg(plan, "commandPreview"); + return string.IsNullOrWhiteSpace(preview) ? null : preview; + } + 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..f99f06877 100644 --- a/src/OpenClaw.Shared/ExecApprovalPrompt.cs +++ b/src/OpenClaw.Shared/ExecApprovalPrompt.cs @@ -14,6 +14,12 @@ public enum ExecApprovalPromptDecisionKind public sealed class ExecApprovalPromptRequest { public string Command { get; init; } = ""; + /// + /// Optional human-readable summary supplied through OpenClaw's canonical + /// commandPreview field. This is presentation context, not a policy + /// decision; remains the exact authoritative command. + /// + 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..707fa2bb7 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovalPromptText.cs @@ -0,0 +1,69 @@ +using System; +using OpenClaw.Shared.ExecApprovals; + +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.Command, 4_000); + var preview = ExecApprovalContextDisplaySanitizer.Sanitize(request.CommandPreview); + var reason = Sanitize(request.Reason, 400); + var shell = Sanitize(request.Shell, 80); + + if (german) + { + var summary = string.IsNullOrWhiteSpace(preview) + ? $"{displayName} hat keine verständliche Beschreibung mitgesendet. Wenn du unsicher bist, lehne ab und lass die Anfrage neu formulieren." + : preview; + return + $"{displayName} möchte etwas auf diesem Windows-PC ausführen.\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)}" + + "\r\n\r\n" + + $"Worum es geht (von {displayName} beschrieben):\r\n" + + summary + + "\r\n\r\n" + + "Sicherheitsgrenze: Policy und konfigurierte Sandbox-Regeln bleiben maßgeblich. Diese Beschreibung ersetzt nicht die technische Prüfung durch den Hub."; + } + + 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" + + "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)}" + + "\r\n\r\n" + + "What this is for (described by the agent):\r\n" + + englishSummary + + "\r\n\r\n" + + "Security boundary: policy and configured sandbox controls remain authoritative. This description does not replace the Hub's technical checks."; + } + + private static string Sanitize(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + var safe = ExecApprovalCommandDisplaySanitizer.Sanitize(value).Trim(); + return safe.Length <= maxLength ? safe : safe[..(maxLength - 1)] + "…"; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs index bb2199a56..97ea88b58 100644 --- a/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs +++ b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs @@ -41,6 +41,7 @@ public sealed class CanonicalCommandIdentity public IReadOnlyDictionary? Env { get; } public string? AgentId { get; } public string? SessionKey { get; } + public string? CommandPreview { get; } internal CanonicalCommandIdentity( IReadOnlyList command, @@ -53,7 +54,8 @@ internal CanonicalCommandIdentity( int timeoutMs, IReadOnlyDictionary? env, string? agentId, - string? sessionKey) + string? sessionKey, + string? commandPreview = null) { Command = command; DisplayCommand = displayCommand; @@ -66,5 +68,6 @@ internal CanonicalCommandIdentity( Env = env; AgentId = agentId; SessionKey = sessionKey; + CommandPreview = commandPreview; } } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalContextDisplaySanitizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalContextDisplaySanitizer.cs new file mode 100644 index 000000000..fc169ae51 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalContextDisplaySanitizer.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Text; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Sanitizes agent-supplied approval context while preserving intentional +/// line breaks. Unlike command text, explanatory context is expected to be +/// multi-line; other control, format, and separator characters are escaped. +/// +public static class ExecApprovalContextDisplaySanitizer +{ + public static string Sanitize(string? value, int maxLength = 1_200) + { + if (string.IsNullOrWhiteSpace(value) || maxLength <= 0) + return ""; + + var output = new StringBuilder(Math.Min(value.Length, maxLength)); + foreach (var rune in value.EnumerateRunes()) + { + var piece = rune.Value is '\r' or '\n' or '\t' + ? rune.ToString() + : Rune.GetUnicodeCategory(rune) is + UnicodeCategory.Control or + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator + ? $@"\u{{{rune.Value:X}}}" + : rune.ToString(); + + if (output.Length + piece.Length <= maxLength) + { + output.Append(piece); + continue; + } + + if (output.Length >= maxLength) + output.Length = maxLength - 1; + output.Append('…'); + break; + } + + return output.ToString().Trim(); + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs index 7d455a161..6814c091c 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs @@ -78,7 +78,8 @@ public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request timeoutMs, env, TryGetString(request.Args, "agentId"), - TryGetString(request.Args, "sessionKey"))); + TryGetString(request.Args, "sessionKey"), + TryGetCommandPreview(request.Args))); } private static ExecApprovalV2ValidationOutcome Deny(string reason) @@ -134,4 +135,21 @@ private static ExecApprovalV2ValidationOutcome Deny(string reason) return null; return el.GetString(); } + + private static string? TryGetCommandPreview(JsonElement args) + { + var preview = TryGetString(args, "commandPreview"); + if (!string.IsNullOrWhiteSpace(preview)) + return preview; + + if (args.ValueKind != JsonValueKind.Object || + !args.TryGetProperty("systemRunPlan", out var plan) || + plan.ValueKind != JsonValueKind.Object) + { + return null; + } + + preview = TryGetString(plan, "commandPreview"); + return string.IsNullOrWhiteSpace(preview) ? null : preview; + } } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs index e6688229b..85c76ad5f 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs @@ -74,7 +74,8 @@ public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest r request.TimeoutMs, env, request.AgentId, - request.SessionKey); + request.SessionKey, + request.CommandPreview); return ExecApprovalV2NormalizationOutcome.Ok(identity); } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs index 683a6a32c..dc59e2c9a 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs @@ -17,6 +17,11 @@ public sealed class ExecApprovalV2PromptRequest public required string AgentId { get; init; } public string? ResolvedPath { get; init; } /// + /// Optional agent-supplied explanation of the request. Presentation context + /// only; remains authoritative. + /// + public string? CommandPreview { get; init; } + /// /// Opaque key scoping AllowOnce/AllowAlways decisions to a conversation session. /// Minted by the gateway per session; null means no session context is available. /// Not safe to display — internal identifier only. diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs index 5d6da85a3..d3e50eb4a 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs @@ -13,7 +13,10 @@ public sealed record ExecApprovalPromptView( string AgentLabel, string? CwdText, string? ExecutablePathText, - bool HasConfusableWarning); + bool HasConfusableWarning) +{ + public string? CommandPreviewText { get; init; } +} /// /// Prompt-handler core behind the approval dialog. UI-free: the dispatcher hop and the @@ -49,6 +52,9 @@ public async Task PromptAsync( // including the agent label, so all of them go through the display sanitizer // before any UI binding to neutralize BiDi and control-character spoofing. var commandText = ExecApprovalCommandDisplaySanitizer.Sanitize(request.DisplayCommand); + var commandPreviewText = string.IsNullOrWhiteSpace(request.CommandPreview) + ? null + : ExecApprovalContextDisplaySanitizer.Sanitize(request.CommandPreview); var agentLabel = ExecApprovalCommandDisplaySanitizer.Sanitize(request.AgentId); var cwdText = request.Cwd is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.Cwd); var pathText = request.ResolvedPath is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.ResolvedPath); @@ -58,10 +64,19 @@ public async Task PromptAsync( // instead so the user knows the command may not read the way it looks. var hasConfusable = ExecApprovalConfusableDetector.HasMixedScriptConfusable(commandText) + || ExecApprovalConfusableDetector.HasMixedScriptConfusable(commandPreviewText) || ExecApprovalConfusableDetector.HasMixedScriptConfusable(cwdText) || ExecApprovalConfusableDetector.HasMixedScriptConfusable(pathText); - var view = new ExecApprovalPromptView(commandText, agentLabel, cwdText, pathText, hasConfusable); + var view = new ExecApprovalPromptView( + commandText, + agentLabel, + cwdText, + pathText, + hasConfusable) + { + CommandPreviewText = commandPreviewText + }; var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -127,4 +142,5 @@ private async Task RunDialogAsync( tcs.TrySetResult(ExecApprovalPromptOutcome.Deny); } } + } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs index 21bd198aa..f04a1c0a0 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs @@ -374,6 +374,7 @@ private static ExecApprovalV2PromptRequest BuildPromptRequest( Ask = context.Ask, AgentId = context.AgentId ?? "main", ResolvedPath = context.Resolution?.ResolvedPath, + CommandPreview = identity.CommandPreview, SessionKey = identity.SessionKey, CorrelationId = correlationId, // Host omitted (no gateway wiring yet) diff --git a/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs index d93c90815..e55304598 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs @@ -15,6 +15,7 @@ public sealed class ValidatedRunRequest public IReadOnlyDictionary? Env { get; } public string? AgentId { get; } public string? SessionKey { get; } + public string? CommandPreview { get; } internal ValidatedRunRequest( string[] argv, @@ -23,7 +24,8 @@ internal ValidatedRunRequest( int timeoutMs, IReadOnlyDictionary? env, string? agentId, - string? sessionKey) + string? sessionKey, + string? commandPreview = null) { Argv = argv; Shell = shell; @@ -32,6 +34,7 @@ internal ValidatedRunRequest( Env = env; AgentId = agentId; SessionKey = sessionKey; + CommandPreview = commandPreview; } } diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs index fa0824b9d..243c6cd67 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs @@ -152,6 +152,33 @@ public ExecApprovalDialog(ExecApprovalPromptView view) Child = commandText, }); + // Agent-supplied context is additive and deliberately follows the exact + // command so an oversized or whitespace-heavy preview cannot displace the + // authoritative approval target from the initial viewport. + if (!string.IsNullOrWhiteSpace(view.CommandPreviewText)) + { + var preview = new StackPanel { Spacing = 6 }; + preview.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("ExecApproval_RequestContextLabel"), + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = ResolveBrush("TextFillColorSecondaryBrush"), + }); + preview.Children.Add(new TextBlock + { + Text = view.CommandPreviewText, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }); + body.Children.Add(new Border + { + Background = ResolveBrush("CardBackgroundFillColorDefaultBrush"), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(10), + Child = preview, + }); + } + AddContextRow(body, "ExecApproval_AgentLabel", view.AgentLabel); AddContextRow(body, "ExecApproval_CwdLabel", view.CwdText); AddContextRow(body, "ExecApproval_ExecutableLabel", view.ExecutablePathText); 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/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index c2b5a910a..9594e9db2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -770,6 +770,9 @@ Use one of these options: The following command is requesting permission to run: + + Request context (provided by the agent) + Agent: @@ -795,7 +798,7 @@ Use one of these options: Scroll to the end of the command before allowing - This command mixes character sets and may not read the way it looks. Check it carefully. + The displayed content mixes character sets and may not read the way it looks. Check it carefully. ✅ Node Paired! diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 9f3e2a037..cc7d8ebce 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -729,6 +729,9 @@ Utilisez l'une de ces options : La commande suivante demande l'autorisation de s'exécuter : + + Contexte de la demande (fourni par l’agent) + Agent : @@ -754,7 +757,7 @@ Utilisez l'une de ces options : Faites défiler jusqu'à la fin de la commande avant d'autoriser - Cette commande mélange des jeux de caractères et peut ne pas être ce qu'elle paraît. Vérifiez-la attentivement. + Le contenu affiché mélange des jeux de caractères et peut ne pas être ce qu’il paraît. Vérifiez-le attentivement. ✅ Noeud appairé! diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 387b49e40..5dbaa88ce 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -730,6 +730,9 @@ Gebruik een van deze opties: De volgende opdracht vraagt toestemming om te worden uitgevoerd: + + Context van het verzoek (aangeleverd door de AI-agent) + AI-agent: @@ -755,7 +758,7 @@ Gebruik een van deze opties: Scroll naar het einde van de opdracht voordat u toestaat - Deze opdracht mengt tekensets en is mogelijk niet wat het lijkt. Controleer hem zorgvuldig. + De weergegeven inhoud mengt tekensets en is mogelijk niet wat het lijkt. Controleer deze zorgvuldig. ✅ Node gekoppeld! diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index d5d6d4bdd..7f48df797 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -729,6 +729,9 @@ 以下命令请求运行权限: + + 请求上下文(由代理提供) + 代理: @@ -754,7 +757,7 @@ 请滚动到命令末尾后再允许 - 此命令混用了不同的字符集,可能与其外观不符。请仔细核对。 + 显示的内容混用了不同的字符集,可能与其外观不符。请仔细核对。 ✅ 节点已配对! diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 28cac1979..b97a553d7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -729,6 +729,9 @@ 以下命令請求執行權限: + + 要求內容(由代理提供) + 代理: @@ -754,7 +757,7 @@ 請捲動至命令結尾後再允許 - 此命令混用了不同的字元集,可能與其外觀不符。請仔細核對。 + 顯示的內容混用了不同的字元集,可能與其外觀不符。請仔細核對。 ✅ 節點已配對! diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 1bec6f133..ba434eacb 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -667,7 +667,15 @@ public async Task RunPrepare_ReturnsPlan_WithArgvAndCwd() { Id = "p3", Command = "system.run.prepare", - Args = Parse("""{"command":["ls","-la"],"cwd":"/tmp","agentId":"agent1","sessionKey":"sk1"}""") + Args = Parse(""" + { + "command": ["ls", "-la"], + "cwd": "/tmp", + "agentId": "agent1", + "sessionKey": "sk1", + "commandPreview": "List directory contents without modifying them." + } + """) }; var res = await cap.ExecuteAsync(req); @@ -680,6 +688,9 @@ public async Task RunPrepare_ReturnsPlan_WithArgvAndCwd() Assert.Equal("/tmp", cwd.GetString()); Assert.True(plan.TryGetProperty("agentId", out var agentId)); Assert.Equal("agent1", agentId.GetString()); + Assert.Equal( + "List directory contents without modifying them.", + plan.GetProperty("commandPreview").GetString()); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalContextDisplaySanitizerTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalContextDisplaySanitizerTests.cs new file mode 100644 index 000000000..27596fd0e --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalContextDisplaySanitizerTests.cs @@ -0,0 +1,36 @@ +using OpenClaw.Shared.ExecApprovals; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public sealed class ExecApprovalContextDisplaySanitizerTests +{ + [Fact] + public void PreservesIntentionalMultilineContext() + { + var input = "Purpose: inspect state.\nRisk: low.\nRecommendation: allow once."; + + Assert.Equal(input, ExecApprovalContextDisplaySanitizer.Sanitize(input)); + } + + [Fact] + public void EscapesBidirectionalAndZeroWidthFormatting() + { + var input = "Read only\u202Espoof\u200Bcontext"; + + Assert.Equal( + @"Read only\u{202E}spoof\u{200B}context", + ExecApprovalContextDisplaySanitizer.Sanitize(input)); + } + + [Fact] + public void BoundsRenderedContext() + { + var sanitized = ExecApprovalContextDisplaySanitizer.Sanitize( + new string('a', 1_500), + maxLength: 100); + + Assert.Equal(100, sanitized.Length); + Assert.EndsWith("…", sanitized); + } +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs index 026b86ecb..ec443c2b0 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs @@ -1430,6 +1430,48 @@ 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.Command); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } } internal class AllowOncePromptHandler : IExecApprovalPromptHandler @@ -1440,6 +1482,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..571b95507 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalPromptTextTests.cs @@ -0,0 +1,74 @@ +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 OpenClaw Companion beschrieben)", text); + Assert.DoesNotContain("Otti", 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 konfigurierte Sandbox-Regeln bleiben maßgeblich", text); + Assert.True( + text.IndexOf("powershell.exe -NoProfile -Command Get-ComputerInfo", StringComparison.Ordinal) < + text.IndexOf("Zweck: Systemzustand lesen.", StringComparison.Ordinal), + "The exact command must be visible before agent-supplied context."); + } + + [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.DoesNotContain('\u202E', text); + Assert.DoesNotContain('\u2066', text); + Assert.Contains(@"safe.exe\u{202E}txt.exe", text); + Assert.Contains(@"Read only\u{2066}spoof", 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); + } + + [Fact] + public void GermanPrompt_UsesSuppliedDisplayName() + { + var text = ExecApprovalPromptText.Build(new ExecApprovalPromptRequest + { + Command = "hostname", + CommandPreview = "Zweck: Rechnernamen lesen.", + Reason = "approval" + }, german: true, displayName: "OpenClaw Companion"); + + Assert.Contains("OpenClaw Companion möchte", text); + Assert.Contains("von OpenClaw Companion beschrieben", text); + Assert.DoesNotContain("Otti", text); + } +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs index 15b267d4e..aacc09375 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs @@ -64,7 +64,8 @@ public void Valid_AllOptionalFields_Parsed() "timeoutMs": 5000, "env": {"MY_VAR": "value"}, "agentId": "agent-1", - "sessionKey": "sess-abc" + "sessionKey": "sess-abc", + "commandPreview": "Read the current repository status." } """)); @@ -77,6 +78,42 @@ public void Valid_AllOptionalFields_Parsed() Assert.Equal("value", r.Env!["MY_VAR"]); Assert.Equal("agent-1", r.AgentId); Assert.Equal("sess-abc", r.SessionKey); + Assert.Equal("Read the current repository status.", r.CommandPreview); + } + + [Fact] + public void Valid_CommandPreviewFromSystemRunPlan_IsParsed() + { + var outcome = ExecApprovalV2InputValidator.Validate(Req(""" + { + "command": ["git", "status"], + "systemRunPlan": { + "commandPreview": "Inspect working-tree changes without modifying files." + } + } + """)); + + Assert.True(outcome.IsValid); + Assert.Equal( + "Inspect working-tree changes without modifying files.", + outcome.Request!.CommandPreview); + } + + [Fact] + public void Valid_TopLevelCommandPreview_TakesPrecedenceOverPlanFallback() + { + var outcome = ExecApprovalV2InputValidator.Validate(Req(""" + { + "command": ["git", "status"], + "commandPreview": "Top-level context", + "systemRunPlan": { + "commandPreview": "Stale plan context" + } + } + """)); + + Assert.True(outcome.IsValid); + Assert.Equal("Top-level context", outcome.Request!.CommandPreview); } [Fact] @@ -267,6 +304,7 @@ public void AbsentOptionalFields_AreNull() Assert.Null(r.Env); Assert.Null(r.AgentId); Assert.Null(r.SessionKey); + Assert.Null(r.CommandPreview); } // ------------------------------------------------------------------------- diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs index 5a5159e21..219d7c199 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2NormalizationTests.cs @@ -18,8 +18,9 @@ private static ValidatedRunRequest Req( string? cwd = null, IReadOnlyDictionary? env = null, string? agentId = null, - string? sessionKey = null) => - new(argv, shell: null, cwd, timeoutMs: 30_000, env, agentId, sessionKey); + string? sessionKey = null, + string? commandPreview = null) => + new(argv, shell: null, cwd, timeoutMs: 30_000, env, agentId, sessionKey, commandPreview); // ── ExecShellWrapperNormalizer ──────────────────────────────────────────── @@ -330,7 +331,13 @@ public void Normalize_DisplayCommand_AlwaysFromArgv_NeverRawCommand() public void Normalize_ContextFieldsCarriedThrough() { var env = new Dictionary { ["FOO"] = "bar" }; - var req = Req(["echo"], cwd: @"C:\tmp", env: env, agentId: "a1", sessionKey: "s1"); + var req = Req( + ["echo"], + cwd: @"C:\tmp", + env: env, + agentId: "a1", + sessionKey: "s1", + commandPreview: "Inspect environment"); var outcome = ExecApprovalV2Normalizer.Normalize(req); Assert.True(outcome.IsResolved); @@ -338,6 +345,7 @@ public void Normalize_ContextFieldsCarriedThrough() Assert.Equal(@"C:\tmp", id.Cwd); Assert.Equal("a1", id.AgentId); Assert.Equal("s1", id.SessionKey); + Assert.Equal("Inspect environment", id.CommandPreview); Assert.Equal(30_000, id.TimeoutMs); Assert.Equal("bar", id.Env!["FOO"]); } diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2PipelineIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2PipelineIntegrationTests.cs index 309a28708..a2e653c58 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2PipelineIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2PipelineIntegrationTests.cs @@ -110,6 +110,30 @@ await MakeCoordinator( Assert.Contains(@"\u{200B}", captured.CwdText!); } + [Fact] + public async Task CommandPreview_ReachesDialogAsAdditiveContext() + { + var request = Req(""" + { + "command": ["where", "hello"], + "systemRunPlan": { + "commandPreview": "Purpose: locate an executable. Risk: read-only." + } + } + """); + + ExecApprovalPromptView? captured = null; + await MakeCoordinator( + dialog: _ => ExecApprovalPromptOutcome.Deny, + onView: v => captured = v).HandleAsync(request, "preview-1"); + + Assert.NotNull(captured); + Assert.Contains("where", captured!.CommandText); + Assert.Equal( + "Purpose: locate an executable. Risk: read-only.", + captured.CommandPreviewText); + } + [Fact] public async Task DialogDenies_ReturnsUserDenied() { diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2UiPromptHandlerTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2UiPromptHandlerTests.cs index 81ff4008a..50fcc25ca 100644 --- a/tests/OpenClaw.Shared.Tests/ExecApprovalV2UiPromptHandlerTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2UiPromptHandlerTests.cs @@ -14,10 +14,11 @@ public class ExecApprovalV2UiPromptHandlerTests private static ExecApprovalV2PromptRequest Request( string command = "echo hello", string? cwd = null, string? resolvedPath = null, - string agentId = "agent-1") => + string agentId = "agent-1", string? commandPreview = null) => new() { DisplayCommand = command, + CommandPreview = commandPreview, Cwd = cwd, ResolvedPath = resolvedPath, Security = ExecSecurity.Full, @@ -152,13 +153,38 @@ public async Task ViewPassedToDialog_CarriesSanitizedCommandCwdAndPath() await handler.PromptAsync(Request( command: "echo " + U(0x202E) + "gpj.exe", cwd: "C:\\work" + U(0x200B) + "dir", - resolvedPath: "C:\\bin\\echo\nfake.exe")); + resolvedPath: "C:\\bin\\echo\nfake.exe", + commandPreview: "Read only" + U(0x2066) + "context")); Assert.NotNull(seen); Assert.Equal(@"echo \u{202E}gpj.exe", seen!.CommandText); Assert.Equal("agent-1", seen.AgentLabel); Assert.Equal(@"C:\work\u{200B}dir", seen.CwdText); Assert.Equal("C:\\bin\\echo" + @"\u{A}" + "fake.exe", seen.ExecutablePathText); + Assert.Equal(@"Read only\u{2066}context", seen.CommandPreviewText); + } + + [Fact] + public async Task PreviewIsAdditive_ExactCommandRemainsAuthoritative() + { + ExecApprovalPromptView? seen = null; + var handler = Handler((view, _) => + { + seen = view; + return Task.FromResult(ExecApprovalPromptOutcome.Deny); + }); + + await handler.PromptAsync(Request( + command: "powershell.exe -NoProfile -Command Get-ComputerInfo", + commandPreview: "Purpose: read system information. Risk: low.")); + + Assert.NotNull(seen); + Assert.Equal( + "powershell.exe -NoProfile -Command Get-ComputerInfo", + seen!.CommandText); + Assert.Equal( + "Purpose: read system information. Risk: low.", + seen.CommandPreviewText); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ExecApprovalDialogContractTests.cs b/tests/OpenClaw.Tray.Tests/ExecApprovalDialogContractTests.cs new file mode 100644 index 000000000..c844d2b62 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ExecApprovalDialogContractTests.cs @@ -0,0 +1,26 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class ExecApprovalDialogContractTests +{ + [Fact] + public void ExactCommand_IsAddedBeforeAgentSuppliedContext() + { + var source = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Dialogs", + "ExecApprovalDialog.cs")); + + var commandIndex = source.IndexOf("Text = view.CommandText", StringComparison.Ordinal); + var previewIndex = source.IndexOf( + "if (!string.IsNullOrWhiteSpace(view.CommandPreviewText))", + StringComparison.Ordinal); + + Assert.True(commandIndex >= 0, "Exact command rendering must remain present."); + Assert.True(previewIndex >= 0, "Agent-supplied context rendering must remain present."); + Assert.True( + commandIndex < previewIndex, + "Exact command must be rendered before agent-supplied context."); + } +}