feat(permissions): unknown-sender request_approval flow + flipped default policy

When an unknown sender writes into a wired messaging group, surface the
situation to an admin instead of silently dropping. Flow:

  1. Router → access gate → handleUnknownSender (policy='request_approval')
  2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery
     pick a reachable admin DM; deliver an Approve / Deny card; insert a
     pending_sender_approvals row carrying the original InboundEvent JSON.
  3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry
     from the same stranger while pending is silently dropped, not re-carded.
  4. Admin clicks → Chat SDK bridge → onAction → host response-registry.
     The new handleSenderApprovalResponse in the permissions module claims
     responses whose questionId matches a pending_sender_approvals row.
  5. approve: addMember(stranger, agent_group) + replay the stored event via
     routeInbound — the second attempt clears the gate because the user is
     now known.
  6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5
     decision) — a future attempt triggers a fresh card.

Schema:
- Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id,
  sender_identity, sender_name, original_message JSON, approver_user_id,
  created_at, UNIQUE(mg_id, sender_identity)).
- Also flips messaging_groups.unknown_sender_policy default from 'strict'
  to 'request_approval' (rebuild-table). Existing rows unchanged — only
  the default applied to new rows flips.
- Router auto-create for unknown platform/chat drops the hardcoded
  'strict' override; schema default applies.
- src/db/schema.ts reference updated to match.

Why default-flip: users wire their DM during setup and don't discover that
'strict' means "silent drop of everyone not in user_roles/members". The
approval flow is the safe default — the admin sees the stranger, explicitly
decides. 'public' stays opt-in for truly open channels.

Failure modes (row NOT created so a future attempt can try again):
- No eligible approver configured (fresh install before first owner).
- No reachable DM for any approver.
- Delivery adapter missing.

Tests (src/modules/permissions/sender-approval.test.ts, 4 cases):
- First unknown message → card delivered + row created
- Retry while pending → dedup'd (1 card, 1 row)
- Approve → member added + message replayed + container woken
- Deny → row cleared + no member added

Closes: ACTION-ITEMS item 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 01:36:11 +03:00
parent 16b9499532
commit 622a370815
8 changed files with 645 additions and 4 deletions

View File

@@ -0,0 +1,59 @@
/**
* CRUD for pending_sender_approvals — the in-flight state for the
* request_approval unknown-sender flow. Rows are created when an unknown
* sender writes into a wired messaging group with that policy, and are
* deleted on admin approve (after adding the user as a member) or deny.
*
* UNIQUE(messaging_group_id, sender_identity) enforces in-flight dedup:
* a retry / second message from the same unknown sender while a card is
* still pending is silently dropped instead of spamming the admin.
*/
import { getDb } from '../../../db/connection.js';
export interface PendingSenderApproval {
id: string;
messaging_group_id: string;
agent_group_id: string;
sender_identity: string;
sender_name: string | null;
original_message: string;
approver_user_id: string;
created_at: string;
}
export function createPendingSenderApproval(row: PendingSenderApproval): void {
getDb()
.prepare(
`INSERT INTO pending_sender_approvals (
id, messaging_group_id, agent_group_id, sender_identity,
sender_name, original_message, approver_user_id, created_at
)
VALUES (
@id, @messaging_group_id, @agent_group_id, @sender_identity,
@sender_name, @original_message, @approver_user_id, @created_at
)`,
)
.run(row);
}
export function getPendingSenderApproval(id: string): PendingSenderApproval | undefined {
return getDb()
.prepare('SELECT * FROM pending_sender_approvals WHERE id = ?')
.get(id) as PendingSenderApproval | undefined;
}
export function hasInFlightSenderApproval(
messagingGroupId: string,
senderIdentity: string,
): boolean {
const row = getDb()
.prepare(
'SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?',
)
.get(messagingGroupId, senderIdentity) as { x: number } | undefined;
return row !== undefined;
}
export function deletePendingSenderApproval(id: string): void {
getDb().prepare('DELETE FROM pending_sender_approvals WHERE id = ?').run(id);
}

View File

@@ -17,16 +17,24 @@
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
import {
routeInbound,
setAccessGate,
setSenderResolver,
setSenderScopeGate,
type AccessGateResult,
type InboundEvent,
} from '../../router.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.js';
import { addMember } from './db/agent-group-members.js';
import {
deletePendingSenderApproval,
getPendingSenderApproval,
} from './db/pending-sender-approvals.js';
import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.js';
function extractAndUpsertUser(event: InboundEvent): string | null {
let content: Record<string, unknown>;
@@ -82,11 +90,12 @@ function handleUnknownSender(
event: InboundEvent,
): void {
const parsed = safeParseContent(event.message.content);
const senderName = parsed.sender ?? null;
const dropRecord = {
channel_type: event.channelType,
platform_id: event.platformId,
user_id: userId,
sender_name: parsed.sender ?? null,
sender_name: senderName,
reason: `unknown_sender_${mg.unknown_sender_policy}`,
messaging_group_id: mg.id,
agent_group_id: agentGroupId,
@@ -104,13 +113,27 @@ function handleUnknownSender(
}
if (mg.unknown_sender_policy === 'request_approval') {
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
log.info('MESSAGE DROPPED — unknown sender (approval requested)', {
messagingGroupId: mg.id,
agentGroupId,
userId,
accessReason,
});
recordDroppedMessage(dropRecord);
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
// If it fails it logs internally — the user's message still stays dropped
// either way. Requires a resolved userId (senderResolver populates users
// row before the gate fires); if we got here without one, there's nothing
// to identify for approval and we just stay in the "silent strict" branch.
if (userId) {
requestSenderApproval({
messagingGroupId: mg.id,
agentGroupId,
senderIdentity: userId,
senderName,
event,
}).catch((err) => log.error('Sender-approval flow threw', { err }));
}
return;
}
@@ -156,3 +179,63 @@ setSenderScopeGate(
return { allowed: false, reason: `sender_scope_${decision.reason}` };
},
);
/**
* Response handler for the unknown-sender approval card.
*
* Claim rule: questionId matches a row in pending_sender_approvals. If no
* such row, return false so the next handler (approvals module, OneCLI,
* interactive) gets a shot.
*
* Approve: add the sender to agent_group_members + re-invoke routeInbound
* with the stored event. The second routing attempt clears the gate because
* the user is now a member.
*
* Deny: delete the row (no "deny list" — a future message re-triggers a
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
*/
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingSenderApproval(payload.questionId);
if (!row) return false;
const approverId = payload.userId ?? row.approver_user_id;
const approved = payload.value === 'approve';
if (approved) {
addMember({
user_id: row.sender_identity,
agent_group_id: row.agent_group_id,
added_by: approverId,
added_at: new Date().toISOString(),
});
log.info('Unknown sender approved — member added', {
approvalId: row.id,
senderIdentity: row.sender_identity,
agentGroupId: row.agent_group_id,
approverId,
});
// Clear the pending row BEFORE re-routing so the gate check on the
// second attempt doesn't see the in-flight row and short-circuit.
deletePendingSenderApproval(row.id);
try {
const event = JSON.parse(row.original_message) as InboundEvent;
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after sender approval', { approvalId: row.id, err });
}
return true;
}
log.info('Unknown sender denied', {
approvalId: row.id,
senderIdentity: row.sender_identity,
agentGroupId: row.agent_group_id,
approverId,
});
deletePendingSenderApproval(row.id);
return true;
}
registerResponseHandler(handleSenderApprovalResponse);

View File

@@ -0,0 +1,265 @@
/**
* Integration tests for the unknown-sender request_approval flow
* (ACTION-ITEMS item 5).
*
* Covers:
* - request_approval policy fires `requestSenderApproval` on first unknown
* message from a sender
* - In-flight dedup: second message from the same sender while pending is
* silently dropped (no second card, no second row)
* - Approve path: member added, original message replayed via routeInbound,
* container woken
* - Deny path: pending row deleted, no member added
*/
import fs from 'fs';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup, createMessagingGroupAgent } from '../../db/messaging-groups.js';
import { upsertUser } from './db/users.js';
import { grantRole } from './db/user-roles.js';
// Mock container runner — prevent actual docker spawn.
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
// Mock delivery adapter — record card deliveries for assertions.
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => ({
deliver: deliverMock,
}),
}));
// Mock ensureUserDm to return the approver's existing messaging group
// instead of hitting a real openDM RPC.
vi.mock('./user-dm.js', () => ({
ensureUserDm: vi.fn(async (userId: string) => {
const { getDb } = await import('../../db/connection.js');
const row = getDb()
.prepare(
`SELECT mg.* FROM messaging_groups mg
JOIN user_dms ud ON ud.messaging_group_id = mg.id
WHERE ud.user_id = ?`,
)
.get(userId);
return row;
}),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-sender-approval' };
});
const TEST_DIR = '/tmp/nanoclaw-test-sender-approval';
function now() {
return new Date().toISOString();
}
beforeEach(async () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
// Side-effect imports: register hooks (permissions module) AFTER the
// mocks are in place so the access gate / response handler pick up the
// mocked delivery + user-dm helpers.
await import('./index.js');
// Fixtures: agent group, messaging group with request_approval, wiring,
// owner + DM messaging group for approver delivery.
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createMessagingGroup({
id: 'mg-chat',
channel_type: 'telegram',
platform_id: 'chat-123',
name: 'Group Chat',
is_group: 1,
unknown_sender_policy: 'request_approval',
created_at: now(),
});
createMessagingGroupAgent({
id: 'mga-1',
messaging_group_id: 'mg-chat',
agent_group_id: 'ag-1',
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
// Owner user + their DM messaging group (pickApprover + ensureUserDm target).
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({
user_id: 'telegram:owner',
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: now(),
});
createMessagingGroup({
id: 'mg-dm-owner',
channel_type: 'telegram',
platform_id: 'dm-owner',
name: 'Owner DM',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
const { getDb } = await import('../../db/connection.js');
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (?, ?, ?, ?)`,
)
.run('telegram:owner', 'telegram', 'mg-dm-owner', now());
deliverMock.mockClear();
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
function stranger(text: string) {
return {
channelType: 'telegram',
platformId: 'chat-123',
threadId: null,
message: {
id: `stranger-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({
senderId: 'tg:stranger',
senderName: 'Stranger',
text,
}),
timestamp: now(),
},
};
}
describe('unknown-sender request_approval flow', () => {
it('delivers an approval card on first unknown message', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(stranger('hi'));
// Wait for the fire-and-forget requestSenderApproval to resolve.
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0];
expect(channel).toBe('telegram');
expect(platformId).toBe('dm-owner'); // delivered to owner's DM
expect(thread).toBeNull();
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
expect(payload.questionId).toMatch(/^nsa-/);
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_sender_approvals').all();
expect(rows).toHaveLength(1);
});
it('dedups a second message from the same stranger while pending', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(stranger('hello'));
await new Promise((r) => setTimeout(r, 10));
await routeInbound(stranger('are you there?'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }
).c;
expect(count).toBe(1);
});
it('approve → adds member and replays the original message', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { wakeContainer } = await import('../../container-runner.js');
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
await routeInbound(stranger('please let me in'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
expect(pending).toBeDefined();
// Fire the approve click through the response-handler chain.
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
value: 'approve',
userId: 'telegram:owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// Member row added for the stranger against the wired agent group.
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
expect(member).toBeDefined();
// Pending row cleared.
const stillPending = getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number };
expect(stillPending.c).toBe(0);
// Message replayed + container woken.
expect(wakeContainer).toHaveBeenCalled();
});
it('deny → deletes the pending row without adding a member', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(stranger('hello'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
expect(pending).toBeDefined();
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
value: 'reject',
userId: 'telegram:owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
const count = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }
).c;
expect(count).toBe(0);
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
expect(member).toBeUndefined();
});
});

View File

@@ -0,0 +1,152 @@
/**
* Unknown-sender approval flow.
*
* When `messaging_groups.unknown_sender_policy = 'request_approval'` and a
* non-member writes into a wired chat, the access gate drops the routing
* attempt and calls `requestSenderApproval` to:
*
* 1. Pick an eligible approver (owner / admin of the agent group).
* 2. Open / reuse a DM to that approver on a reachable channel.
* 3. Deliver an Approve / Deny card.
* 4. Record a pending_sender_approvals row that holds the original message
* so it can be re-routed on approve.
*
* On approve: the handler in index.ts adds an agent_group_members row for
* the sender and re-invokes routeInbound with the stored event — the second
* routing attempt passes the gate because the user is now a member.
*
* Failure modes (logged + row NOT created, so the dedup gate lets a future
* attempt try again):
* - No eligible approver in user_roles — fresh install, no owner yet.
* - Approver has no reachable DM (no user_dms row + channel can't
* openDM) — e.g. owner hasn't registered on any channel we're wired to.
* - Delivery adapter missing.
*
* Dedup: `pending_sender_approvals` has UNIQUE(messaging_group_id,
* sender_identity). A retry / rapid second message from the same unknown
* sender is silently dropped (no duplicate card sent).
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../router.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' },
{ label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' },
];
function generateId(): string {
return `nsa-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface RequestSenderApprovalInput {
messagingGroupId: string;
agentGroupId: string;
senderIdentity: string; // namespaced user id (channel_type:handle)
senderName: string | null;
event: InboundEvent;
}
export async function requestSenderApproval(input: RequestSenderApprovalInput): Promise<void> {
const { messagingGroupId, agentGroupId, senderIdentity, senderName, event } = input;
// In-flight dedup: don't spam the admin if the same unknown sender
// retries while a card is already pending.
if (hasInFlightSenderApproval(messagingGroupId, senderIdentity)) {
log.debug('Unknown-sender approval already in flight — dropping retry', {
messagingGroupId,
senderIdentity,
});
return;
}
const approvers = pickApprover(agentGroupId);
if (approvers.length === 0) {
log.warn('Unknown-sender approval skipped — no owner or admin configured', {
messagingGroupId,
agentGroupId,
senderIdentity,
});
return;
}
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
const target = await pickApprovalDelivery(approvers, originChannelType);
if (!target) {
log.warn('Unknown-sender approval skipped — no DM channel for any approver', {
messagingGroupId,
agentGroupId,
senderIdentity,
});
return;
}
const approvalId = generateId();
const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity;
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
const title = '👤 New sender';
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
createPendingSenderApproval({
id: approvalId,
messaging_group_id: messagingGroupId,
agent_group_id: agentGroupId,
sender_identity: senderIdentity,
sender_name: senderName,
original_message: JSON.stringify(event),
approver_user_id: target.userId,
created_at: new Date().toISOString(),
});
const adapter = getDeliveryAdapter();
if (!adapter) {
// Without a delivery adapter, the card can't be sent. Log + leave the
// row in place so the admin can see it via DB or manual tooling; the
// dedup gate will suppress further cards until it's cleared.
log.error('Unknown-sender approval row created but no delivery adapter is wired', {
approvalId,
});
return;
}
try {
await adapter.deliver(
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: approvalId,
title,
question,
options: APPROVAL_OPTIONS,
}),
);
log.info('Unknown-sender approval card delivered', {
approvalId,
senderIdentity,
approver: target.userId,
messagingGroupId,
agentGroupId,
});
} catch (err) {
log.error('Unknown-sender approval card delivery failed', {
approvalId,
err,
});
}
}
/**
* Option value the admin clicked that means "allow" — shared with the
* response handler so the two sides can't drift.
*/
export const APPROVE_VALUE = 'approve';
export const REJECT_VALUE = 'reject';