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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed class NyxIdChatTurnIntentClassifier : INyxIdChatTurnIntentClassifi
internal const string KeyRotateIntentId = "key_rotate";
internal const string KeyRotateRoutingDescription =
"Rotate one exact caller-visible NyxID API key through the browser-owned secure journey.";
internal const string ServiceReauthorizeIntentId = "service_reauthorize";
private static readonly TimeSpan ClassificationTimeout = TimeSpan.FromSeconds(15);
internal static AgentProfileTurnClassificationCandidate ServiceConnectCandidate { get; } =
new(
Expand Down
183 changes: 155 additions & 28 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public sealed class NyxIdAssistantActionRegistry
public const string LegacyRegistryRevision = "nyxid-assistant-actions.v4";
public const string WaveOneDraftRegistryRevision = "nyxid-assistant-actions.v5";
public const string LeastScopeRegistryRevision = "nyxid-assistant-actions.v6";
public const string SupportedRegistryRevision = "nyxid-assistant-actions.v7";
public const string KeyRotationRegistryRevision = "nyxid-assistant-actions.v7";
public const string SupportedRegistryRevision = "nyxid-assistant-actions.v8";
public const string ServiceAccessReviewRegistryRevision =
"aevatar-nyxid-actions.v1";

Expand All @@ -45,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 @@ -223,11 +231,18 @@ public sealed class NyxIdAssistantActionRegistry
"service.connect",
"key.create",
}.ToFrozenSet(StringComparer.Ordinal),
[KeyRotationRegistryRevision] = new[]
{
"service.connect",
"key.create",
"key.rotate",
}.ToFrozenSet(StringComparer.Ordinal),
[SupportedRegistryRevision] = new[]
{
"service.connect",
"key.create",
"key.rotate",
"service.reauthorize",
}.ToFrozenSet(StringComparer.Ordinal),
}.ToFrozenDictionary(StringComparer.Ordinal);

Expand All @@ -243,7 +258,15 @@ public sealed class NyxIdAssistantActionRegistry
.ToFrozenSet(StringComparer.Ordinal),
[LeastScopeRegistryRevision] = new[] { "service.connect", "key.create" }
.ToFrozenSet(StringComparer.Ordinal),
[SupportedRegistryRevision] = new[] { "service.connect", "key.create", "key.rotate" }
[KeyRotationRegistryRevision] = new[] { "service.connect", "key.create", "key.rotate" }
.ToFrozenSet(StringComparer.Ordinal),
[SupportedRegistryRevision] = new[]
{
"service.connect",
"key.create",
"key.rotate",
"service.reauthorize",
}
.ToFrozenSet(StringComparer.Ordinal),
}.ToFrozenDictionary(StringComparer.Ordinal);

Expand Down Expand Up @@ -309,6 +332,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 @@ -582,22 +606,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 @@ -621,13 +636,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 @@ -637,6 +649,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 @@ -698,11 +742,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 @@ -883,7 +941,9 @@ private static void ValidatePinnedContract(
NyxIdAssistantActionRisk risk,
bool rememberEligible)
{
var pinnedParamsSchema = revision is LeastScopeRegistryRevision or SupportedRegistryRevision &&
var pinnedParamsSchema = revision is LeastScopeRegistryRevision
or KeyRotationRegistryRevision
or SupportedRegistryRevision &&
contract.Action == NyxIdAssistantActionKind.KeyCreate
? LeastScopeKeyCreateParamsSchema
: contract.PinnedParamsSchema;
Expand Down Expand Up @@ -1151,6 +1211,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
28 changes: 24 additions & 4 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public static NyxIdChatBrowserActionDecision RequestAuthorization(
!string.IsNullOrWhiteSpace(blocker?.ServiceSlug);
var hasKeyCreate = blocker?.KeyCreate is not null;
var hasKeyRotate = blocker?.KeyRotate is not null;
var hasServiceReauthorize = blocker?.ServiceReauthorize is not null;
if (receipt?.Status != AgentToolReceiptStatus.AuthorizationRequired ||
blocker is null ||
signalKey is null ||
Expand All @@ -77,7 +78,8 @@ signalKey is null ||
(hasServiceAccessReview ? 1 : 0) +
(hasCatalogServiceConnect ? 1 : 0) +
(hasKeyCreate ? 1 : 0) +
(hasKeyRotate ? 1 : 0) != 1 ||
(hasKeyRotate ? 1 : 0) +
(hasServiceReauthorize ? 1 : 0) != 1 ||
state.ActiveTurn is null ||
state.ActiveTask is null)
{
Expand Down Expand Up @@ -106,7 +108,9 @@ state.ActiveTurn is null ||
: hasKeyCreate
? registry.ResolveKeyCreate(blocker.KeyCreate)
: hasKeyRotate
? registry.ResolveKeyRotate(blocker.KeyRotate)
? registry.ResolveKeyRotate(blocker.KeyRotate)
: hasServiceReauthorize
? registry.ResolveServiceReauthorize(blocker.ServiceReauthorize)
: registry.ResolveCatalogServiceConnect(
blocker.ServiceSlug,
blocker.RequestedScopes);
Expand Down Expand Up @@ -1221,6 +1225,8 @@ NyxIdAssistantActionParams.ParamsOneofCase.CatalogServiceConnect or
IsValidServiceAccessReviewParams(request.Params?.ServiceAccessReview),
NyxIdAssistantActionKind.KeyCreate => IsValidKeyCreateParams(request.Params?.KeyCreate),
NyxIdAssistantActionKind.KeyRotate => IsValidKeyRotateParams(request.Params?.KeyRotate),
NyxIdAssistantActionKind.ServiceReauthorize =>
IsValidServiceReauthorizeParams(request.Params?.ServiceReauthorize),
_ => false,
};

Expand Down Expand Up @@ -1267,8 +1273,22 @@ private static bool IsValidServiceAccessReviewParams(
private static bool IsValidKeyRotateParams(NyxIdKeyRotateParams? value) =>
value is not null &&
IsNormalizedActionValue(value.KeyId, 256) &&
!value.KeyId.Any(char.IsWhiteSpace) &&
!value.KeyId.Any(static character => character is '/' or '\\' or '?' or '#');
NyxIdAssistantActionRegistry.IsSafeIdentity(value.KeyId);

private static bool IsValidServiceReauthorizeParams(NyxIdServiceReauthorizeParams? value)
{
if (value is null ||
!IsNormalizedActionValue(value.UserServiceId, 256) ||
!NyxIdAssistantActionRegistry.IsSafeIdentity(value.UserServiceId) ||
value.RequestedScopes.Count is < 1 or > 64)
{
return false;
}

var scopes = new HashSet<string>(StringComparer.Ordinal);
return value.RequestedScopes.All(scope =>
IsNormalizedActionValue(scope, 256) && scopes.Add(scope));
}

private static bool IsNormalizedActionValue(string? value, int maxLength) =>
!string.IsNullOrWhiteSpace(value) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,11 @@ public static IReadOnlyList<AGUIEvent> BuildApprovalChanged(
NyxIdAssistantActionParams.ParamsOneofCase.ServiceReauthorize =>
new NyxIdAssistantActionWireParams
{
ServiceReauthorize = request.Params.ServiceReauthorize.Clone(),
ServiceReauthorizeUserServiceId = request.Params.ServiceReauthorize.UserServiceId,
ServiceReauthorizeRequestedScopes =
{
request.Params.ServiceReauthorize.RequestedScopes,
},
},
NyxIdAssistantActionParams.ParamsOneofCase.ServiceAccessReview =>
new NyxIdAssistantActionWireParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ enum NyxIdChatTurnIntent {
NYX_ID_CHAT_TURN_INTENT_SERVICE_CONNECT = 1;
NYX_ID_CHAT_TURN_INTENT_KEY_CREATE = 2;
NYX_ID_CHAT_TURN_INTENT_KEY_ROTATE = 3;
NYX_ID_CHAT_TURN_INTENT_SERVICE_REAUTHORIZE = 4;
}

enum NyxIdChatTaskStatus {
Expand Down Expand Up @@ -939,16 +940,21 @@ message NyxIdAssistantActionParams {
// postcondition facts remain internal committed state and never leak into the
// browser-action request frame.
message NyxIdAssistantActionWireParams {
// Field 3 previously nested service.reauthorize params under a wrapper;
// the NyxID descriptor schema is flat, so the wire form now uses 9 and 10.
reserved 3;
reserved "service_reauthorize";
oneof params {
NyxIdCatalogServiceConnectParams catalog_service = 1;
NyxIdCustomServiceConnectParams custom_service = 2;
NyxIdServiceReauthorizeParams service_reauthorize = 3;
NyxIdServiceAccessReviewParams service_access_review = 8;
}
string key_create_name = 4 [json_name = "name"];
string key_create_platform = 5 [json_name = "platform"];
repeated string key_create_allowed_service_ids = 6 [json_name = "allowedServiceIds"];
string key_rotate_key_id = 7 [json_name = "keyId"];
string service_reauthorize_user_service_id = 9 [json_name = "userServiceId"];
repeated string service_reauthorize_requested_scopes = 10 [json_name = "requestedScopes"];
}

message NyxIdAssistantActionRequestWirePayload {
Expand Down
Loading
Loading