feat(permissions): unknown-channel registration flow with owner approval

When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).

Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
  mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
  rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
  in-flight dedup. One card per channel, no spam on rapid retries.

Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
  from the no-wirings branch when the message was addressed to the bot
  (isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
  and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
  no agents) are consolidated into one escalation path that calls the
  gate once. Without the module, behavior is log + record (no regression).

Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
  agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
  to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
  primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
  must be the designated approver OR have admin privilege over the
  target agent group).
- Approve defaults per the feature spec:
    engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
    sender_scope = 'known'
    ignored_message_policy = 'accumulate'
    session_mode = 'shared'
  DM vs group inferred from the original event's threadId (non-null →
  group) because the auto-created mg has a placeholder is_group=0 until
  the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
  'known' doesn't bounce the replayed message into a sender-approval
  cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
  drop without creating a pending row, letting a future attempt try
  again (same as sender-approval).

9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.

168 tests pass (was 159; +9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 14:34:00 +03:00
parent a4061a0012
commit 719f97e483
9 changed files with 882 additions and 18 deletions

View File

@@ -100,6 +100,20 @@ export function deleteMessagingGroup(id: string): void {
getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id);
}
/**
* Mark a messaging group as denied by the owner (channel-registration flow).
* Future mentions on this channel silently drop until an admin explicitly
* wires it via `createMessagingGroupAgent`, which implicitly clears the
* denied state by making `agentCount > 0` — the router's denied-channel
* check sits on the `agentCount === 0` branch.
*
* Passing null unsets the flag (used by tests or a future "unblock channel"
* admin command).
*/
export function setMessagingGroupDeniedAt(id: string, deniedAt: string | null): void {
getDb().prepare('UPDATE messaging_groups SET denied_at = ? WHERE id = ?').run(deniedAt, id);
}
// ── Messaging Group Agents ──
/**

View File

@@ -0,0 +1,48 @@
/**
* Unknown-channel registration flow.
*
* When a channel that isn't wired to any agent group receives a mention or
* DM, the router escalates to the owner for approval before wiring. Approve
* creates a `messaging_group_agents` row (with conservative defaults) and
* replays the triggering event. Deny marks the channel denied forever
* (stored as a timestamp on `messaging_groups.denied_at`) so future
* messages on that channel drop silently without re-prompting.
*
* Two changes:
* 1. `messaging_groups.denied_at TEXT NULL` — set on deny, checked in the
* router before re-escalating. ALTER TABLE ADD COLUMN is FK-safe
* unlike the table rebuild that bit us in migration 011.
* 2. `pending_channel_approvals` table. PRIMARY KEY on
* `messaging_group_id` gives free in-flight dedup — a second mention
* while the card is pending is silently dropped by INSERT OR IGNORE,
* preventing card spam.
*/
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration012: Migration = {
version: 12,
name: 'channel-registration',
up: (db: Database.Database) => {
// 1. Add denied_at to messaging_groups. Idempotent guard in case the
// column was added by some other path before this migration ran.
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'denied_at')) {
db.exec(`ALTER TABLE messaging_groups ADD COLUMN denied_at TEXT`);
}
// 2. pending_channel_approvals.
db.exec(`
CREATE TABLE IF NOT EXISTS pending_channel_approvals (
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
-- The agent the approved wiring will target.
-- Picked at request time (currently: earliest
-- agent_group by created_at).
original_message TEXT NOT NULL, -- JSON serialized InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
},
};

View File

@@ -8,6 +8,7 @@ import { migration008 } from './008-dropped-messages.js';
import { migration009 } from './009-drop-pending-credentials.js';
import { migration010 } from './010-engage-modes.js';
import { migration011 } from './011-pending-sender-approvals.js';
import { migration012 } from './012-channel-registration.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
@@ -27,6 +28,7 @@ const migrations: Migration[] = [
migration009,
migration010,
migration011,
migration012,
];
export function runMigrations(db: Database.Database): void {