Files
nexus/backend/Services/OpenClawWriteGate.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

265 lines
10 KiB
C#

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('-', '_');
}