diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 6f3bae57a2..930ccef2b3 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -12,6 +12,11 @@ bool agentIsSharedWithUser( Set sharedChannelIds, String? currentPubkey, ) { + if (currentPubkey != null && + agent.ownerPubkey == currentPubkey.toLowerCase() && + const ['owner-only', 'allowlist', 'anyone'].contains(agent.respondTo)) { + return true; + } if (agent.respondTo == 'allowlist' && currentPubkey != null) { return agent.respondToAllowlist.contains(currentPubkey.toLowerCase()); } @@ -62,6 +67,11 @@ List buildMentionCandidates({ final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; final isAgent = member.isBot || ownerPubkey != null; + final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull; + if (policy?.ownerPubkey != null && + !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) { + continue; + } candidates.add( MentionCandidate( pubkey: pk, @@ -121,6 +131,12 @@ List buildMentionCandidates({ // verified NIP-OA owner) or shared via the relay agent directory. final ownedByCurrentUser = currentLower != null && ownerPubkey?.toLowerCase() == currentLower; + final policy = relayAgents + .where((agent) => agent.pubkey == pk) + .firstOrNull; + if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) { + continue; + } if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) { continue; } diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index d69c749f79..e5be060be4 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -5,8 +5,11 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/crypto/nip_oa.dart'; +import '../../shared/crypto/signed_event.dart'; import '../../shared/relay/relay.dart'; +part 'agent_policy.dart'; + /// A relay agent parsed from its kind:10100 agent-profile event. /// /// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility @@ -15,6 +18,7 @@ import '../../shared/relay/relay.dart'; class AgentDirectoryEntry { final String pubkey; final String? displayName; + final String? ownerPubkey; final String? respondTo; final List respondToAllowlist; final List channelIds; @@ -22,6 +26,7 @@ class AgentDirectoryEntry { const AgentDirectoryEntry({ required this.pubkey, this.displayName, + this.ownerPubkey, this.respondTo, this.respondToAllowlist = const [], this.channelIds = const [], @@ -66,7 +71,7 @@ final agentDirectoryProvider = FutureProvider>(( if (sessionState.status != SessionStatus.connected) return const []; final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory(NostrFilters.agentProfiles()); - return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; + return resolveAgentPolicies(session, events); }); /// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart new file mode 100644 index 0000000000..be7f6642b8 --- /dev/null +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -0,0 +1,140 @@ +part of 'agent_identity_provider.dart'; + +bool _newer(NostrEvent event, NostrEvent previous) => + event.createdAt > previous.createdAt || + (event.createdAt == previous.createdAt && + event.id.compareTo(previous.id) < 0); + +/// Exact-coordinate queries, bounded to ten filters in flight. An empty result +/// is distinct from a failed read; failures must never revive runtime access. +Future> _queryAgentFilters( + RelaySessionNotifier session, + List filters, +) async { + final events = []; + for (var start = 0; start < filters.length; start += 10) { + events.addAll( + await session.queryRelay(filters.skip(start).take(10).toList()), + ); + } + return events; +} + +/// Overlay current owner-authenticated policy onto the existing runtime +/// directory. This does not expand discovery to owner-only coordinates yet. +Future> resolveAgentPolicies( + RelaySessionNotifier session, + List runtimeEvents, +) async { + final latest = {}; + for (final event in runtimeEvents.where((event) => event.kind == 10100)) { + final previous = latest[event.pubkey]; + if (previous == null || _newer(event, previous)) { + latest[event.pubkey] = event; + } + } + final profiles = latestProfileEvents( + await _queryAgentFilters(session, [ + for (final key in latest.keys) + NostrFilter(kinds: const [0], authors: [key], limit: 1), + ]), + ); + final owners = {}; + for (final profile in profiles.values) { + final owner = verifiedOaOwnerPubkey(profile); + if (owner != null && latest.containsKey(profile.pubkey)) { + owners[profile.pubkey] = owner; + } + } + final policies = await _queryAgentFilters(session, [ + for (final owner in owners.entries) + NostrFilter( + kinds: const [30177], + authors: [owner.value], + tags: { + '#d': [owner.key], + }, + limit: 1, + ), + ]); + return mergeAgentPolicies(latest.values, policies, owners); +} + +/// Latest signed owner policy is the access authority, not runtime claims. +/// Malformed/revoked policy reserves the identity with deny-all permissions; +/// it must not fall back to an older permissive policy or kind:10100 record. +List mergeAgentPolicies( + Iterable runtimeEvents, + Iterable policies, + Map verifiedOwners, +) { + final latestPolicies = {}; + for (final event in policies) { + final key = event.getTagValue('d'); + if (key == null || verifiedOwners[key] != event.pubkey) continue; + final previous = latestPolicies[key]; + if (previous == null || _newer(event, previous)) { + latestPolicies[key] = event; + } + } + final agents = {}; + for (final event in runtimeEvents) { + if (event.kind != 10100 || !verifySignedEvent(event)) continue; + final data = _tryDecodeJsonMap(event.content); + if (data == null) continue; + agents[event.pubkey] = AgentDirectoryEntry( + pubkey: event.pubkey, + displayName: data['display_name'] is String + ? data['display_name'] as String + : data['name'] is String + ? data['name'] as String + : null, + respondTo: data['respond_to'] is String + ? data['respond_to'] as String + : null, + respondToAllowlist: _stringList(data['respond_to_allowlist']) ?? const [], + channelIds: _stringList(data['channel_ids']) ?? const [], + ); + } + for (final entry in latestPolicies.entries) { + final event = entry.value; + final data = _tryDecodeJsonMap(event.content); + final mode = data?['respond_to']; + final parallelism = data?['parallelism']; + final allowlist = _stringList(data?['respond_to_allowlist'] ?? []); + final valid = + event.kind == 30177 && + verifySignedEvent(event) && + event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd').length == + 1 && + data?['name'] is String && + parallelism is int && + parallelism >= 0 && + parallelism <= 4294967295 && + const ['owner-only', 'allowlist', 'anyone', 'nobody'].contains(mode) && + allowlist != null && + const [ + 'persona_id', + 'system_prompt', + 'model', + 'provider', + 'persona_source_version', + ].every((key) => data?[key] == null || data?[key] is String); + agents[entry.key] = AgentDirectoryEntry( + pubkey: entry.key, + ownerPubkey: event.pubkey, + displayName: valid + ? data!['name'] as String + : agents[entry.key]?.displayName, + respondTo: valid ? mode as String : 'nobody', + respondToAllowlist: valid ? allowlist : const [], + channelIds: agents[entry.key]?.channelIds ?? const [], + ); + } + return agents.values.toList(); +} + +List? _stringList(Object? value) => + value is List && value.every((v) => v is String) + ? value.cast().map((v) => v.toLowerCase()).toList() + : null; diff --git a/mobile/test/shared/mentions/agent_policy_test.dart b/mobile/test/shared/mentions/agent_policy_test.dart new file mode 100644 index 0000000000..85cdc6faef --- /dev/null +++ b/mobile/test/shared/mentions/agent_policy_test.dart @@ -0,0 +1,280 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.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; + +NostrEvent signed( + nostr.Keys key, + int kind, + Object content, { + int time = 100, + List> tags = const [], +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: content is String ? content : jsonEncode(content), + secretKey: key.secret, + createdAt: time, + tags: tags, + ).toMap(), +); + +class PolicySession extends RelaySessionNotifier { + PolicySession(this.events, {this.failPolicy = false}); + final List events; + final bool failPolicy; + final queries = []; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => events.where((e) => e.kind == 10100).toList(); + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filters.length, lessThanOrEqualTo(10)); + queries.addAll(filters); + if (failPolicy && filters.any((f) => f.kinds.contains(30177))) { + throw StateError('policy read failed'); + } + return events + .where( + (event) => filters.any( + (f) => + f.kinds.contains(event.kind) && + f.authors!.contains(event.pubkey) && + (f.tags['#d'] == null || + f.tags['#d']!.contains(event.getTagValue('d'))), + ), + ) + .toList(); + } +} + +void main() { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final stranger = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final runtime = signed(agent, 10100, { + 'name': 'runtime', + 'respond_to': 'anyone', + 'channel_ids': ['channel'], + }); + NostrEvent policy(Object content, {int time = 100, nostr.Keys? author}) => + signed( + author ?? owner, + 30177, + content, + time: time, + tags: [ + ['d', agent.public], + ], + ); + final allow = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'owner-only', + }); + + Future> directory( + List events, { + bool failPolicy = false, + void Function(PolicySession)? inspect, + }) async { + final session = PolicySession(events, failPolicy: failPolicy); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + try { + final result = await container.read(agentDirectoryProvider.future); + inspect?.call(session); + return result; + } finally { + container.dispose(); + } + } + + test( + 'production directory reads exact authenticated owner coordinates', + () async { + final agents = await directory( + [runtime, owned, allow], + inspect: (session) { + final query = session.queries.singleWhere( + (q) => q.kinds.contains(30177), + ); + expect(query.authors, [owner.public]); + expect(query.tags, { + '#d': [agent.public], + }); + expect(query.limit, 1); + }, + ); + expect(agents.single.ownerPubkey, owner.public); + expect(agents.single.respondTo, 'owner-only'); + expect(agents.single.displayName, 'Agent'); + expect(agentIsSharedWithUser(agents.single, {}, owner.public), isTrue); + expect( + agentIsSharedWithUser(agents.single, {'channel'}, stranger.public), + isFalse, + ); + }, + ); + + test( + 'latest malformed policy reserves deny-all, never runtime/older fallback', + () async { + for (final body in [ + '', + '{}', + {'name': 'Agent', 'parallelism': 1, 'respond_to': 'unknown'}, + { + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'anyone', + 'respond_to_allowlist': [3], + }, + {'name': 'Agent', 'parallelism': -1, 'respond_to': 'anyone'}, + ]) { + final bad = policy(body, time: 101); + for (final policies in [ + [allow, bad], + [bad, allow], + ]) { + final result = await directory([runtime, owned, ...policies]); + expect(result.single.respondTo, 'nobody'); + expect( + agentIsSharedWithUser(result.single, {'channel'}, owner.public), + isFalse, + ); + } + } + }, + ); + + test( + 'same-second policy ties are independent of response ordering', + () async { + final deny = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'nobody', + }); + for (final policies in [ + [allow, deny], + [deny, allow], + ]) { + expect( + (await directory([runtime, owned, ...policies])).single.respondTo, + allow.id.compareTo(deny.id) < 0 ? 'owner-only' : 'nobody', + ); + } + }, + ); + + test( + 'tampered authenticated policy cannot revive runtime permission', + () async { + final tampered = NostrEvent.fromJson({ + ...allow.toJson(), + 'content': '{}', + }); + expect( + (await directory([runtime, owned, tampered])).single.respondTo, + 'nobody', + ); + }, + ); + + test( + 'foreign policy cannot override a headless runtime; revoked owner is not revived', + () async { + final foreign = policy({}, author: stranger); + expect( + (await directory([runtime, owned, foreign])).single.respondTo, + 'anyone', + ); + final revoked = profile(agent, [], createdAt: 101); + final result = await directory([runtime, owned, revoked, allow]); + expect(result.single.ownerPubkey, isNull); + expect(result.single.respondTo, 'anyone'); // OSS headless compatibility. + }, + ); + + test( + 'policy read failure propagates instead of falling back to runtime', + () async { + await expectLater( + directory([runtime, owned, allow], failPolicy: true), + throwsStateError, + ); + }, + ); + + test('latest tampered runtime never revives older valid runtime', () async { + final bad = NostrEvent.fromJson({...runtime.toJson(), 'created_at': 102}); + expect(await directory([runtime, bad]), isEmpty); + }); + + test( + 'policy denial applies to member, non-member and owned search candidates', + () async { + final deny = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'nobody', + }); + final agents = await directory([runtime, owned, deny]); + for (final members in [ + [], + [ + ChannelMember( + pubkey: agent.public, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ]) { + final candidates = buildMentionCandidates( + members: members, + relayAgents: agents, + sharedChannelIds: {'channel'}, + userCache: {}, + ownerByAgentPubkey: {agent.public: owner.public}, + currentPubkey: owner.public, + searchResults: [ + UserProfile(pubkey: agent.public, ownerPubkey: owner.public), + ], + ); + expect(candidates, isEmpty); + } + }, + ); + + test('owner is allowed by each supported mode except nobody', () async { + for (final mode in ['owner-only', 'allowlist', 'anyone', 'nobody']) { + final agents = await directory([ + runtime, + owned, + policy({'name': 'Agent', 'parallelism': 1, 'respond_to': mode}), + ]); + expect( + agentIsSharedWithUser(agents.single, {}, owner.public), + mode != 'nobody', + ); + } + }); +}