diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 00e54e2185..a036b25524 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -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. /// diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart index 9d2d57a274..0abc2e076b 100644 --- a/mobile/lib/shared/mentions/agent_policy.dart +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -24,11 +24,14 @@ Future> _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> resolveAgentPolicies( RelaySessionNotifier session, List runtimeEvents, { Set? requestedKeys, void Function()? checkCurrent, + void Function(Map profiles)? onProfileEvidence, }) async { final latest = {}; for (final event in runtimeEvents.where((event) => event.kind == 10100)) { @@ -64,6 +67,7 @@ Future> resolveAgentPolicies( ), ], checkCurrent: checkCurrent); checkCurrent?.call(); + onProfileEvidence?.call(Map.unmodifiable(profiles)); return mergeAgentPolicies(latest.values, policies, owners); } diff --git a/mobile/lib/shared/mentions/selected_mention_authorization.dart b/mobile/lib/shared/mentions/selected_mention_authorization.dart new file mode 100644 index 0000000000..a88966fadd --- /dev/null +++ b/mobile/lib/shared/mentions/selected_mention_authorization.dart @@ -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> +readSelectedMentionAuthorization( + RelaySessionNotifier session, + Set requestedKeys, { + required String viewer, + required String channelId, + Set 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 = {}; + 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 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 = {}; + 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 = {}; + 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); +} diff --git a/mobile/test/shared/mentions/selected_mention_authorization_test.dart b/mobile/test/shared/mentions/selected_mention_authorization_test.dart new file mode 100644 index 0000000000..e826d2df59 --- /dev/null +++ b/mobile/test/shared/mentions/selected_mention_authorization_test.dart @@ -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)? onQuery; + @override + Future fetchRelaySelf() async => authority; + @override + Future> queryRelay( + List 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> read( + List events, { + Set prior = const {}, + bool Function()? current, + _Session? session, + Set? 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 >[ + [], + [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); + }); +}