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
34 changes: 33 additions & 1 deletion src/OpenClaw.Shared/Capabilities/SystemCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -319,6 +320,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
argv,
cwd,
rawCommand,
commandPreview,
requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(),
effectiveShell,
agentId,
Expand Down Expand Up @@ -446,6 +448,7 @@ private async Task<NodeInvokeResponse> 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));
Expand Down Expand Up @@ -510,6 +513,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
effectiveShell,
commandPreview,
sessionKey,
correlationId);
if (approvalError != null)
Expand All @@ -528,6 +532,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
fullCommand,
approvedHostFallbackShell,
commandPreview,
sessionKey,
correlationId);
if (approvalError != null)
Expand Down Expand Up @@ -740,6 +745,7 @@ private async Task<NodeInvokeResponse> RunApprovedAsync(
private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
string command,
string? shell,
string? commandPreview,
ExecApprovalResult approval,
string? sessionKey,
string correlationId,
Expand All @@ -757,6 +763,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
var decision = await _promptHandler.RequestAsync(new ExecApprovalPromptRequest
{
Command = command,
CommandPreview = commandPreview,
Shell = shell,
MatchedPattern = approval.MatchedPattern,
Reason = approval.Reason ?? "Command requires approval",
Expand Down Expand Up @@ -796,6 +803,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
private async Task<NodeInvokeResponse?> EnsureCommandAndNestedTargetsApprovedAsync(
string fullCommand,
string? shell,
string? commandPreview,
string? sessionKey,
string correlationId)
{
Expand All @@ -820,7 +828,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 @@ -851,6 +865,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
var innerApprovalCheck = await EnsureApprovedAsync(
target.Command,
target.Shell,
commandPreview,
innerApproval,
sessionKey,
correlationId);
Expand All @@ -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 &&
Expand Down
6 changes: 6 additions & 0 deletions src/OpenClaw.Shared/ExecApprovalPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public enum ExecApprovalPromptDecisionKind
public sealed class ExecApprovalPromptRequest
{
public string Command { get; init; } = "";
/// <summary>
/// Optional human-readable summary supplied through OpenClaw's canonical
/// <c>commandPreview</c> field. This is presentation context, not a policy
/// decision; <see cref="Command"/> remains the exact authoritative command.
/// </summary>
public string? CommandPreview { get; init; }
public string? Shell { get; init; }
public string? MatchedPattern { get; init; }
public string Reason { get; init; } = "";
Expand Down
69 changes: 69 additions & 0 deletions src/OpenClaw.Shared/ExecApprovalPromptText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using OpenClaw.Shared.ExecApprovals;

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.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)] + "…";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public sealed class CanonicalCommandIdentity
public IReadOnlyDictionary<string, string>? Env { get; }
public string? AgentId { get; }
public string? SessionKey { get; }
public string? CommandPreview { get; }

internal CanonicalCommandIdentity(
IReadOnlyList<string> command,
Expand All @@ -53,7 +54,8 @@ internal CanonicalCommandIdentity(
int timeoutMs,
IReadOnlyDictionary<string, string>? env,
string? agentId,
string? sessionKey)
string? sessionKey,
string? commandPreview = null)
{
Command = command;
DisplayCommand = displayCommand;
Expand All @@ -66,5 +68,6 @@ internal CanonicalCommandIdentity(
Env = env;
AgentId = agentId;
SessionKey = sessionKey;
CommandPreview = commandPreview;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Globalization;
using System.Text;

namespace OpenClaw.Shared.ExecApprovals;

/// <summary>
/// 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.
/// </summary>
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public sealed class ExecApprovalV2PromptRequest
public required string AgentId { get; init; }
public string? ResolvedPath { get; init; }
/// <summary>
/// Optional agent-supplied explanation of the request. Presentation context
/// only; <see cref="DisplayCommand"/> remains authoritative.
/// </summary>
public string? CommandPreview { get; init; }
/// <summary>
/// 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.
Expand Down
20 changes: 18 additions & 2 deletions src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ public sealed record ExecApprovalPromptView(
string AgentLabel,
string? CwdText,
string? ExecutablePathText,
bool HasConfusableWarning);
bool HasConfusableWarning)
{
public string? CommandPreviewText { get; init; }
}

/// <summary>
/// Prompt-handler core behind the approval dialog. UI-free: the dispatcher hop and the
Expand Down Expand Up @@ -49,6 +52,9 @@ public async Task<ExecApprovalPromptOutcome> 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);
Expand All @@ -58,10 +64,19 @@ public async Task<ExecApprovalPromptOutcome> 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<ExecApprovalPromptOutcome>(
TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -127,4 +142,5 @@ private async Task RunDialogAsync(
tcs.TrySetResult(ExecApprovalPromptOutcome.Deny);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading