fix(mobile): make verified owner policy override runtime access

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-05 17:27:50 -04:00
parent b2253ce1aa
commit 804c07f016
4 changed files with 442 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -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

View 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;

View 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',
);
}
});
}