diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs
new file mode 100644
index 0000000..b7e2f33
--- /dev/null
+++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs
@@ -0,0 +1,137 @@
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace OpenClaw.Shared.ExecApprovals;
+
+///
+/// Phase 1 of the V2 exec approval pipeline: structural input validation (rail 18, step 1).
+/// Parses a raw NodeInvokeRequest into a ValidatedRunRequest or returns validation-failed.
+/// Does not resolve executables, detect shell wrappers, or evaluate policy.
+///
+public static class ExecApprovalV2InputValidator
+{
+ private const int DefaultTimeoutMs = 30_000;
+
+ public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request)
+ {
+ var argv = TryParseArgv(request.Args, out bool malformedCommand);
+ if (malformedCommand)
+ return Deny("malformed-command");
+ if (argv == null || argv.Length == 0)
+ return Deny("missing-command");
+ if (string.IsNullOrWhiteSpace(argv[0]))
+ return Deny("empty-command");
+
+ // cwd — optional, but empty/whitespace is a caller error; wrong type is a protocol violation
+ string? cwd = null;
+ if (request.Args.ValueKind == JsonValueKind.Object &&
+ request.Args.TryGetProperty("cwd", out var cwdEl))
+ {
+ if (cwdEl.ValueKind != JsonValueKind.String)
+ return Deny("malformed-cwd");
+ var rawCwd = cwdEl.GetString();
+ if (string.IsNullOrWhiteSpace(rawCwd))
+ return Deny("empty-cwd");
+ cwd = rawCwd;
+ }
+
+ // env — must be a JSON object if present; non-string values are a protocol violation
+ IReadOnlyDictionary? env = null;
+ if (request.Args.ValueKind == JsonValueKind.Object &&
+ request.Args.TryGetProperty("env", out var envEl))
+ {
+ if (envEl.ValueKind != JsonValueKind.Object)
+ return Deny("malformed-env");
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var prop in envEl.EnumerateObject())
+ {
+ if (prop.Value.ValueKind != JsonValueKind.String)
+ return Deny("malformed-env");
+ dict[prop.Name] = prop.Value.GetString() ?? "";
+ }
+ env = dict;
+ }
+
+ // timeoutMs / timeout — positive integer; defaults to 30 000.
+ // Upper-bound clamping (legacy safety limit) is enforced in the execution/policy phase, not here.
+ var timeoutMs = DefaultTimeoutMs;
+ if (request.Args.ValueKind == JsonValueKind.Object)
+ {
+ if (request.Args.TryGetProperty("timeoutMs", out var tmsEl))
+ {
+ if (tmsEl.ValueKind != JsonValueKind.Number || !tmsEl.TryGetInt32(out var v) || v <= 0)
+ return Deny("invalid-timeout");
+ timeoutMs = v;
+ }
+ else if (request.Args.TryGetProperty("timeout", out var tEl))
+ {
+ if (tEl.ValueKind != JsonValueKind.Number || !tEl.TryGetInt32(out var v) || v <= 0)
+ return Deny("invalid-timeout");
+ timeoutMs = v;
+ }
+ }
+
+ return ExecApprovalV2ValidationOutcome.Ok(new ValidatedRunRequest(
+ argv,
+ TryGetString(request.Args, "shell"),
+ cwd,
+ timeoutMs,
+ env,
+ TryGetString(request.Args, "agentId"),
+ TryGetString(request.Args, "sessionKey")));
+ }
+
+ private static ExecApprovalV2ValidationOutcome Deny(string reason)
+ => ExecApprovalV2ValidationOutcome.Fail(ExecApprovalV2Result.ValidationFailed(reason));
+
+ private static string[]? TryParseArgv(JsonElement args, out bool malformed)
+ {
+ malformed = false;
+ if (args.ValueKind != JsonValueKind.Object ||
+ !args.TryGetProperty("command", out var cmdEl))
+ return null;
+
+ if (cmdEl.ValueKind == JsonValueKind.Array)
+ {
+ var list = new List();
+ foreach (var item in cmdEl.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.String) { malformed = true; return null; }
+ list.Add(item.GetString() ?? "");
+ }
+ return list.Count > 0 ? [.. list] : null;
+ }
+
+ if (cmdEl.ValueKind == JsonValueKind.String)
+ {
+ var cmd = cmdEl.GetString();
+ if (string.IsNullOrWhiteSpace(cmd)) return null;
+
+ // Also merge a separate "args" array when command is a bare string.
+ // A non-array "args" value is a protocol violation.
+ if (args.TryGetProperty("args", out var argsEl))
+ {
+ if (argsEl.ValueKind != JsonValueKind.Array) { malformed = true; return null; }
+ var list = new List { cmd };
+ foreach (var item in argsEl.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.String) { malformed = true; return null; }
+ list.Add(item.GetString() ?? "");
+ }
+ return [.. list];
+ }
+ return [cmd];
+ }
+
+ return null;
+ }
+
+ private static string? TryGetString(JsonElement args, string key)
+ {
+ if (args.ValueKind != JsonValueKind.Object ||
+ !args.TryGetProperty(key, out var el) ||
+ el.ValueKind != JsonValueKind.String)
+ return null;
+ return el.GetString();
+ }
+}
diff --git a/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs
new file mode 100644
index 0000000..d93c908
--- /dev/null
+++ b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+
+namespace OpenClaw.Shared.ExecApprovals;
+
+///
+/// Structurally-valid system.run input produced by ExecApprovalV2InputValidator.
+/// Argv is guaranteed non-empty with a non-blank first element.
+///
+public sealed class ValidatedRunRequest
+{
+ public string[] Argv { get; }
+ public string? Shell { get; }
+ public string? Cwd { get; }
+ public int TimeoutMs { get; }
+ public IReadOnlyDictionary? Env { get; }
+ public string? AgentId { get; }
+ public string? SessionKey { get; }
+
+ internal ValidatedRunRequest(
+ string[] argv,
+ string? shell,
+ string? cwd,
+ int timeoutMs,
+ IReadOnlyDictionary? env,
+ string? agentId,
+ string? sessionKey)
+ {
+ Argv = argv;
+ Shell = shell;
+ Cwd = cwd;
+ TimeoutMs = timeoutMs;
+ Env = env;
+ AgentId = agentId;
+ SessionKey = sessionKey;
+ }
+}
+
+///
+/// Either a ValidatedRunRequest (IsValid=true) or a typed denial (IsValid=false).
+/// Produced by ExecApprovalV2InputValidator; consumed by the coordinator pipeline.
+///
+public sealed class ExecApprovalV2ValidationOutcome
+{
+ public bool IsValid { get; }
+ public ValidatedRunRequest? Request { get; }
+ public ExecApprovalV2Result? Error { get; }
+
+ private ExecApprovalV2ValidationOutcome(ValidatedRunRequest request)
+ {
+ IsValid = true;
+ Request = request;
+ }
+
+ private ExecApprovalV2ValidationOutcome(ExecApprovalV2Result error)
+ {
+ IsValid = false;
+ Error = error;
+ }
+
+ public static ExecApprovalV2ValidationOutcome Ok(ValidatedRunRequest r) => new(r);
+ public static ExecApprovalV2ValidationOutcome Fail(ExecApprovalV2Result e) => new(e);
+}
diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs
new file mode 100644
index 0000000..15b267d
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/ExecApprovalV2InputValidationTests.cs
@@ -0,0 +1,643 @@
+using System.Text.Json;
+using Xunit;
+using OpenClaw.Shared;
+using OpenClaw.Shared.ExecApprovals;
+
+namespace OpenClaw.Shared.Tests;
+
+///
+/// Tests for PR2: input validation phase of the V2 exec approval pipeline.
+/// Covers structural validation only: allow, deny, malformed input.
+/// No shell wrapper detection, executable resolution, or evaluation.
+///
+public class ExecApprovalV2InputValidationTests
+{
+ private static JsonElement Parse(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+
+ private static NodeInvokeRequest Req(string argsJson)
+ => new() { Id = "r1", Command = "system.run", Args = Parse(argsJson) };
+
+ // -------------------------------------------------------------------------
+ // Allow paths
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Valid_ArrayCommand_ReturnsOk()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":["echo","hello"]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(["echo", "hello"], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void Valid_StringCommand_ReturnsOk()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":"echo"}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(["echo"], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void Valid_StringCommandWithSeparateArgs_MergesArgv()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","args":["hello","world"]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(["echo", "hello", "world"], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void Valid_AllOptionalFields_Parsed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""
+ {
+ "command": ["git", "status"],
+ "cwd": "C:\\repo",
+ "shell": "pwsh",
+ "timeoutMs": 5000,
+ "env": {"MY_VAR": "value"},
+ "agentId": "agent-1",
+ "sessionKey": "sess-abc"
+ }
+ """));
+
+ Assert.True(outcome.IsValid);
+ var r = outcome.Request!;
+ Assert.Equal(["git", "status"], r.Argv);
+ Assert.Equal("C:\\repo", r.Cwd);
+ Assert.Equal("pwsh", r.Shell);
+ Assert.Equal(5000, r.TimeoutMs);
+ Assert.Equal("value", r.Env!["MY_VAR"]);
+ Assert.Equal("agent-1", r.AgentId);
+ Assert.Equal("sess-abc", r.SessionKey);
+ }
+
+ [Fact]
+ public void Valid_DefaultTimeout_Is30000()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":"echo"}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(30_000, outcome.Request!.TimeoutMs);
+ }
+
+ [Fact]
+ public void Valid_LegacyTimeoutKey_Accepted()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeout":10000}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(10_000, outcome.Request!.TimeoutMs);
+ }
+
+ [Fact]
+ public void Valid_EmptyEnvObject_ReturnsOk()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":{}}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.NotNull(outcome.Request!.Env);
+ Assert.Empty(outcome.Request.Env!);
+ }
+
+ // -------------------------------------------------------------------------
+ // Deny paths
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void MissingCommand_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"cwd":"/tmp"}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, outcome.Error!.Code);
+ Assert.Equal("missing-command", outcome.Error.Reason);
+ }
+
+ [Fact]
+ public void EmptyArrayCommand_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":[]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, outcome.Error!.Code);
+ Assert.Equal("missing-command", outcome.Error.Reason);
+ }
+
+ [Fact]
+ public void WhitespaceCommand_ValidationFailed()
+ {
+ // Whitespace-only string command → IsNullOrWhiteSpace → TryParseArgv returns null → missing-command
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":" "}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, outcome.Error!.Code);
+ Assert.Equal("missing-command", outcome.Error.Reason);
+ }
+
+ [Fact]
+ public void WhitespaceFirstArgvElement_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":[" ","arg"]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("empty-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void EmptyCwd_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","cwd":""}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("empty-cwd", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void WhitespaceCwd_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","cwd":" "}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("empty-cwd", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void EnvNotObject_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":"bad"}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-env", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void EnvArray_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":["a","b"]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-env", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void NegativeTimeout_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeoutMs":-1}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("invalid-timeout", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ZeroTimeout_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeoutMs":0}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("invalid-timeout", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void NegativeLegacyTimeout_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeout":-5000}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("invalid-timeout", outcome.Error!.Reason);
+ }
+
+ // -------------------------------------------------------------------------
+ // Malformed / edge-case inputs
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void CommandIsNumber_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":42}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void CommandIsNull_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":null}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void EnvWithNonStringValues_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":{"A":"ok","B":42,"C":true}}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-env", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void AbsentOptionalFields_AreNull()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":"echo"}"""));
+
+ Assert.True(outcome.IsValid);
+ var r = outcome.Request!;
+ Assert.Null(r.Shell);
+ Assert.Null(r.Cwd);
+ Assert.Null(r.Env);
+ Assert.Null(r.AgentId);
+ Assert.Null(r.SessionKey);
+ }
+
+ // -------------------------------------------------------------------------
+ // Error outcome shape
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void FailOutcome_ErrorIsNotNull_RequestIsNull()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.NotNull(outcome.Error);
+ Assert.Null(outcome.Request);
+ }
+
+ [Fact]
+ public void OkOutcome_RequestIsNotNull_ErrorIsNull()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("""{"command":"echo"}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.NotNull(outcome.Request);
+ Assert.Null(outcome.Error);
+ }
+
+ // -------------------------------------------------------------------------
+ // 9. Argv trim
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ArrayCommand_ElementsPreservedExactly()
+ {
+ // argv elements are not trimmed; spaces are meaningful in arguments
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":[" echo "," hello "]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal([" echo ", " hello "], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void StringCommand_PreservedExactly()
+ {
+ // argv[0] from a string command is stored as-is; whitespace check uses IsNullOrWhiteSpace
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":" echo "}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal([" echo "], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void SeparateArgs_PreservedExactly()
+ {
+ // argv elements (including separate args) are not trimmed
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":" echo ","args":[" hello "," world "]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal([" echo ", " hello ", " world "], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void ArrayCommand_WhitespaceOnlyFirstElement_EmptyCommand()
+ {
+ // argv[0] is whitespace-only → IsNullOrWhiteSpace check → empty-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":[" ","arg"]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("empty-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArrayCommand_WhitespaceOnlyNonFirstElement_PreservedAsIs()
+ {
+ // argv[1+] are not trimmed and not checked for whitespace — " " is a valid argument
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":["echo"," "]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(["echo", " "], outcome.Request!.Argv);
+ }
+
+ [Fact]
+ public void StringCommand_WhitespaceOnly_MissingCommand()
+ {
+ // Whitespace-only string command → IsNullOrWhiteSpace → TryParseArgv returns null → missing-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":" "}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ // -------------------------------------------------------------------------
+ // 10. Command array — mixed element types
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ArrayCommand_NonStringElement_ValidationFailed()
+ {
+ // 42 is not JsonValueKind.String → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":["echo",42,true]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArrayCommand_NullElement_ValidationFailed()
+ {
+ // null (JsonValueKind.Null) is not String → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":[null,"x"]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArrayCommand_OnlyNonStringElements_ValidationFailed()
+ {
+ // All elements are non-string → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":[42,true]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArrayCommand_SingleNull_ValidationFailed()
+ {
+ // [null] is non-string → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":[null]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ // -------------------------------------------------------------------------
+ // 11. Separate args array — mixed element types
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void SeparateArgs_NonStringElement_ValidationFailed()
+ {
+ // 1, null, true are not String → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","args":["ok",1,null,true]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void SeparateArgs_NoStringElements_ValidationFailed()
+ {
+ // args contains only non-string elements → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","args":[1,null,true]}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void SeparateArgs_NotAnArray_ValidationFailed()
+ {
+ // args present but not an array → protocol violation → malformed-command
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","args":"hello"}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void SeparateArgs_NotAnArray_ObjectValue_ValidationFailed()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","args":{"x":1}}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-command", outcome.Error!.Reason);
+ }
+
+ // -------------------------------------------------------------------------
+ // 12. Timeout field precedence and upper-bound deferral
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Timeout_TimeoutMsWinsWhenBothPresent()
+ {
+ // timeoutMs is checked first (if branch); timeout is the else-branch and is never read
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeoutMs":5000,"timeout":9999}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(5000, outcome.Request!.TimeoutMs);
+ }
+
+ [Fact]
+ public void Timeout_InvalidTimeoutMs_DeniesEvenIfTimeoutIsValid()
+ {
+ // timeoutMs is invalid → deny; timeout (valid) is never reached (else-branch)
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeoutMs":-1,"timeout":5000}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("invalid-timeout", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void LargeTimeout_PassesStructuralValidation()
+ {
+ // Upper-bound clamping (legacy safety limit) is enforced in the execution/policy phase.
+ // Structural validation accepts any positive integer.
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","timeoutMs":86400000}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal(86_400_000, outcome.Request!.TimeoutMs);
+ }
+
+ // -------------------------------------------------------------------------
+ // 13. Optional string fields — non-string JSON values treated as absent
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void CwdWrongType_ValidationFailed()
+ {
+ // cwd affects execution semantics → wrong type is a protocol violation → malformed-cwd
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","cwd":123}"""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("malformed-cwd", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ShellWrongType_TreatedAsAbsent()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","shell":true}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Null(outcome.Request!.Shell);
+ }
+
+ [Fact]
+ public void AgentIdWrongType_TreatedAsAbsent()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","agentId":{}}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Null(outcome.Request!.AgentId);
+ }
+
+ [Fact]
+ public void SessionKeyWrongType_TreatedAsAbsent()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","sessionKey":[]}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Null(outcome.Request!.SessionKey);
+ }
+
+ // -------------------------------------------------------------------------
+ // 14. Env — less-trivial combinations
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Env_EmptyStringValue_Accepted()
+ {
+ // GetString() ?? "" → empty string is a valid value, not skipped
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":{"A":""}}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Equal("", outcome.Request!.Env!["A"]);
+ }
+
+ [Fact]
+ public void Env_DuplicateKeysByCasing_LastValueWins()
+ {
+ // StringComparer.OrdinalIgnoreCase: second assignment updates the same slot
+ var outcome = ExecApprovalV2InputValidator.Validate(
+ Req("""{"command":"echo","env":{"PATH":"/usr/bin","path":"/sbin"}}"""));
+
+ Assert.True(outcome.IsValid);
+ Assert.Single(outcome.Request!.Env!);
+ Assert.Equal("/sbin", outcome.Request!.Env!["PATH"]);
+ }
+
+ // -------------------------------------------------------------------------
+ // 15. Args root is not a JSON object
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ArgsRootIsArray_MissingCommand()
+ {
+ // TryGetProperty("command") returns false on a JSON array → argv null → missing-command
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("[]"));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArgsRootIsString_MissingCommand()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("\"hello\""));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArgsRootIsNumber_MissingCommand()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("42"));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ [Fact]
+ public void ArgsRootIsNull_MissingCommand()
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(Req("null"));
+
+ Assert.False(outcome.IsValid);
+ Assert.Equal("missing-command", outcome.Error!.Reason);
+ }
+
+ // -------------------------------------------------------------------------
+ // 16. Validation code invariant
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EveryFailure_ProducesValidationFailedCode()
+ {
+ // The Deny() helper in this validator always wraps ValidationFailed.
+ // No failure path produces SecurityDeny, ResolutionFailed, or any other code.
+ var cases = new[]
+ {
+ Req("""{}"""), // missing-command
+ Req("""{"command":[]}"""), // missing-command (empty array)
+ Req("""{"command":" "}"""), // missing-command (trims to empty)
+ Req("""{"command":["echo",42]}"""), // malformed-command
+ Req("""{"command":"echo","args":[1]}"""), // malformed-command (non-string element)
+ Req("""{"command":"echo","args":"hello"}"""), // malformed-command (args not an array)
+ Req("""{"command":"echo","cwd":""}"""), // empty-cwd
+ Req("""{"command":"echo","cwd":123}"""), // malformed-cwd
+ Req("""{"command":"echo","env":"bad"}"""), // malformed-env
+ Req("""{"command":"echo","env":{"A":42}}"""), // malformed-env (non-string value)
+ Req("""{"command":"echo","timeoutMs":-1}"""), // invalid-timeout
+ };
+
+ foreach (var req in cases)
+ {
+ var outcome = ExecApprovalV2InputValidator.Validate(req);
+ Assert.False(outcome.IsValid);
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, outcome.Error!.Code);
+ }
+ }
+}