Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/nyxid-conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: ChronoAIProject/NyxID
ref: 47f2e0086c6c2117f644f8559d871a01d2a61982
ref: c03dfb753ad07d48b05521a4199a544d998d2f38
path: external/nyxid

- name: Checkout pinned support contract
Expand Down
161 changes: 135 additions & 26 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ public sealed class NyxIdAssistantActionRegistry
private const string PolicyCallerOwned = "NYXID_ACTION_POLICY_CALLER_OWNED";
private const string RegistryInvalid = "NYXID_ACTION_REGISTRY_INVALID";

private const string ServiceReauthorizeIdentityInvalidMessage =
"The service reauthorization identity is invalid.";
private const string ServiceReauthorizeScopeCountInvalidMessage =
"Service reauthorization requires an exact nonempty scope set.";
private const string ServiceReauthorizeScopesInvalidMessage =
"The service reauthorization scopes are invalid.";

private const string ServiceConnectParamsSchema = """
{
"oneOf": [
Expand Down Expand Up @@ -243,7 +250,7 @@ public sealed class NyxIdAssistantActionRegistry
// Each revision exposes only actions that have a typed producer, wire
// mapper, and typed postcondition reader on the canonical actor path.
// Revision v8 pins service.reauthorize so the manifest loads, but the
// action stays closed until that path exists.
// action stays closed until the complete cross-repo path is released.
private static readonly FrozenDictionary<string, FrozenSet<string>> ExecutableActionsByRevision =
new Dictionary<string, FrozenSet<string>>(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -321,6 +328,7 @@ internal static bool IsActionExecutable(
NyxIdAssistantActionKind.ServiceConnect => "service.connect",
NyxIdAssistantActionKind.KeyCreate => "key.create",
NyxIdAssistantActionKind.KeyRotate => "key.rotate",
NyxIdAssistantActionKind.ServiceReauthorize => "service.reauthorize",
_ => null,
};
return wireAction is not null &&
Expand Down Expand Up @@ -594,22 +602,13 @@ public NyxIdAssistantActionValidation ResolveKeyCreate(

var name = NormalizeString(requirement.Name, 256, required: true);
var platform = NormalizeString(requirement.Platform, 128, required: true);
if (requirement.AllowedServiceIds.Count is < 1 or > 64)
throw Error(ParamsInvalid, "Key creation requires an exact nonempty service set.");

var allowedServiceIds = new List<string>(requirement.AllowedServiceIds.Count);
var distinct = new HashSet<string>(StringComparer.Ordinal);
foreach (var serviceId in requirement.AllowedServiceIds)
{
var normalized = NormalizeString(serviceId, 256, required: true);
if (!string.Equals(serviceId, normalized, StringComparison.Ordinal) ||
!distinct.Add(normalized))
{
throw Error(ParamsInvalid, "The key creation service identities are invalid.");
}

allowedServiceIds.Add(normalized);
}
var allowedServiceIds = NormalizeDistinctSet(
requirement.AllowedServiceIds,
minCount: 1,
maxCount: 64,
maxItemLength: 256,
countInvalidMessage: "Key creation requires an exact nonempty service set.",
itemInvalidMessage: "The key creation service identities are invalid.");

var value = new NyxIdKeyCreateParams
{
Expand All @@ -633,13 +632,10 @@ public NyxIdAssistantActionValidation ResolveKeyRotate(
throw Error(ActionUnsupported, "Key rotation is not present in the pinned registry.");
}

var keyId = NormalizeString(requirement.KeyId, 256, required: true);
if (!string.Equals(requirement.KeyId, keyId, StringComparison.Ordinal) ||
keyId.Any(char.IsWhiteSpace) ||
keyId.Any(static character => character is '/' or '\\' or '?' or '#'))
{
throw Error(ParamsInvalid, "The key rotation identity is invalid.");
}
var keyId = NormalizeSafeIdentity(
requirement.KeyId,
256,
"The key rotation identity is invalid.");

return new NyxIdAssistantActionValidation(
entry.Definition.Clone(),
Expand All @@ -649,6 +645,38 @@ public NyxIdAssistantActionValidation ResolveKeyRotate(
});
}

public NyxIdAssistantActionValidation ResolveServiceReauthorize(
NyxIdServiceReauthorizeActionRequirement requirement)
{
ArgumentNullException.ThrowIfNull(requirement);
if (!_entries.TryGetValue("service.reauthorize", out var entry) ||
!_executableActions.Contains("service.reauthorize") ||
entry.Definition.Action != NyxIdAssistantActionKind.ServiceReauthorize)
{
throw Error(
ActionUnsupported,
"Service reauthorization is not present in the pinned registry.");
}

var userServiceId = NormalizeSafeIdentity(
requirement.UserServiceId,
256,
ServiceReauthorizeIdentityInvalidMessage);
var requestedScopes = NormalizeDistinctSet(
requirement.RequestedScopes,
minCount: 1,
maxCount: 64,
maxItemLength: 256,
countInvalidMessage: ServiceReauthorizeScopeCountInvalidMessage,
itemInvalidMessage: ServiceReauthorizeScopesInvalidMessage);

var value = new NyxIdServiceReauthorizeParams { UserServiceId = userServiceId };
value.RequestedScopes.Add(requestedScopes);
return new NyxIdAssistantActionValidation(
entry.Definition.Clone(),
new NyxIdAssistantActionParams { ServiceReauthorize = value });
}

private static NyxIdAssistantActionParams ParseServiceConnect(JsonElement root)
{
EnsureOnlyProperties(root, "catalogService", "customService");
Expand Down Expand Up @@ -710,11 +738,25 @@ private static NyxIdAssistantActionParams ParseServiceConnect(JsonElement root)
internal static NyxIdAssistantActionParams ParseServiceReauthorize(JsonElement root)
{
EnsureOnlyProperties(root, "userServiceId", "requestedScopes");
var requestedScopes = ReadStringArray(
root,
"requestedScopes",
64,
256,
rejectDuplicates: true,
rejectNormalizationChanges: true);
if (requestedScopes.Count == 0)
throw Error(ParamsInvalid, ServiceReauthorizeScopeCountInvalidMessage);

var value = new NyxIdServiceReauthorizeParams
{
UserServiceId = ReadRequiredString(root, "userServiceId", 256),
UserServiceId = ReadSafeIdentity(
root,
"userServiceId",
256,
ServiceReauthorizeIdentityInvalidMessage),
};
value.RequestedScopes.AddRange(ReadStringArray(root, "requestedScopes", 64, 256));
value.RequestedScopes.AddRange(requestedScopes);
return new NyxIdAssistantActionParams { ServiceReauthorize = value };
}

Expand Down Expand Up @@ -1165,6 +1207,73 @@ private static string ReadEnumString(
: throw Error(ParamsInvalid, "An action enum value is invalid.");
}

private static string ReadSafeIdentity(
JsonElement element,
string name,
int maxLength,
string invalidMessage)
{
if (!element.TryGetProperty(name, out var property) ||
property.ValueKind != JsonValueKind.String)
{
throw Error(ParamsInvalid, "A required action string is missing.");
}

return NormalizeSafeIdentity(property.GetString(), maxLength, invalidMessage);
}

/// <summary>
/// Identity values travel verbatim into NyxID resource paths, so they must
/// already be canonical (no surrounding whitespace) and free of path or
/// query delimiters.
/// </summary>
internal static bool IsSafeIdentity(string value) =>
!value.Any(char.IsWhiteSpace) &&
!value.Any(static character => character is '/' or '\\' or '?' or '#');

private static string NormalizeSafeIdentity(
string? raw,
int maxLength,
string invalidMessage)
{
var normalized = NormalizeString(raw, maxLength, required: true);
if (!string.Equals(raw, normalized, StringComparison.Ordinal) ||
!IsSafeIdentity(normalized))
{
throw Error(ParamsInvalid, invalidMessage);
}

return normalized;
}

private static IReadOnlyList<string> NormalizeDistinctSet(
IReadOnlyCollection<string> values,
int minCount,
int maxCount,
int maxItemLength,
string countInvalidMessage,
string itemInvalidMessage)
{
if (values.Count < minCount || values.Count > maxCount)
throw Error(ParamsInvalid, countInvalidMessage);

var normalizedValues = new List<string>(values.Count);
var distinct = new HashSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
var normalized = NormalizeString(value, maxItemLength, required: true);
if (!string.Equals(value, normalized, StringComparison.Ordinal) ||
!distinct.Add(normalized))
{
throw Error(ParamsInvalid, itemInvalidMessage);
}

normalizedValues.Add(normalized);
}

return normalizedValues;
}

private static string NormalizeString(string? value, int maxLength, bool required)
{
var normalized = value?.Trim() ?? string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ key is null ||
actionRequestId,
StringComparison.Ordinal) ||
postconditionStep.Status != NyxIdChatStepStatus.Done ||
postconditionStep.ExternalEffect != NyxIdChatEffectEvidence.Confirmed))
postconditionStep.ExternalEffect != NyxIdChatEffectEvidence.Confirmed ||
!NyxIdChatBrowserActionPostconditionRecovery
.HasVerifiedCompletedBoundEvidence(
authorityState,
postconditionStep,
action)))
{
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using Google.Protobuf;

namespace Aevatar.GAgents.NyxidChat;

internal static class NyxIdChatActionPostconditionEvidence
{
internal const int Sha256Length = SHA256.HashSizeInBytes;

internal static ByteString ComputeVerificationInputSha256(
NyxIdChatActionPostconditionInput? input)
{
var canonical = input?.Clone() ?? new NyxIdChatActionPostconditionInput();
// Credentials are execution-only and must never affect or enter the
// durable verification binding.
canonical.ToolContext = null;

using var stream = new MemoryStream(canonical.CalculateSize());
using var output = new CodedOutputStream(stream, leaveOpen: true)
{
Deterministic = true,
};
canonical.WriteTo(output);
output.Flush();
return ByteString.CopyFrom(SHA256.HashData(stream.ToArray()));
}

internal static bool Matches(
NyxIdChatActionPostconditionInput? expectedInput,
NyxIdChatActionPostconditionResult result)
{
ArgumentNullException.ThrowIfNull(result);
var actual = result.VerificationInputSha256;
if (actual.Length != Sha256Length)
return false;

var expected = ComputeVerificationInputSha256(expectedInput);
return CryptographicOperations.FixedTimeEquals(expected.Span, actual.Span);
}
}
Loading
Loading