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:
parent
a794d5ffb2
commit
49661864b1
@ -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();
|
||||
}
|
||||
}
|
||||
62
src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs
Normal file
62
src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user