Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 37 additions & 2 deletions src/OpenClaw.Shared/Capabilities/SystemCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -293,6 +294,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
argv,
cwd,
rawCommand,
commandPreview,
requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(),
effectiveShell,
agentId,
Expand Down Expand Up @@ -377,6 +379,8 @@ private async Task<NodeInvokeResponse> 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));
Expand Down Expand Up @@ -434,6 +438,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
effectiveShell,
commandPreview,
sessionKey,
correlationId);
if (approvalError != null)
Expand All @@ -445,6 +450,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
approvedHostFallbackShell,
commandPreview,
sessionKey,
correlationId);
if (approvalError != null)
Expand Down Expand Up @@ -486,6 +492,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
string command,
string? shell,
string? commandPreview,
ExecApprovalResult approval,
string? sessionKey,
string correlationId,
Expand All @@ -502,7 +509,9 @@ private async Task<ExecApprovalCheckResult> 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",
Expand Down Expand Up @@ -542,6 +551,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
private async Task<NodeInvokeResponse?> EnsureCommandAndNestedTargetsApprovedAsync(
string fullCommand,
string? shell,
string? commandPreview,
string? sessionKey,
string correlationId)
{
Expand All @@ -566,7 +576,13 @@ private async Task<ExecApprovalCheckResult> 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})");
Expand Down Expand Up @@ -597,6 +613,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
var innerApprovalCheck = await EnsureApprovedAsync(
target.Command,
target.Shell,
commandPreview,
innerApproval,
sessionKey,
correlationId);
Expand All @@ -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 &&
Expand Down
11 changes: 11 additions & 0 deletions src/OpenClaw.Shared/ExecApprovalPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ public enum ExecApprovalPromptDecisionKind
public sealed class ExecApprovalPromptRequest
{
public string Command { get; init; } = "";
/// <summary>
/// Exact command when <see cref="Command"/> has been formatted for a
/// legacy presenter that does not understand <see cref="CommandPreview"/>.
/// </summary>
public string? TechnicalCommand { get; init; }
/// <summary>
/// Optional human-readable summary supplied through OpenClaw's canonical
/// <c>commandPreview</c> field. This is presentation context, not a policy
/// decision; the exact command remains authoritative and must stay visible.
/// </summary>
public string? CommandPreview { get; init; }
public string? Shell { get; init; }
public string? MatchedPattern { get; init; }
public string Reason { get; init; } = "";
Expand Down
97 changes: 97 additions & 0 deletions src/OpenClaw.Shared/ExecApprovalPromptText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.Linq;

namespace OpenClaw.Shared;

/// <summary>
/// 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.
/// </summary>
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)}";
}

/// <summary>
/// Compatibility bridge for installed presenters that only render the
/// legacy Command property. New presenters use CommandPreview and
/// TechnicalCommand separately.
/// </summary>
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');
}
26 changes: 12 additions & 14 deletions src/OpenClaw.Tray.WinUI/Services/ExecApprovalPromptService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,7 +16,11 @@ public sealed class ExecApprovalPromptService : IExecApprovalPromptHandler
private readonly IOpenClawLogger _logger;
private readonly Func<OpenClawChatDataProvider?>? _chatProviderProvider;
private readonly Func<string, bool>? _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,
Expand Down Expand Up @@ -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
{
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions tests/OpenClaw.Shared.Tests/ExecApprovalPolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecApprovalRule>(), 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
Expand All @@ -1249,6 +1294,19 @@ public Task<ExecApprovalPromptDecision> RequestAsync(
=> Task.FromResult(ExecApprovalPromptDecision.AllowOnce());
}

internal sealed class CapturingAllowOncePromptHandler : IExecApprovalPromptHandler
{
public ExecApprovalPromptRequest? Request { get; private set; }

public Task<ExecApprovalPromptDecision> RequestAsync(
ExecApprovalPromptRequest request,
System.Threading.CancellationToken cancellationToken = default)
{
Request = request;
return Task.FromResult(ExecApprovalPromptDecision.AllowOnce());
}
}

/// <summary>Mock command runner that always succeeds</summary>
internal class MockCommandRunner : ICommandRunner
{
Expand Down
Loading
Loading