Skip to content

Commit 3e00346

Browse files
AlexAlves87Copilot
andauthored
Execute approved exec-approval commands as resolved argv (#799)
- Adds shell-free direct-argv execution payloads for approved exec-approval commands. - Fails closed for unresolved executables, batch scripts, modified env wrappers, nested modifiers, and sandbox transports that cannot carry argv faithfully. - Maintainer hardening added: timeout clamp, immutable payload collections, and canonical ToCommandRequest mapping. Validation: - Dual-model adversarial review: no blocking issues after maintainer hardening - Autoreview: clean - ./build.ps1 - dotnet test ./tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj --no-restore - dotnet test ./tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj --no-restore - GitHub Build and Test checks passed, including test, e2e, win-x64, and win-arm64 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58e363d commit 3e00346

9 files changed

Lines changed: 807 additions & 19 deletions

File tree

src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.Linq;
5+
using OpenClaw.Shared;
6+
17
namespace OpenClaw.Shared.ExecApprovals;
28

9+
/// <summary>
10+
/// What the caller must execute after an allow. <see cref="Argv"/> is the validated
11+
/// argument vector and <see cref="Env"/> is the sanitized environment built during
12+
/// evaluation. Neither may be re-derived from the raw request, and the argv must
13+
/// reach the process without a shell re-parsing it. The constructor takes defensive
14+
/// copies so the approved argv/env cannot be mutated through an aliased reference
15+
/// between approval and execution.
16+
/// </summary>
17+
public sealed record ExecApprovedExecution
18+
{
19+
public const int MaxTimeoutMs = 600_000;
20+
21+
public IReadOnlyList<string> Argv { get; }
22+
public string? Cwd { get; }
23+
public int TimeoutMs { get; }
24+
public IReadOnlyDictionary<string, string>? Env { get; }
25+
26+
public ExecApprovedExecution(
27+
IReadOnlyList<string> argv,
28+
string? cwd,
29+
int timeoutMs,
30+
IReadOnlyDictionary<string, string>? env)
31+
{
32+
ArgumentNullException.ThrowIfNull(argv);
33+
if (argv.Count == 0)
34+
throw new ArgumentException("Approved execution requires a non-empty argv.", nameof(argv));
35+
if (timeoutMs <= 0)
36+
throw new ArgumentOutOfRangeException(nameof(timeoutMs), timeoutMs, "Approved execution timeout must be positive.");
37+
38+
Argv = Array.AsReadOnly(argv.ToArray());
39+
Cwd = cwd;
40+
TimeoutMs = Math.Min(timeoutMs, MaxTimeoutMs);
41+
Env = env is null
42+
? null
43+
: new ReadOnlyDictionary<string, string>(
44+
new Dictionary<string, string>(env, StringComparer.OrdinalIgnoreCase));
45+
}
46+
47+
public CommandRequest ToCommandRequest() => new()
48+
{
49+
Argv = Argv,
50+
Cwd = Cwd,
51+
TimeoutMs = TimeoutMs,
52+
Env = Env is null
53+
? null
54+
: new Dictionary<string, string>(Env, StringComparer.OrdinalIgnoreCase),
55+
};
56+
}
57+
358
/// <summary>
459
/// Stable result codes for the V2 exec approval path.
560
/// </summary>
@@ -25,10 +80,25 @@ public sealed class ExecApprovalV2Result
2580
public ExecApprovalV2Code Code { get; }
2681
public string Reason { get; }
2782

28-
private ExecApprovalV2Result(ExecApprovalV2Code code, string reason)
83+
/// <summary>
84+
/// The command to execute. Non-null only on <see cref="ExecApprovalV2Code.Allow"/>.
85+
/// Carries the validated argv and sanitized env so the caller never re-derives
86+
/// them from the raw request.
87+
/// </summary>
88+
public ExecApprovedExecution? Execution { get; }
89+
90+
private ExecApprovalV2Result(ExecApprovalV2Code code, string reason, ExecApprovedExecution? execution = null)
2991
{
92+
// Invariant: Allow must carry a payload; non-Allow must not. A null payload
93+
// on Allow (or a payload on a deny) is a bug, not a representable state.
94+
if (code == ExecApprovalV2Code.Allow && execution is null)
95+
throw new ArgumentNullException(nameof(execution), "Allow result requires an execution payload.");
96+
if (code != ExecApprovalV2Code.Allow && execution is not null)
97+
throw new ArgumentException("Non-allow result must not carry an execution payload.", nameof(execution));
98+
3099
Code = code;
31100
Reason = reason;
101+
Execution = execution;
32102
}
33103

34104
public static ExecApprovalV2Result Unavailable(string reason = "Handler not available")
@@ -55,8 +125,16 @@ public static ExecApprovalV2Result ResolutionFailed(string reason)
55125
public static ExecApprovalV2Result InternalError(string reason)
56126
=> new(ExecApprovalV2Code.InternalError, reason);
57127

58-
public static ExecApprovalV2Result Allow()
59-
=> new(ExecApprovalV2Code.Allow, "approved");
128+
/// <summary>
129+
/// Approve the command and carry the execution payload the caller must run.
130+
/// An allow without a payload is not a valid state — there is intentionally
131+
/// no parameterless allow: the approved argv must reach the process verbatim.
132+
/// </summary>
133+
public static ExecApprovalV2Result Allow(ExecApprovedExecution execution)
134+
{
135+
ArgumentNullException.ThrowIfNull(execution);
136+
return new(ExecApprovalV2Code.Allow, "approved", execution);
137+
}
60138

61139
public bool IsAllow => Code == ExecApprovalV2Code.Allow;
62140

src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,19 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
100100
if (pass1 is ExecHostPolicyDecision.AllowOutcome)
101101
{
102102
// Pre-approved path (security=Full, ask=Off or allowlist satisfied): skip prompt.
103+
// Fail closed if the approved executable cannot be pinned to a resolved path.
104+
var preApprovedExecution = BuildApprovedExecution(identity, sanitizedEnv);
105+
if (preApprovedExecution is null)
106+
return LogAndReturn(ExecApprovalV2Result.InternalError("unresolved-executable-on-allow"),
107+
correlationId, promptAttempted: false, fallbackUsed: false, canonical: context.DisplayCommand);
108+
103109
// Side effects are best-effort: a metadata write failure must not flip an allow to a deny.
104110
try { await RecordAllowlistUsageAsync(context).ConfigureAwait(false); }
105111
catch (Exception ex) { _logger.Warn($"[EXEC-APPROVALS] [{correlationId}] side-effect: record-usage failed (non-fatal): {ex.Message}"); }
106112
_logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " +
107113
$"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " +
108114
$"reason=approved fallbackUsed=false promptAttempted=false");
109-
return ExecApprovalV2Result.Allow();
115+
return ExecApprovalV2Result.Allow(preApprovedExecution);
110116
}
111117
// RequiresPromptOutcome → continue to prompt/fallback block
112118

@@ -186,7 +192,14 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
186192
_promptLock.Release();
187193
}
188194

189-
// Step 8: side effects — strictly after the final allow decision.
195+
// Step 8: build payload before any store writes — a fail-closed payload result
196+
// must not leave persistent allowlist state behind.
197+
var execution = BuildApprovedExecution(identity, sanitizedEnv);
198+
if (execution is null)
199+
return LogAndReturn(ExecApprovalV2Result.InternalError("unresolved-executable-on-allow"),
200+
correlationId, promptAttempted, fallbackUsed, canonical: context.DisplayCommand);
201+
202+
// Step 9: side effects — only reached when the payload is valid.
190203
// Each side effect is independently best-effort so a failure in one does not skip the other.
191204
if (persistAllowlistEntry && context.Security == ExecSecurity.Allowlist)
192205
{
@@ -196,13 +209,13 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
196209
try { await RecordAllowlistUsageAsync(context).ConfigureAwait(false); }
197210
catch (Exception ex) { _logger.Warn($"[EXEC-APPROVALS] [{correlationId}] side-effect: record-usage failed (non-fatal): {ex.Message}"); }
198211

199-
// Step 9: final allow log
212+
// Step 10: final allow log
200213
_logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " +
201214
$"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " +
202215
$"reason=approved fallbackUsed={fallbackUsed} promptAttempted={promptAttempted}");
203216

204217
// Step 10: return Allow
205-
return ExecApprovalV2Result.Allow();
218+
return ExecApprovalV2Result.Allow(execution);
206219
}
207220
catch (Exception ex)
208221
{
@@ -217,6 +230,50 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
217230
}
218231
}
219232

233+
// Builds the approved execution payload from the RESOLVED executable path, never
234+
// the raw argv[0]. The command must execute with the same canonical identity it
235+
// was evaluated under: a relative argv[0] in the payload would let Windows
236+
// re-resolve it against PATH/cwd at execution time (a hijack), and the
237+
// direct-argv runner rejects non-absolute executables anyway. Returns null when
238+
// the executable could not be resolved to a path — the caller fails closed
239+
// rather than execute a command whose identity we cannot pin.
240+
internal static ExecApprovedExecution? BuildApprovedExecution(
241+
CanonicalCommandIdentity identity,
242+
IReadOnlyDictionary<string, string>? sanitizedEnv)
243+
{
244+
var resolvedPath = identity.Resolution?.ResolvedPath;
245+
if (string.IsNullOrEmpty(resolvedPath))
246+
return null;
247+
248+
// A batch script (.bat/.cmd) cannot run without cmd.exe, which re-parses the
249+
// arguments and breaks the verbatim-argv guarantee. The direct-argv runner
250+
// rejects these too; reject here as well so the fail-closed result is reached
251+
// before any approval state is written, not after.
252+
if (resolvedPath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)
253+
|| resolvedPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase))
254+
return null;
255+
256+
// If any env wrapper in the chain carries modifiers (VAR=val assignments or
257+
// flags), the direct-argv payload cannot faithfully carry those semantics: the
258+
// modifier would be silently dropped, and the process would run in a different
259+
// environment than the one that was approved. This walks the full unwrap chain
260+
// so a nested form such as `env env FOO=bar node` is caught, not just the outer
261+
// wrapper. Fail closed rather than execute a command that differs from what was
262+
// evaluated.
263+
if (ExecEnvInvocationUnwrapper.AnyWrapperHasModifiers(identity.Command))
264+
return null;
265+
266+
// Transparent env wrappers (no modifiers) are safe to unwrap: the inner
267+
// command is the real executable and the args are preserved verbatim.
268+
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(identity.Command);
269+
var argv = new string[effective.Count];
270+
argv[0] = resolvedPath;
271+
for (var i = 1; i < effective.Count; i++)
272+
argv[i] = effective[i];
273+
274+
return new ExecApprovedExecution(argv, identity.Cwd, identity.TimeoutMs, sanitizedEnv);
275+
}
276+
220277
// Persists allowAlways patterns after an AllowAlways prompt decision (non-empty only).
221278
// Caller guarantees Security == Allowlist (guard is in HandleAsync step 8).
222279
private async Task PersistAllowlistEntriesAsync(ExecApprovalEvaluation context)

src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ internal static bool HasModifiers(IReadOnlyList<string> command)
8181
return false;
8282
}
8383

84+
// Returns true when any env wrapper in the full unwrap chain carries modifiers
85+
// (VAR=val assignments or flags), including nested forms such as `env env FOO=bar node`.
86+
// UnwrapForResolution strips every level for resolution, so checking only the outer
87+
// wrapper would let an inner modifier slip through and be dropped from execution.
88+
internal static bool AnyWrapperHasModifiers(IReadOnlyList<string> command)
89+
{
90+
var current = command;
91+
for (var depth = 0; depth < MaxWrapperDepth; depth++)
92+
{
93+
if (current.Count == 0) break;
94+
var token = current[0].Trim();
95+
if (token.Length == 0) break;
96+
if (!ExecCommandToken.IsEnv(token)) break;
97+
if (HasModifiers(current)) return true;
98+
var unwrapped = Unwrap(current);
99+
if (unwrapped is null || unwrapped.Count == 0) break;
100+
current = unwrapped;
101+
}
102+
return false;
103+
}
104+
84105
// Iteratively strips env wrappers for executable resolution only.
85106
internal static IReadOnlyList<string> UnwrapForResolution(IReadOnlyList<string> command)
86107
{

src/OpenClaw.Shared/ICommandRunner.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ public class CommandRequest
1212
{
1313
/// <summary>The command to execute (e.g., "echo hello" or "Get-Process")</summary>
1414
public string Command { get; set; } = "";
15-
15+
16+
/// <summary>
17+
/// When set, execute this argv directly with no shell between policy and the
18+
/// process: FileName = Argv[0], the rest go through ProcessStartInfo.ArgumentList
19+
/// verbatim. Takes precedence over Command/Args/Shell, which are ignored.
20+
/// Null = legacy shell-wrapped path.
21+
/// </summary>
22+
public IReadOnlyList<string>? Argv { get; set; }
23+
1624
/// <summary>Optional arguments array</summary>
1725
public string[]? Args { get; set; }
1826

0 commit comments

Comments
 (0)