feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record OpenClawWriteGateDecision(
|
||||
bool Allowed,
|
||||
string State,
|
||||
string Message,
|
||||
string? Recovery = null)
|
||||
{
|
||||
public static OpenClawWriteGateDecision Permit()
|
||||
=> new(true, "available", "OpenClaw write boundary verified.");
|
||||
|
||||
public static OpenClawWriteGateDecision Block(
|
||||
string state,
|
||||
string message,
|
||||
string? recovery = null)
|
||||
=> new(false, state, message, recovery);
|
||||
}
|
||||
|
||||
public interface IOpenClawWriteGate
|
||||
{
|
||||
Task<OpenClawWriteGateDecision> EvaluateAsync(
|
||||
string method,
|
||||
string requiredScope = "operator.admin",
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds every OpenClaw write to the one adopted primary profile. This is a
|
||||
/// local policy boundary in addition to OpenClaw's own scope enforcement.
|
||||
/// </summary>
|
||||
public sealed class OpenClawWriteGate(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IGatewayConnector connector,
|
||||
IOptions<GatewayConnectorOptions> gatewayOptions,
|
||||
IConfiguration configuration) : IOpenClawWriteGate
|
||||
{
|
||||
public async Task<OpenClawWriteGateDecision> EvaluateAsync(
|
||||
string method,
|
||||
string requiredScope = "operator.admin",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!configuration.GetValue(
|
||||
"OpenClawSetup:ExternalClientIdentitySupported",
|
||||
false)
|
||||
|| !gatewayOptions.Value.ExternalClientIdentitySupported)
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"experimental_blocked",
|
||||
"OpenClaw has not declared the external Nexus client identity supported.",
|
||||
"Keep writes disabled until the pinned OpenClaw release registers the Nexus client id.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OpenClawGatewayProtocol.ValidateExternalClientIdentity(
|
||||
gatewayOptions.Value);
|
||||
}
|
||||
catch (OpenClawGatewayRpcException exception)
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
NormalizeCode(exception.Code),
|
||||
exception.Message,
|
||||
"Use only an officially registered external Nexus operator identity.");
|
||||
}
|
||||
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||
var profile = await db.OpenClawConnectionProfiles
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item => item.ProfileId ==
|
||||
OpenClawConnectionProfile.PrimaryProfileId,
|
||||
cancellationToken);
|
||||
|
||||
if (profile is null
|
||||
|| profile.AdoptionState != OpenClawAdoptionStates.Adopted
|
||||
|| !profile.ManagementEnabled)
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"management_disabled",
|
||||
"The adopted primary OpenClaw profile is not approved for management.",
|
||||
"An owner must adopt, verify and explicitly enable management for the primary profile.");
|
||||
}
|
||||
|
||||
if (connector.ConnectionState != GatewayConnectionState.Connected)
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"gateway_unavailable",
|
||||
"OpenClaw Gateway is not connected.",
|
||||
"Restore the verified primary Gateway connection and retry.");
|
||||
}
|
||||
|
||||
if (!EndpointsEqual(profile.Endpoint, connector.ActiveEndpoint))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"endpoint_trust_mismatch",
|
||||
"The connected OpenClaw endpoint is not the adopted primary endpoint.",
|
||||
"Reconnect and re-verify the adopted primary profile before enabling writes.");
|
||||
}
|
||||
|
||||
var tlsPinRequired = RequiresTlsPin(profile.Endpoint);
|
||||
if (tlsPinRequired
|
||||
&& (string.IsNullOrWhiteSpace(profile.TlsCertificateFingerprint)
|
||||
|| string.IsNullOrWhiteSpace(
|
||||
connector.ActiveTlsFingerprint)))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"tls_trust_missing",
|
||||
"The adopted WSS OpenClaw endpoint has no complete TLS fingerprint binding.",
|
||||
"Probe the endpoint, confirm its certificate fingerprint and re-adopt the connection.");
|
||||
}
|
||||
|
||||
if ((tlsPinRequired
|
||||
|| !string.IsNullOrWhiteSpace(
|
||||
profile.TlsCertificateFingerprint)
|
||||
|| !string.IsNullOrWhiteSpace(
|
||||
connector.ActiveTlsFingerprint))
|
||||
&& !FingerprintsEqual(
|
||||
profile.TlsCertificateFingerprint,
|
||||
connector.ActiveTlsFingerprint))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"tls_trust_mismatch",
|
||||
"The active OpenClaw TLS fingerprint differs from the adopted primary profile.",
|
||||
"Confirm the certificate fingerprint and re-adopt the connection.");
|
||||
}
|
||||
|
||||
if (!BoundIdentityEquals(profile.DeviceId, connector.DeviceId))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"device_trust_mismatch",
|
||||
"The active OpenClaw device identity differs from the adopted primary profile.",
|
||||
"Pair and verify the expected Nexus device before enabling writes.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profile.RequiredVersion)
|
||||
&& !string.Equals(
|
||||
profile.RequiredVersion,
|
||||
connector.GatewayVersion,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"version_mismatch",
|
||||
"The connected OpenClaw version differs from the adopted primary profile.",
|
||||
"Restore the pinned OpenClaw version and re-verify the connection.");
|
||||
}
|
||||
|
||||
var capabilityHash = BuildCapabilityHash(connector);
|
||||
if (string.IsNullOrWhiteSpace(profile.CapabilityHash)
|
||||
|| !string.Equals(
|
||||
profile.CapabilityHash,
|
||||
capabilityHash,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"capability_drift",
|
||||
"OpenClaw capabilities changed after management approval.",
|
||||
"Re-verify the primary profile and approve its current capability set.");
|
||||
}
|
||||
|
||||
if (!connector.Supports(method))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"capability_missing",
|
||||
$"OpenClaw does not advertise required method '{method}'.",
|
||||
"Use a compatible OpenClaw release and re-verify capabilities.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredScope)
|
||||
&& !connector.GrantedScopes.Contains(requiredScope))
|
||||
{
|
||||
return OpenClawWriteGateDecision.Block(
|
||||
"scope_upgrade_required",
|
||||
$"OpenClaw did not grant {requiredScope}.",
|
||||
"Approve the explicit scope upgrade, reconnect and re-verify the primary profile.");
|
||||
}
|
||||
|
||||
return OpenClawWriteGateDecision.Permit();
|
||||
}
|
||||
|
||||
public static string BuildCapabilityHash(IGatewayConnector gateway)
|
||||
{
|
||||
var contract = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
gateway.GatewayVersion ?? string.Empty,
|
||||
gateway.RequiredVersion ?? string.Empty,
|
||||
gateway.ProtocolVersion?.ToString() ?? string.Empty,
|
||||
string.Join(",", gateway.GrantedScopes.Order(StringComparer.Ordinal)),
|
||||
string.Join(",", gateway.AdvertisedMethods.Order(StringComparer.Ordinal)),
|
||||
string.Join(",", gateway.AdvertisedEvents.Order(StringComparer.Ordinal))
|
||||
});
|
||||
return Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(contract)));
|
||||
}
|
||||
|
||||
private static bool EndpointsEqual(string? expected, string? actual)
|
||||
{
|
||||
if (!Uri.TryCreate(expected, UriKind.Absolute, out var expectedUri)
|
||||
|| !Uri.TryCreate(actual, UriKind.Absolute, out var actualUri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
NormalizeEndpoint(expectedUri),
|
||||
NormalizeEndpoint(actualUri),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeEndpoint(Uri value)
|
||||
{
|
||||
var builder = new UriBuilder(value)
|
||||
{
|
||||
Host = value.Host.ToLowerInvariant(),
|
||||
Path = string.IsNullOrWhiteSpace(value.AbsolutePath)
|
||||
? "/"
|
||||
: value.AbsolutePath.TrimEnd('/') + "/",
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty
|
||||
};
|
||||
return builder.Uri.AbsoluteUri.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static bool FingerprintsEqual(string? expected, string? actual)
|
||||
=> string.Equals(
|
||||
NormalizeFingerprint(expected),
|
||||
NormalizeFingerprint(actual),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static bool RequiresTlsPin(string? endpoint)
|
||||
=> Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)
|
||||
&& string.Equals(
|
||||
uri.Scheme,
|
||||
Uri.UriSchemeWss,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string NormalizeFingerprint(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: new string(value
|
||||
.Where(Uri.IsHexDigit)
|
||||
.Select(char.ToUpperInvariant)
|
||||
.ToArray());
|
||||
|
||||
private static bool BoundIdentityEquals(string? expected, string? actual)
|
||||
=> string.Equals(
|
||||
expected?.Trim() ?? string.Empty,
|
||||
actual?.Trim() ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static string NormalizeCode(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "external_identity_invalid"
|
||||
: value.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
}
|
||||
Reference in New Issue
Block a user