139 lines
5.7 KiB
C#
139 lines
5.7 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Dada.Supervisor;
|
|
|
|
internal static partial class ControlledExternalValidationLauncher
|
|
{
|
|
private static readonly HashSet<string> AllowedModels =
|
|
[
|
|
"gemini-3.1-flash-image-preview",
|
|
"gemini-3-pro-image-preview",
|
|
"gpt-image-2",
|
|
];
|
|
|
|
private static readonly HashSet<string> ValueOptions =
|
|
[
|
|
"--candidate-record",
|
|
"--config-manifest",
|
|
"--evidence-dir",
|
|
"--max-real-calls",
|
|
"--model",
|
|
"--run-id",
|
|
"--service",
|
|
];
|
|
|
|
private static readonly HashSet<string> SwitchOptions =
|
|
[
|
|
"--confirm-controlled-real",
|
|
"--execute-controlled-real",
|
|
];
|
|
|
|
internal static async Task<int> RunAsync(string[] args, ICredentialStore credentials, CancellationToken cancellationToken = default)
|
|
{
|
|
var validated = ValidateArguments(args);
|
|
var script = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "scripts", "validate-external.mjs"));
|
|
if (!File.Exists(script)) throw new InvalidOperationException("external_validator_not_found");
|
|
var startInfo = new ProcessStartInfo("node") { WorkingDirectory = Environment.CurrentDirectory };
|
|
startInfo.ArgumentList.Add(script);
|
|
foreach (var value in validated) startInfo.ArgumentList.Add(value);
|
|
startInfo.ArgumentList.Add("--credential-stdin");
|
|
|
|
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
|
|
if (result.SensitiveOutputDetected || !TrySelectSanitizedJson(result, out var output, out var useError))
|
|
{
|
|
Console.Error.WriteLine("{\"code\":\"external_validator_output_invalid\",\"real_calls\":0,\"status\":\"failed\"}");
|
|
return 1;
|
|
}
|
|
if (useError) Console.Error.WriteLine(output); else Console.WriteLine(output);
|
|
return result.ExitCode;
|
|
}
|
|
|
|
internal static string[] ValidateArguments(string[] args)
|
|
{
|
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var switches = new HashSet<string>(StringComparer.Ordinal);
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
var option = args[index];
|
|
if (SwitchOptions.Contains(option))
|
|
{
|
|
if (!switches.Add(option)) throw new ArgumentException("external_validator_argument_duplicate");
|
|
continue;
|
|
}
|
|
if (!ValueOptions.Contains(option) || index + 1 >= args.Length || !values.TryAdd(option, args[++index]))
|
|
{
|
|
throw new ArgumentException("external_validator_argument_invalid");
|
|
}
|
|
}
|
|
if (values.GetValueOrDefault("--service") != "ai-gateway-service-id"
|
|
|| !AllowedModels.Contains(values.GetValueOrDefault("--model") ?? string.Empty)
|
|
|| !SafeRunId().IsMatch(values.GetValueOrDefault("--run-id") ?? string.Empty)
|
|
|| values.GetValueOrDefault("--max-real-calls") != "120"
|
|
|| !values.ContainsKey("--candidate-record")
|
|
|| !values.ContainsKey("--config-manifest")
|
|
|| !values.ContainsKey("--evidence-dir")
|
|
|| !switches.SetEquals(SwitchOptions))
|
|
{
|
|
throw new ArgumentException("external_validator_argument_invalid");
|
|
}
|
|
if (values.Values.Any(value => value.Length == 0 || value.IndexOfAny(['\r', '\n', '\0']) >= 0))
|
|
{
|
|
throw new ArgumentException("external_validator_argument_invalid");
|
|
}
|
|
return args.ToArray();
|
|
}
|
|
|
|
private static bool TrySelectSanitizedJson(CredentialProcessResult result, out string output, out bool useError)
|
|
{
|
|
var stdout = result.StandardOutput.Trim();
|
|
var stderr = result.StandardError.Trim();
|
|
useError = stdout.Length == 0;
|
|
output = useError ? stderr : stdout;
|
|
if (output.Length == 0 || (stdout.Length > 0 && stderr.Length > 0)) return false;
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(output);
|
|
return IsSanitized(document.RootElement);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsSanitized(JsonElement element)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (ForbiddenKey().IsMatch(property.Name) || property.NameEquals("verified") || !IsSanitized(property.Value)) return false;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in element.EnumerateArray()) if (!IsSanitized(item)) return false;
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.String)
|
|
{
|
|
var value = element.GetString() ?? string.Empty;
|
|
if (WindowsUserPath().IsMatch(value) || BearerValue().IsMatch(value)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
[GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$", RegexOptions.CultureInvariant)]
|
|
private static partial Regex SafeRunId();
|
|
|
|
[GeneratedRegex("(?:^|_)(?:absolute_path|authorization|body|credential|image|password|path|prompt|raw|secret|token)(?:_|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
|
private static partial Regex ForbiddenKey();
|
|
|
|
[GeneratedRegex("[A-Za-z]:\\\\Users\\\\", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
|
private static partial Regex WindowsUserPath();
|
|
|
|
[GeneratedRegex("(?:Bearer\\s+|\\bsk-[A-Za-z0-9_-]{8,})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
|
private static partial Regex BearerValue();
|
|
}
|