Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit a3c3fba

Browse filesBrowse files
author
Otti
committed
feat: add semantic exec approval prompts
1 parent f50af18 commit a3c3fba
Copy full SHA for a3c3fba

27 files changed

+570-33Lines changed: 570 additions & 33 deletions
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎src/OpenClaw.Shared/Capabilities/SystemCapability.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/Capabilities/SystemCapability.cs
+33-1Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
301301

302302
var command = argv[0];
303303
var rawCommand = GetStringArg(request.Args, "rawCommand");
304+
var commandPreview = GetCommandPreviewArg(request.Args);
304305
var requestedShell = GetStringArg(request.Args, "shell");
305306
var effectiveShell = _commandRunner?.ResolveEffectiveShell(requestedShell)
306307
?? ResolveDefaultEffectiveShell(requestedShell);
@@ -319,6 +320,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
319320
argv,
320321
cwd,
321322
rawCommand,
323+
commandPreview,
322324
requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(),
323325
effectiveShell,
324326
agentId,
@@ -446,6 +448,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
446448

447449
var shell = GetStringArg(request.Args, "shell");
448450
var cwd = GetStringArg(request.Args, "cwd");
451+
var commandPreview = GetCommandPreviewArg(request.Args);
449452
var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey");
450453
var timeoutMs = GetIntArg(request.Args, "timeoutMs",
451454
GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs));
@@ -510,6 +513,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
510513
var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
511514
fullCommand,
512515
effectiveShell,
516+
commandPreview,
513517
sessionKey,
514518
correlationId);
515519
if (approvalError != null)
@@ -528,6 +532,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
528532
approvalError = await EnsureCommandAndNestedTargetsApprovedAsync(
529533
fullCommand,
530534
approvedHostFallbackShell,
535+
commandPreview,
531536
sessionKey,
532537
correlationId);
533538
if (approvalError != null)
@@ -740,6 +745,7 @@ private async Task<NodeInvokeResponse> RunApprovedAsync(
740745
private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
741746
string command,
742747
string? shell,
748+
string? commandPreview,
743749
ExecApprovalResult approval,
744750
string? sessionKey,
745751
string correlationId,
@@ -757,6 +763,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
757763
var decision = await _promptHandler.RequestAsync(new ExecApprovalPromptRequest
758764
{
759765
Command = command,
766+
CommandPreview = commandPreview,
760767
Shell = shell,
761768
MatchedPattern = approval.MatchedPattern,
762769
Reason = approval.Reason ?? "Command requires approval",
@@ -796,6 +803,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
796803
private async Task<NodeInvokeResponse?> EnsureCommandAndNestedTargetsApprovedAsync(
797804
string fullCommand,
798805
string? shell,
806+
string? commandPreview,
799807
string? sessionKey,
800808
string correlationId)
801809
{
@@ -820,7 +828,13 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
820828
var approvalCheck = new ExecApprovalCheckResult(false, null);
821829
if (evaluateOuter)
822830
{
823-
approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId);
831+
approvalCheck = await EnsureApprovedAsync(
832+
fullCommand,
833+
shell,
834+
commandPreview,
835+
approval,
836+
sessionKey,
837+
correlationId);
824838
if (!approvalCheck.Allowed)
825839
{
826840
Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})");
@@ -851,6 +865,7 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
851865
var innerApprovalCheck = await EnsureApprovedAsync(
852866
target.Command,
853867
target.Shell,
868+
commandPreview,
854869
innerApproval,
855870
sessionKey,
856871
correlationId);
@@ -877,6 +892,23 @@ private static bool CanPersistExactAllowRule(string command) =>
877892
!string.IsNullOrWhiteSpace(command) &&
878893
command.IndexOfAny(['*', '?']) < 0;
879894

895+
private string? GetCommandPreviewArg(System.Text.Json.JsonElement args)
896+
{
897+
var preview = GetStringArg(args, "commandPreview");
898+
if (!string.IsNullOrWhiteSpace(preview))
899+
return preview;
900+
901+
if (args.ValueKind != System.Text.Json.JsonValueKind.Object ||
902+
!args.TryGetProperty("systemRunPlan", out var plan) ||
903+
plan.ValueKind != System.Text.Json.JsonValueKind.Object)
904+
{
905+
return null;
906+
}
907+
908+
preview = GetStringArg(plan, "commandPreview");
909+
return string.IsNullOrWhiteSpace(preview) ? null : preview;
910+
}
911+
880912
private static bool IsExactAllowRuleForCommand(ExecApprovalResult approval, string command) =>
881913
approval.Allowed &&
882914
approval.Action == ExecApprovalAction.Allow &&
Collapse file

‎src/OpenClaw.Shared/ExecApprovalPrompt.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovalPrompt.cs
+6Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ public enum ExecApprovalPromptDecisionKind
1414
public sealed class ExecApprovalPromptRequest
1515
{
1616
public string Command { get; init; } = "";
17+
/// <summary>
18+
/// Optional human-readable summary supplied through OpenClaw's canonical
19+
/// <c>commandPreview</c> field. This is presentation context, not a policy
20+
/// decision; <see cref="Command"/> remains the exact authoritative command.
21+
/// </summary>
22+
public string? CommandPreview { get; init; }
1723
public string? Shell { get; init; }
1824
public string? MatchedPattern { get; init; }
1925
public string Reason { get; init; } = "";
Collapse file
+69Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System;
2+
using OpenClaw.Shared.ExecApprovals;
3+
4+
namespace OpenClaw.Shared;
5+
6+
/// <summary>
7+
/// Builds the native exec-approval explanation. The human-readable preview is
8+
/// explicitly labelled as agent-supplied context; the exact command remains
9+
/// visible and host policy remains authoritative.
10+
/// </summary>
11+
internal static class ExecApprovalPromptText
12+
{
13+
internal static string Build(
14+
ExecApprovalPromptRequest request,
15+
bool german,
16+
string displayName)
17+
{
18+
var command = Sanitize(request.Command, 4_000);
19+
var preview = ExecApprovalContextDisplaySanitizer.Sanitize(request.CommandPreview);
20+
var reason = Sanitize(request.Reason, 400);
21+
var shell = Sanitize(request.Shell, 80);
22+
23+
if (german)
24+
{
25+
var summary = string.IsNullOrWhiteSpace(preview)
26+
? $"{displayName} hat keine verständliche Beschreibung mitgesendet. Wenn du unsicher bist, lehne ab und lass die Anfrage neu formulieren."
27+
: preview;
28+
return
29+
$"{displayName} möchte etwas auf diesem Windows-PC ausführen.\r\n\r\n" +
30+
"Technische Details:\r\n" +
31+
(string.IsNullOrWhiteSpace(command) ? "(kein Befehl angegeben)" : command) +
32+
"\r\n" +
33+
$"Shell: {(string.IsNullOrWhiteSpace(shell) ? "automatisch" : shell)}" +
34+
"\r\n" +
35+
$"Policy: {(string.IsNullOrWhiteSpace(reason) ? "Freigabe erforderlich" : reason)}" +
36+
"\r\n\r\n" +
37+
$"Worum es geht (von {displayName} beschrieben):\r\n" +
38+
summary +
39+
"\r\n\r\n" +
40+
"Sicherheitsgrenze: Policy und konfigurierte Sandbox-Regeln bleiben maßgeblich. Diese Beschreibung ersetzt nicht die technische Prüfung durch den Hub.";
41+
}
42+
43+
var englishSummary = string.IsNullOrWhiteSpace(preview)
44+
? "The agent did not include a plain-language description. Deny if unsure and ask it to retry with a clearer summary."
45+
: preview;
46+
return
47+
$"{displayName} needs approval before a remote agent can run something on this Windows machine.\r\n\r\n" +
48+
"Technical details:\r\n" +
49+
(string.IsNullOrWhiteSpace(command) ? "(no command supplied)" : command) +
50+
"\r\n" +
51+
$"Shell: {(string.IsNullOrWhiteSpace(shell) ? "auto" : shell)}" +
52+
"\r\n" +
53+
$"Policy: {(string.IsNullOrWhiteSpace(reason) ? "Approval required" : reason)}" +
54+
"\r\n\r\n" +
55+
"What this is for (described by the agent):\r\n" +
56+
englishSummary +
57+
"\r\n\r\n" +
58+
"Security boundary: policy and configured sandbox controls remain authoritative. This description does not replace the Hub's technical checks.";
59+
}
60+
61+
private static string Sanitize(string? value, int maxLength)
62+
{
63+
if (string.IsNullOrWhiteSpace(value))
64+
return "";
65+
66+
var safe = ExecApprovalCommandDisplaySanitizer.Sanitize(value).Trim();
67+
return safe.Length <= maxLength ? safe : safe[..(maxLength - 1)] + "…";
68+
}
69+
}
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs
+4-1Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public sealed class CanonicalCommandIdentity
4141
public IReadOnlyDictionary<string, string>? Env { get; }
4242
public string? AgentId { get; }
4343
public string? SessionKey { get; }
44+
public string? CommandPreview { get; }
4445

4546
internal CanonicalCommandIdentity(
4647
IReadOnlyList<string> command,
@@ -53,7 +54,8 @@ internal CanonicalCommandIdentity(
5354
int timeoutMs,
5455
IReadOnlyDictionary<string, string>? env,
5556
string? agentId,
56-
string? sessionKey)
57+
string? sessionKey,
58+
string? commandPreview = null)
5759
{
5860
Command = command;
5961
DisplayCommand = displayCommand;
@@ -66,5 +68,6 @@ internal CanonicalCommandIdentity(
6668
Env = env;
6769
AgentId = agentId;
6870
SessionKey = sessionKey;
71+
CommandPreview = commandPreview;
6972
}
7073
}
Collapse file
+45Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System.Globalization;
2+
using System.Text;
3+
4+
namespace OpenClaw.Shared.ExecApprovals;
5+
6+
/// <summary>
7+
/// Sanitizes agent-supplied approval context while preserving intentional
8+
/// line breaks. Unlike command text, explanatory context is expected to be
9+
/// multi-line; other control, format, and separator characters are escaped.
10+
/// </summary>
11+
public static class ExecApprovalContextDisplaySanitizer
12+
{
13+
public static string Sanitize(string? value, int maxLength = 1_200)
14+
{
15+
if (string.IsNullOrWhiteSpace(value) || maxLength <= 0)
16+
return "";
17+
18+
var output = new StringBuilder(Math.Min(value.Length, maxLength));
19+
foreach (var rune in value.EnumerateRunes())
20+
{
21+
var piece = rune.Value is '\r' or '\n' or '\t'
22+
? rune.ToString()
23+
: Rune.GetUnicodeCategory(rune) is
24+
UnicodeCategory.Control or
25+
UnicodeCategory.Format or
26+
UnicodeCategory.LineSeparator or
27+
UnicodeCategory.ParagraphSeparator
28+
? $@"\u{{{rune.Value:X}}}"
29+
: rune.ToString();
30+
31+
if (output.Length + piece.Length <= maxLength)
32+
{
33+
output.Append(piece);
34+
continue;
35+
}
36+
37+
if (output.Length >= maxLength)
38+
output.Length = maxLength - 1;
39+
output.Append('…');
40+
break;
41+
}
42+
43+
return output.ToString().Trim();
44+
}
45+
}
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs
+19-1Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request
7878
timeoutMs,
7979
env,
8080
TryGetString(request.Args, "agentId"),
81-
TryGetString(request.Args, "sessionKey")));
81+
TryGetString(request.Args, "sessionKey"),
82+
TryGetCommandPreview(request.Args)));
8283
}
8384

8485
private static ExecApprovalV2ValidationOutcome Deny(string reason)
@@ -134,4 +135,21 @@ private static ExecApprovalV2ValidationOutcome Deny(string reason)
134135
return null;
135136
return el.GetString();
136137
}
138+
139+
private static string? TryGetCommandPreview(JsonElement args)
140+
{
141+
var preview = TryGetString(args, "commandPreview");
142+
if (!string.IsNullOrWhiteSpace(preview))
143+
return preview;
144+
145+
if (args.ValueKind != JsonValueKind.Object ||
146+
!args.TryGetProperty("systemRunPlan", out var plan) ||
147+
plan.ValueKind != JsonValueKind.Object)
148+
{
149+
return null;
150+
}
151+
152+
preview = TryGetString(plan, "commandPreview");
153+
return string.IsNullOrWhiteSpace(preview) ? null : preview;
154+
}
137155
}
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs
+2-1Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest r
7474
request.TimeoutMs,
7575
env,
7676
request.AgentId,
77-
request.SessionKey);
77+
request.SessionKey,
78+
request.CommandPreview);
7879

7980
return ExecApprovalV2NormalizationOutcome.Ok(identity);
8081
}
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public sealed class ExecApprovalV2PromptRequest
1717
public required string AgentId { get; init; }
1818
public string? ResolvedPath { get; init; }
1919
/// <summary>
20+
/// Optional agent-supplied explanation of the request. Presentation context
21+
/// only; <see cref="DisplayCommand"/> remains authoritative.
22+
/// </summary>
23+
public string? CommandPreview { get; init; }
24+
/// <summary>
2025
/// Opaque key scoping AllowOnce/AllowAlways decisions to a conversation session.
2126
/// Minted by the gateway per session; null means no session context is available.
2227
/// Not safe to display — internal identifier only.
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs
+18-2Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ public sealed record ExecApprovalPromptView(
1313
string AgentLabel,
1414
string? CwdText,
1515
string? ExecutablePathText,
16-
bool HasConfusableWarning);
16+
bool HasConfusableWarning)
17+
{
18+
public string? CommandPreviewText { get; init; }
19+
}
1720

1821
/// <summary>
1922
/// Prompt-handler core behind the approval dialog. UI-free: the dispatcher hop and the
@@ -49,6 +52,9 @@ public async Task<ExecApprovalPromptOutcome> PromptAsync(
4952
// including the agent label, so all of them go through the display sanitizer
5053
// before any UI binding to neutralize BiDi and control-character spoofing.
5154
var commandText = ExecApprovalCommandDisplaySanitizer.Sanitize(request.DisplayCommand);
55+
var commandPreviewText = string.IsNullOrWhiteSpace(request.CommandPreview)
56+
? null
57+
: ExecApprovalContextDisplaySanitizer.Sanitize(request.CommandPreview);
5258
var agentLabel = ExecApprovalCommandDisplaySanitizer.Sanitize(request.AgentId);
5359
var cwdText = request.Cwd is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.Cwd);
5460
var pathText = request.ResolvedPath is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.ResolvedPath);
@@ -58,10 +64,19 @@ public async Task<ExecApprovalPromptOutcome> PromptAsync(
5864
// instead so the user knows the command may not read the way it looks.
5965
var hasConfusable =
6066
ExecApprovalConfusableDetector.HasMixedScriptConfusable(commandText)
67+
|| ExecApprovalConfusableDetector.HasMixedScriptConfusable(commandPreviewText)
6168
|| ExecApprovalConfusableDetector.HasMixedScriptConfusable(cwdText)
6269
|| ExecApprovalConfusableDetector.HasMixedScriptConfusable(pathText);
6370

64-
var view = new ExecApprovalPromptView(commandText, agentLabel, cwdText, pathText, hasConfusable);
71+
var view = new ExecApprovalPromptView(
72+
commandText,
73+
agentLabel,
74+
cwdText,
75+
pathText,
76+
hasConfusable)
77+
{
78+
CommandPreviewText = commandPreviewText
79+
};
6580

6681
var tcs = new TaskCompletionSource<ExecApprovalPromptOutcome>(
6782
TaskCreationOptions.RunContinuationsAsynchronously);
@@ -127,4 +142,5 @@ private async Task RunDialogAsync(
127142
tcs.TrySetResult(ExecApprovalPromptOutcome.Deny);
128143
}
129144
}
145+
130146
}
Collapse file

‎src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs‎

Copy file name to clipboardExpand all lines: src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ private static ExecApprovalV2PromptRequest BuildPromptRequest(
374374
Ask = context.Ask,
375375
AgentId = context.AgentId ?? "main",
376376
ResolvedPath = context.Resolution?.ResolvedPath,
377+
CommandPreview = identity.CommandPreview,
377378
SessionKey = identity.SessionKey,
378379
CorrelationId = correlationId,
379380
// Host omitted (no gateway wiring yet)

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.