feat(mobile): read exact selected mention capability evidence

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 13:30:07 -04:00
parent 2fd0d0bcc4
commit b4f266dae1
4 changed files with 416 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart';
part 'agent_policy.dart';
part 'agent_authorization.dart';
part 'agent_publication.dart';
part 'selected_mention_authorization.dart';
/// A relay agent parsed from its kind:10100 agent-profile event.
///

View File

@@ -24,11 +24,14 @@ Future<List<NostrEvent>> _queryAgentFilters(
/// Overlay current owner-authenticated policy onto the existing runtime
/// directory. This does not expand discovery to owner-only coordinates yet.
/// [onProfileEvidence] observes immutable latest query heads after the final
/// scope check. It does not resolve ownership or mutate any shared profile cache.
Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
RelaySessionNotifier session,
List<NostrEvent> runtimeEvents, {
Set<String>? requestedKeys,
void Function()? checkCurrent,
void Function(Map<String, NostrEvent> profiles)? onProfileEvidence,
}) async {
final latest = <String, NostrEvent>{};
for (final event in runtimeEvents.where((event) => event.kind == 10100)) {
@@ -64,6 +67,7 @@ Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
),
], checkCurrent: checkCurrent);
checkCurrent?.call();
onProfileEvidence?.call(Map.unmodifiable(profiles));
return mergeAgentPolicies(latest.values, policies, owners);
}

View File

@@ -0,0 +1,173 @@
part of 'agent_identity_provider.dart';
/// Evidence requirements, not a claim that an ordinary identity is human.
enum SelectedMentionKind { ordinary, agent, unresolvedAgent }
/// Fresh evidence for one exact selected key. This is NOT permission to write:
/// consumers must apply their existing agent eligibility evaluator to [agent],
/// normal channel invitation permission, consent and scope fences separately.
/// An existing member needs no role write, whatever their current role is.
class SelectedMentionAuthorization {
final SelectedMentionKind kind;
final bool isMember;
/// Policy projection with relay-authenticated destination membership only.
/// Null for ordinary identities or unresolved prior-agent evidence. Missing
/// owner policy produces a deny-all entry, never runtime fallback.
final AgentDirectoryEntry? agent;
const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent);
bool get requiresAgentAuthorization => kind != SelectedMentionKind.ordinary;
/// Role selection only, never an invitation authorization. A consumer must
/// first validate eligibility and consent and skip writes for existing members.
String? get invitationRole => switch (kind) {
SelectedMentionKind.ordinary => 'member',
SelectedMentionKind.agent => 'bot',
SelectedMentionKind.unresolvedAgent => null,
};
}
/// Read all selected keys, not just candidates whose saved isAgent bit is true.
/// [priorAgentKeys] is denial-only taint: lost provenance cannot turn a saved
/// agent capability into an ordinary-member invitation or notification bypass.
/// Absence of agent evidence permits the existing ordinary membership flow,
/// NOT a humanity assertion. Positive fresh evidence overrides saved false.
///
/// Every requested key has a result or the entire read throws. This read makes
/// no cache writes and does not opt into resolveProfileOwners. Check [isCurrent]
/// again before each side effect; re-read after consent and accepted invitations.
Future<Map<String, SelectedMentionAuthorization>>
readSelectedMentionAuthorization(
RelaySessionNotifier session,
Set<String> requestedKeys, {
required String viewer,
required String channelId,
Set<String> priorAgentKeys = const {},
required bool Function() isCurrent,
}) async {
void check() {
if (!isCurrent()) throw StateError('Mention authorization scope changed');
}
check();
if (requestedKeys.length > 1000 ||
!requestedKeys.containsAll(priorAgentKeys) ||
requestedKeys.any((key) => !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(viewer) ||
channelId.isEmpty) {
throw StateError('Invalid selected mention request');
}
if (requestedKeys.isEmpty) return const {};
final authority = await session.fetchRelaySelf();
check();
final membership = await _membershipPages(
session,
authority,
viewer,
channelId,
check,
);
check();
NostrEvent? roster;
for (final event in membership) {
if (event.kind != 39002 ||
event.pubkey != authority ||
event.getTagValue('d') != channelId) {
continue;
}
if (roster == null || _newer(event, roster)) roster = event;
}
// A malformed newest head is unavailable, never permission to invite someone
// whose current membership could not be established. Missing head is likewise
// not a trustworthy empty roster at this destination.
if (roster == null ||
!verifySignedEvent(roster) ||
roster.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1) {
throw StateError('Destination membership unavailable');
}
final members = <String, String>{};
for (final tag in roster.tags.where((t) => t.isNotEmpty && t[0] == 'p')) {
if (tag.length < 2 || members.containsKey(tag[1])) {
throw StateError('Ambiguous destination membership');
}
members[tag[1]] = tag.length >= 4 ? tag[3] : 'member';
}
final runtime = await _queryAgentFilters(session, [
for (final key in requestedKeys)
NostrFilter(kinds: const [10100], authors: [key], limit: 1),
], checkCurrent: check);
check();
Map<String, NostrEvent> profiles = const {};
final policies = await resolveAgentPolicies(
session,
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
requestedKeys: requestedKeys,
checkCurrent: check,
onProfileEvidence: (value) => profiles = value,
);
check();
final latestRuntime = <String, NostrEvent>{};
for (final event in runtime) {
if (event.kind != 10100 || !requestedKeys.contains(event.pubkey)) continue;
final previous = latestRuntime[event.pubkey];
if (previous == null || _newer(event, previous)) {
latestRuntime[event.pubkey] = event;
}
}
final byKey = {for (final policy in policies) policy.pubkey: policy};
final result = <String, SelectedMentionAuthorization>{};
for (final key in requestedKeys) {
final profile = profiles[key];
final owner = profile == null ? null : verifiedOaOwnerPubkey(profile);
final invalidProfile =
profile != null &&
(!verifySignedEvent(profile) ||
(owner == null &&
profile.tags.any((t) => t.isNotEmpty && t[0] == 'auth')));
final runtimeHead = latestRuntime[key];
final knownAgent =
owner != null ||
members[key] == 'bot' ||
(runtimeHead != null && verifySignedEvent(runtimeHead));
final unresolved =
invalidProfile ||
(runtimeHead != null && !verifySignedEvent(runtimeHead)) ||
(!knownAgent && priorAgentKeys.contains(key));
final kind = unresolved
? SelectedMentionKind.unresolvedAgent
: knownAgent
? SelectedMentionKind.agent
: SelectedMentionKind.ordinary;
AgentDirectoryEntry? agent;
if (kind == SelectedMentionKind.agent) {
final policy = byKey[key];
// Ownership without a matching valid policy must not revive headless
// runtime authority. mergeAgentPolicies remains the sole policy parser.
final missingOwnedPolicy = owner != null && policy?.ownerPubkey != owner;
agent = AgentDirectoryEntry(
pubkey: key,
ownerPubkey: owner,
displayName: policy?.displayName,
respondTo: missingOwnedPolicy ? 'nobody' : policy?.respondTo,
respondToAllowlist: missingOwnedPolicy
? const []
: policy?.respondToAllowlist ?? const [],
channelIds:
members.containsKey(viewer) &&
members.containsKey(key) &&
(owner == viewer || members[key] == 'bot')
? [channelId]
: const [],
);
}
result[key] = SelectedMentionAuthorization._(
kind,
members.containsKey(key),
agent,
);
}
check();
return Map.unmodifiable(result);
}

View File

@@ -0,0 +1,238 @@
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip_oa_test.dart' show authTag, profile;
import 'agent_policy_test.dart' show PolicySession, signed;
class _Session extends PolicySession {
_Session(super.events, this.authority, {super.failPolicy});
final String authority;
void Function(List<NostrFilter>)? onQuery;
@override
Future<String> fetchRelaySelf() async => authority;
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
onQuery?.call(filters);
return super.queryRelay(filters, timeout: timeout);
}
}
void main() {
final viewer = nostr.Keys.generate();
final person = nostr.Keys.generate();
final agent = nostr.Keys.generate();
final relay = nostr.Keys.generate();
final owned = profile(agent, [authTag(viewer, agent.public)]);
final runtime = signed(agent, 10100, {'respond_to': 'anyone'});
NostrEvent roster({String? role, bool personMember = true, int time = 100}) =>
signed(
relay,
39002,
'',
time: time,
tags: [
['d', 'room'],
['p', viewer.public],
if (personMember) ['p', person.public, '', 'member'],
if (role != null) ['p', agent.public, '', role],
],
);
NostrEvent policy(String mode, {int time = 100}) => signed(
viewer,
30177,
{'name': 'Agent', 'parallelism': 1, 'respond_to': mode},
time: time,
tags: [
['d', agent.public],
],
);
Future<Map<String, SelectedMentionAuthorization>> read(
List<NostrEvent> events, {
Set<String> prior = const {},
bool Function()? current,
_Session? session,
Set<String>? keys,
}) async {
final source = session ?? _Session(events, relay.public);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => source)],
);
container.read(relaySessionProvider);
try {
return await readSelectedMentionAuthorization(
source,
keys ?? {person.public, agent.public},
viewer: viewer.public,
channelId: 'room',
priorAgentKeys: prior,
isCurrent: current ?? () => true,
);
} finally {
container.dispose();
}
}
test(
'exact total map preserves ordinary member and nonmember flows',
() async {
for (final member in [true, false]) {
final result = await read([roster(personMember: member)]);
expect(result.keys.toSet(), {person.public, agent.public});
final ordinary = result[person.public]!;
expect(ordinary.kind, SelectedMentionKind.ordinary);
expect(ordinary.requiresAgentAuthorization, isFalse);
expect(ordinary.isMember, member);
expect(ordinary.invitationRole, 'member');
expect(() => result.clear(), throwsUnsupportedError);
}
},
);
test(
'saved false cannot exclude fresh agent or select member role',
() async {
final result = await read([
roster(role: 'member'),
owned,
policy('anyone'),
]);
final fresh = result[agent.public]!;
expect(fresh.kind, SelectedMentionKind.agent);
expect(fresh.requiresAgentAuthorization, isTrue);
expect(fresh.invitationRole, 'bot');
expect(fresh.isMember, isTrue);
expect(fresh.agent!.ownerPubkey, viewer.public);
expect(fresh.agent!.channelIds, ['room']);
expect(result[person.public]!.kind, SelectedMentionKind.ordinary);
},
);
test(
'saved true with lost provenance remains unresolved even as member',
() async {
final result = await read(
[roster(role: 'member'), owned, profile(agent, [], createdAt: 101)],
prior: {agent.public},
);
final lost = result[agent.public]!;
expect(lost.kind, SelectedMentionKind.unresolvedAgent);
expect(lost.isMember, isTrue);
expect(lost.requiresAgentAuthorization, isTrue);
expect(lost.agent, isNull);
expect(lost.invitationRole, isNull);
},
);
test(
'missing and revoked owner policy deny without runtime fallback',
() async {
for (final policies in <List<NostrEvent>>[
[],
[policy('anyone'), policy('nobody', time: 101)],
]) {
final result = await read([
roster(role: 'bot'),
owned,
runtime,
...policies,
]);
final fresh = result[agent.public]!;
expect(fresh.kind, SelectedMentionKind.agent);
expect(fresh.agent!.respondTo, 'nobody');
expect(fresh.agent!.ownerPubkey, viewer.public);
}
},
);
test(
'known runtime agent without membership never becomes ordinary',
() async {
final result = await read([roster(), runtime]);
expect(result[agent.public]!.kind, SelectedMentionKind.agent);
expect(result[agent.public]!.agent!.channelIds, isEmpty);
expect(result[agent.public]!.isMember, isFalse);
},
);
test(
'headless bot requires runtime policy; malformed OA is unresolved',
() async {
final bot = (await read([roster(role: 'bot')]))[agent.public]!;
expect(bot.kind, SelectedMentionKind.agent);
expect(bot.agent!.respondTo, isNull);
final good = (await read([roster(role: 'bot'), runtime]))[agent.public]!;
expect(good.agent!.respondTo, 'anyone');
expect(good.agent!.channelIds, ['room']);
final bad = (await read([
roster(role: 'bot'),
runtime,
profile(agent, [
['auth', viewer.public, '', 'bad'],
]),
]))[agent.public]!;
expect(bad.kind, SelectedMentionKind.unresolvedAgent);
expect(bad.agent, isNull);
},
);
test(
'newest roster removal wins; invalid head does not revive old member',
() async {
final result = await read([
roster(role: 'bot'),
roster(time: 101),
runtime,
]);
expect(result[agent.public]!.isMember, isFalse);
expect(result[agent.public]!.agent!.channelIds, isEmpty);
final invalid = NostrEvent.fromJson({
...roster(time: 102).toJson(),
'content': 'tampered',
});
await expectLater(read([roster(), invalid]), throwsStateError);
await expectLater(read([]), throwsStateError);
},
);
test(
'policy failures and scope retirement throw rather than partial map',
() async {
await expectLater(
read(
[],
session: _Session([roster(), owned], relay.public, failPolicy: true),
),
throwsStateError,
);
var current = true;
final session = _Session([roster(), owned], relay.public)
..onQuery = (filters) {
if (filters.any((f) => f.kinds.contains(0))) current = false;
};
await expectLater(
read([], session: session, current: () => current),
throwsStateError,
);
expect(session.queries.any((f) => f.kinds.contains(30177)), isFalse);
},
);
test('exact request bounds and denial-only taint are validated', () async {
await expectLater(read([roster()], keys: {'not-a-key'}), throwsStateError);
await expectLater(
read([roster()], keys: {person.public}, prior: {agent.public}),
throwsStateError,
);
final keys = {
for (var i = 0; i < 1001; i++) i.toRadixString(16).padLeft(64, '0'),
};
await expectLater(read([roster()], keys: keys), throwsStateError);
});
}