mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #2566 from Hinotoi-agent/fix/channel-approval-target-authz
[security] fix(permissions): scope channel approval targets
This commit is contained in:
@@ -357,6 +357,87 @@ describe('unknown-channel registration flow', () => {
|
|||||||
.c;
|
.c;
|
||||||
expect(stillPending).toBe(1);
|
expect(stillPending).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let a scoped admin connect an unknown channel to another agent group', async () => {
|
||||||
|
const { routeInbound } = await import('../../router.js');
|
||||||
|
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||||
|
const { getDb } = await import('../../db/connection.js');
|
||||||
|
|
||||||
|
createAgentGroup({ id: 'ag-2', name: 'Betty', folder: 'betty', agent_provider: null, created_at: now() });
|
||||||
|
upsertUser({ id: 'telegram:scoped-admin', kind: 'telegram', display_name: 'Scoped Admin', created_at: now() });
|
||||||
|
grantRole({
|
||||||
|
user_id: 'telegram:scoped-admin',
|
||||||
|
role: 'admin',
|
||||||
|
agent_group_id: 'ag-1',
|
||||||
|
granted_by: 'telegram:owner',
|
||||||
|
granted_at: now(),
|
||||||
|
});
|
||||||
|
createMessagingGroup({
|
||||||
|
id: 'mg-dm-scoped-admin',
|
||||||
|
channel_type: 'telegram',
|
||||||
|
platform_id: 'dm-scoped-admin',
|
||||||
|
name: 'Scoped Admin DM',
|
||||||
|
is_group: 0,
|
||||||
|
unknown_sender_policy: 'public',
|
||||||
|
created_at: now(),
|
||||||
|
});
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run('telegram:scoped-admin', 'telegram', 'mg-dm-scoped-admin', now());
|
||||||
|
|
||||||
|
await routeInbound(groupMention('chat-scoped-cross-group'));
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||||
|
messaging_group_id: string;
|
||||||
|
};
|
||||||
|
expect(pending).toBeDefined();
|
||||||
|
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deliverMock.mock.calls[0][1]).toBe('dm-scoped-admin');
|
||||||
|
|
||||||
|
for (const handler of getResponseHandlers()) {
|
||||||
|
const claimed = await handler({
|
||||||
|
questionId: pending.messaging_group_id,
|
||||||
|
value: 'choose_existing',
|
||||||
|
userId: 'scoped-admin',
|
||||||
|
channelType: 'telegram',
|
||||||
|
platformId: 'dm-scoped-admin',
|
||||||
|
threadId: null,
|
||||||
|
});
|
||||||
|
if (claimed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const followupPayload = JSON.parse(deliverMock.mock.calls[1][4] as string) as {
|
||||||
|
options: Array<{ label: string; value: string }>;
|
||||||
|
};
|
||||||
|
expect(followupPayload.options.map((option) => option.value)).toContain('connect:ag-1');
|
||||||
|
expect(followupPayload.options.map((option) => option.value)).not.toContain('connect:ag-2');
|
||||||
|
|
||||||
|
for (const handler of getResponseHandlers()) {
|
||||||
|
const claimed = await handler({
|
||||||
|
questionId: pending.messaging_group_id,
|
||||||
|
value: 'connect:ag-2',
|
||||||
|
userId: 'scoped-admin',
|
||||||
|
channelType: 'telegram',
|
||||||
|
platformId: 'dm-scoped-admin',
|
||||||
|
threadId: null,
|
||||||
|
});
|
||||||
|
if (claimed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mgaCount = (
|
||||||
|
getDb()
|
||||||
|
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||||
|
.get(pending.messaging_group_id) as { c: number }
|
||||||
|
).c;
|
||||||
|
expect(mgaCount).toBe(0);
|
||||||
|
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
|
||||||
|
.c;
|
||||||
|
expect(stillPending).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no-owner / no-agent failure modes', () => {
|
describe('no-owner / no-agent failure modes', () => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type { InboundEvent } from '../../channels/adapter.js';
|
|||||||
import type { AgentGroup } from '../../types.js';
|
import type { AgentGroup } from '../../types.js';
|
||||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||||
|
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||||
|
|
||||||
// ── Value constants (response handler in index.ts parses these) ──
|
// ── Value constants (response handler in index.ts parses these) ──
|
||||||
|
|
||||||
@@ -76,15 +77,24 @@ function toFolder(name: string): string {
|
|||||||
|
|
||||||
// ── Card builders ──
|
// ── Card builders ──
|
||||||
|
|
||||||
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
function visibleAgentGroupsForApprover(
|
||||||
|
agentGroups: AgentGroup[],
|
||||||
|
approverUserId: string | null | undefined,
|
||||||
|
): AgentGroup[] {
|
||||||
|
if (!approverUserId) return agentGroups;
|
||||||
|
return agentGroups.filter((agentGroup) => hasAdminPrivilege(approverUserId, agentGroup.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string | null): RawOption[] {
|
||||||
|
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||||
const options: RawOption[] = [];
|
const options: RawOption[] = [];
|
||||||
if (agentGroups.length === 1) {
|
if (visibleAgentGroups.length === 1) {
|
||||||
options.push({
|
options.push({
|
||||||
label: `Connect to ${agentGroups[0].name}`,
|
label: `Connect to ${visibleAgentGroups[0].name}`,
|
||||||
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`,
|
||||||
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (visibleAgentGroups.length > 1) {
|
||||||
options.push({
|
options.push({
|
||||||
label: 'Choose existing agent',
|
label: 'Choose existing agent',
|
||||||
selectedLabel: '📋 Choosing…',
|
selectedLabel: '📋 Choosing…',
|
||||||
@@ -194,7 +204,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
const channelName = originMg?.name ?? null;
|
const channelName = originMg?.name ?? null;
|
||||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||||
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
|
||||||
|
|
||||||
createPendingChannelApproval({
|
createPendingChannelApproval({
|
||||||
messaging_group_id: messagingGroupId,
|
messaging_group_id: messagingGroupId,
|
||||||
@@ -241,8 +251,12 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
/**
|
/**
|
||||||
* Build normalized options for the agent-selection follow-up card.
|
* Build normalized options for the agent-selection follow-up card.
|
||||||
*/
|
*/
|
||||||
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
export function buildAgentSelectionOptions(
|
||||||
const options: RawOption[] = agentGroups.map((ag) => ({
|
agentGroups: AgentGroup[],
|
||||||
|
approverUserId?: string | null,
|
||||||
|
): NormalizedOption[] {
|
||||||
|
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||||
|
const options: RawOption[] = visibleAgentGroups.map((ag) => ({
|
||||||
label: ag.name,
|
label: ag.name,
|
||||||
selectedLabel: `✅ Connected to ${ag.name}`,
|
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||||
value: `${CONNECT_PREFIX}${ag.id}`,
|
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
if (!adapter) return true;
|
if (!adapter) return true;
|
||||||
|
|
||||||
const agentGroups = getAllAgentGroups();
|
const agentGroups = getAllAgentGroups();
|
||||||
const options = buildAgentSelectionOptions(agentGroups);
|
const options = buildAgentSelectionOptions(agentGroups, approverId);
|
||||||
const title = '📋 Choose an agent';
|
const title = '📋 Choose an agent';
|
||||||
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||||
|
|
||||||
@@ -438,6 +438,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
|||||||
deletePendingChannelApproval(row.messaging_group_id);
|
deletePendingChannelApproval(row.messaging_group_id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
|
||||||
|
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
|
||||||
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
targetAgentGroupId,
|
||||||
|
approverId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn('Channel registration: unknown response value', {
|
log.warn('Channel registration: unknown response value', {
|
||||||
messagingGroupId: row.messaging_group_id,
|
messagingGroupId: row.messaging_group_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user