feat: add structural validation for system.run approvals

Add the first V2 exec-approval input-validation slice for system.run requests.\n\nThis introduces a structural validator and validated request DTO without wiring it into production execution yet, so existing system.run behavior stays unchanged while the next approval-policy phases get a tested foundation.\n\nValidated locally on the PR branch and on the current-master merge result with .\\build.ps1, Shared tests, and Tray tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
AlexAlves87 2026-05-01 23:31:48 +02:00 committed by GitHub
parent a794d5ffb2
commit 49661864b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 842 additions and 0 deletions

View File

@ -0,0 +1,137 @@
using System.Collections.Generic;
using System.Text.Json;
namespace OpenClaw.Shared.ExecApprovals;
/// <summary>
/// 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.
/// </summary>
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<string, string>? 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<string, string>(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<string>();
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<string> { 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();
}
}

View File

@ -0,0 +1,62 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
/// <summary>
/// Structurally-valid system.run input produced by ExecApprovalV2InputValidator.
/// Argv is guaranteed non-empty with a non-blank first element.
/// </summary>
public sealed class ValidatedRunRequest
{
public string[] Argv { get; }
public string? Shell { get; }
public string? Cwd { get; }
public int TimeoutMs { get; }
public IReadOnlyDictionary<string, string>? Env { get; }
public string? AgentId { get; }
public string? SessionKey { get; }
internal ValidatedRunRequest(
string[] argv,
string? shell,
string? cwd,
int timeoutMs,
IReadOnlyDictionary<string, string>? env,
string? agentId,
string? sessionKey)
{
Argv = argv;
Shell = shell;
Cwd = cwd;
TimeoutMs = timeoutMs;
Env = env;
AgentId = agentId;
SessionKey = sessionKey;
}
}
/// <summary>
/// Either a ValidatedRunRequest (IsValid=true) or a typed denial (IsValid=false).
/// Produced by ExecApprovalV2InputValidator; consumed by the coordinator pipeline.
/// </summary>
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);
}

View File

@ -0,0 +1,643 @@
using System.Text.Json;
using Xunit;
using OpenClaw.Shared;
using OpenClaw.Shared.ExecApprovals;
namespace OpenClaw.Shared.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}