mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(mobile): make verified owner policy override runtime access
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -12,6 +12,11 @@ bool agentIsSharedWithUser(
|
||||
Set<String> 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<MentionCandidate> 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<MentionCandidate> 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;
|
||||
}
|
||||
|
||||
@@ -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<String> respondToAllowlist;
|
||||
final List<String> 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<List<AgentDirectoryEntry>>((
|
||||
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
|
||||
|
||||
140
mobile/lib/shared/mentions/agent_policy.dart
Normal file
140
mobile/lib/shared/mentions/agent_policy.dart
Normal file
@@ -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<List<NostrEvent>> _queryAgentFilters(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrFilter> filters,
|
||||
) async {
|
||||
final events = <NostrEvent>[];
|
||||
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<List<AgentDirectoryEntry>> resolveAgentPolicies(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrEvent> runtimeEvents,
|
||||
) async {
|
||||
final latest = <String, NostrEvent>{};
|
||||
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 = <String, String>{};
|
||||
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<AgentDirectoryEntry> mergeAgentPolicies(
|
||||
Iterable<NostrEvent> runtimeEvents,
|
||||
Iterable<NostrEvent> policies,
|
||||
Map<String, String> verifiedOwners,
|
||||
) {
|
||||
final latestPolicies = <String, NostrEvent>{};
|
||||
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 = <String, AgentDirectoryEntry>{};
|
||||
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<String>? _stringList(Object? value) =>
|
||||
value is List && value.every((v) => v is String)
|
||||
? value.cast<String>().map((v) => v.toLowerCase()).toList()
|
||||
: null;
|
||||
280
mobile/test/shared/mentions/agent_policy_test.dart
Normal file
280
mobile/test/shared/mentions/agent_policy_test.dart
Normal file
@@ -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<List<String>> 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<NostrEvent> events;
|
||||
final bool failPolicy;
|
||||
final queries = <NostrFilter>[];
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => events.where((e) => e.kind == 10100).toList();
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> 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<List<AgentDirectoryEntry>> directory(
|
||||
List<NostrEvent> 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>[],
|
||||
[
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user