refactor(permissions): preserve pre-PR behavior in three spots

PR #5 review flagged three behavior changes that shouldn't have slipped
in. This commit reverts each to match the pre-refactor behavior exactly.

1. User upsert ordering. Split the router hook into two setters:
   setSenderResolver (runs before agent resolution) and setAccessGate
   (runs after). Restores the pre-PR sequence where the users row is
   upserted even if the message is dropped by wiring or trigger rules.

2. dropped_messages audit. Moved src/modules/permissions/db/dropped-messages.ts
   back to src/db/dropped-messages.ts. The table is core audit infra, not
   permissions-specific. Router re-writes rows for no_agent_wired and
   no_trigger_match; the access gate writes rows for policy refusals.

3. Permissionless container fallback. Dropped. poll-loop restores the
   original deny-all check when NANOCLAW_ADMIN_USER_IDS is empty.

Module contract doc updated with the two-hook shape.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering both hooks and clean SIGTERM shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 18:00:10 +03:00
parent 7cc4ecc3be
commit 32bcc2c5ae
5 changed files with 142 additions and 75 deletions

View File

@@ -80,10 +80,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Handle commands: categorize chat messages // Handle commands: categorize chat messages
const adminUserIds = config.adminUserIds ?? new Set<string>(); const adminUserIds = config.adminUserIds ?? new Set<string>();
// Permissionless mode: when the permissions module isn't installed on
// the host, NANOCLAW_ADMIN_USER_IDS arrives empty. Treat every sender
// with an identifiable senderId as admin so admin commands still work.
const permissionless = adminUserIds.size === 0;
const normalMessages = []; const normalMessages = [];
const commandIds: string[] = []; const commandIds: string[] = [];
@@ -103,8 +99,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
} }
if (cmdInfo.category === 'admin') { if (cmdInfo.category === 'admin') {
const authorized = permissionless ? !!cmdInfo.senderId : !!cmdInfo.senderId && adminUserIds.has(cmdInfo.senderId); if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) {
if (!authorized) {
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`); log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
writeMessageOut({ writeMessageOut({
id: generateId(), id: generateId(),

View File

@@ -68,30 +68,42 @@ export function registerDeliveryAction(action: string, handler: ActionHandler):
**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`). **Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`).
### 2. Router inbound gate ### 2. Router sender resolver + access gate
Two separate setters, called at different points in `routeInbound`. Preserves the pre-refactor ordering: sender-upsert side effects fire even when the message is ultimately dropped by wiring or trigger rules.
```typescript ```typescript
// src/router.ts // src/router.ts
type InboundGateResult = type SenderResolverFn = (event: InboundEvent) => string | null;
| { allowed: true; userId: string | null }
| { allowed: false; userId: string | null; reason: string };
type InboundGateFn = ( export function setSenderResolver(fn: SenderResolverFn): void;
type AccessGateResult =
| { allowed: true }
| { allowed: false; reason: string };
type AccessGateFn = (
event: InboundEvent, event: InboundEvent,
userId: string | null,
mg: MessagingGroup, mg: MessagingGroup,
agentGroupId: string, agentGroupId: string,
) => InboundGateResult; ) => AccessGateResult;
export function setInboundGate(fn: InboundGateFn): void; export function setAccessGate(fn: AccessGateFn): void;
``` ```
**Purpose:** single-setter gate that owns both sender resolution (user upsert) and access decision. Takes the raw event because the permissions module needs the sender fields inside `event.message.content`. **Call order in `routeInbound`:**
1. Resolve messaging group.
2. **Sender resolver** (if set). Permissions upserts the users row here so the record exists even if agent resolution drops the message.
3. Resolve wired agents; `no_agent_wired` → record + drop. (Core writes the dropped_messages row.)
4. Pick agent by trigger rules; `no_trigger_match` → record + drop.
5. **Access gate** (if set). On refusal it writes its own `dropped_messages` row keyed by policy reason.
**Default when unset:** `{ allowed: true, userId: null }`. Every message routes through, no users table is needed, downstream must tolerate `userId=null`. **Defaults when unset:** resolver returns null; gate defaults to `{ allowed: true }`. Every message routes through, no users table is needed, downstream tolerates `userId=null`.
**Current consumer:** permissions module. **Current consumer:** permissions module (registers both).
**Not a registry, a setter.** There is one decision per inbound message and one module that owns it. Calling `setInboundGate` twice overwrites; core does not iterate. **Not registries, setters.** There is one sender and one access decision per inbound message and one module that owns both. Calling `setSenderResolver` / `setAccessGate` twice overwrites; core does not iterate.
### 3. Response dispatcher ### 3. Response dispatcher

View File

@@ -1,4 +1,4 @@
import { getDb } from '../../../db/connection.js'; import { getDb } from './connection.js';
export interface UnregisteredSender { export interface UnregisteredSender {
channel_type: string; channel_type: string;

View File

@@ -1,25 +1,25 @@
/** /**
* Permissions module — sender resolution + access gate. * Permissions module — sender resolution + access gate.
* *
* Registers a single inbound-gate via setInboundGate(). The gate owns: * Registers two hooks into the core router:
* 1. Sender resolution: parse the channel adapter's payload, derive a * 1. setSenderResolver — runs before agent resolution. Parses the payload,
* namespaced user id, and upsert the `users` row on first sight so * derives a namespaced user id, and upserts the `users` row on first
* role/access lookups land on a real record thereafter. * sight. Returns null when the payload doesn't carry enough to identify
* 2. Access decision: owners → global admins → scoped admins → members. * a sender.
* 3. Unknown-sender policy: strict drops; request_approval is a TODO * 2. setAccessGate — runs after agent resolution. Enforces the
* (pending the `add_group_member` action kind). * unknown_sender_policy (strict/request_approval/public) and the
* 4. Audit trail: drops get logged into `dropped_messages`. * owner/global-admin/scoped-admin/member access hierarchy. Records its
* own `dropped_messages` row on refusal (structural drops are recorded
* by core).
* *
* Without this module: core's router defaults to allow-all (PR #2), every * Without this module: sender resolution is a no-op (userId=null); the
* message routes through, and no users table is needed. Drops are not * access gate is not registered and core defaults to allow-all.
* recorded anywhere. Admin commands inside the container fall back to
* permissionless mode (see formatter.ts).
*/ */
import { setInboundGate, type InboundEvent, type InboundGateResult } from '../../router.js'; import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { setAccessGate, setSenderResolver, type AccessGateResult, type InboundEvent } from '../../router.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { MessagingGroup } from '../../types.js'; import type { MessagingGroup } from '../../types.js';
import { canAccessAgentGroup } from './access.js'; import { canAccessAgentGroup } from './access.js';
import { recordDroppedMessage } from './db/dropped-messages.js';
import { getUser, upsertUser } from './db/users.js'; import { getUser, upsertUser } from './db/users.js';
function extractAndUpsertUser(event: InboundEvent): string | null { function extractAndUpsertUser(event: InboundEvent): string | null {
@@ -111,24 +111,24 @@ function handleUnknownSender(
// 'public' should have been handled before the gate; fall through silently. // 'public' should have been handled before the gate; fall through silently.
} }
setInboundGate((event, mg, agentGroupId): InboundGateResult => { setSenderResolver(extractAndUpsertUser);
const userId = extractAndUpsertUser(event);
setAccessGate((event, userId, mg, agentGroupId): AccessGateResult => {
// Public channels skip the access check entirely. // Public channels skip the access check entirely.
if (mg.unknown_sender_policy === 'public') { if (mg.unknown_sender_policy === 'public') {
return { allowed: true, userId }; return { allowed: true };
} }
if (!userId) { if (!userId) {
handleUnknownSender(mg, null, agentGroupId, 'unknown_user', event); handleUnknownSender(mg, null, agentGroupId, 'unknown_user', event);
return { allowed: false, userId: null, reason: 'unknown_user' }; return { allowed: false, reason: 'unknown_user' };
} }
const decision = canAccessAgentGroup(userId, agentGroupId); const decision = canAccessAgentGroup(userId, agentGroupId);
if (decision.allowed) { if (decision.allowed) {
return { allowed: true, userId }; return { allowed: true };
} }
handleUnknownSender(mg, userId, agentGroupId, decision.reason, event); handleUnknownSender(mg, userId, agentGroupId, decision.reason, event);
return { allowed: false, userId, reason: decision.reason }; return { allowed: false, reason: decision.reason };
}); });

View File

@@ -1,16 +1,24 @@
/** /**
* Inbound message routing. * Inbound message routing.
* *
* Channel adapter event → resolve messaging group → pick agent → inbound * Channel adapter event → resolve messaging group → sender resolver →
* gate (if set) → resolve/create session → write messages_in → wake * resolve/pick agent → access gate → resolve/create session → write
* container. * messages_in → wake container.
* *
* Access model lives in the permissions module via `setInboundGate`. Without * Two module hooks (registered by the permissions module):
* the module, the gate is unset and every message routes through * - `setSenderResolver` runs BEFORE agent resolution so user rows get
* (downstream code tolerates `userId=null`). Drops by policy are only * upserted even if the message ends up dropped by agent wiring.
* recorded when the permissions module is loaded; core just logs. * Without the module, userId is null and downstream code tolerates it.
* - `setAccessGate` runs AFTER agent resolution so policy decisions can
* branch on the target agent group. Without the module, access is
* allow-all.
*
* `dropped_messages` is core audit infra. Core writes rows for structural
* drops (no agent wired, no trigger match); the access gate writes rows
* for policy refusals.
*/ */
import { getChannelAdapter } from './channels/channel-registry.js'; import { getChannelAdapter } from './channels/channel-registry.js';
import { recordDroppedMessage } from './db/dropped-messages.js';
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js'; import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
import { startTypingRefresh } from './modules/typing/index.js'; import { startTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js'; import { log } from './log.js';
@@ -36,29 +44,57 @@ export interface InboundEvent {
} }
/** /**
* Inbound gate hook. * Sender-resolver hook. Runs before agent resolution.
* *
* The permissions module registers a gate that owns sender resolution + * The permissions module registers this to extract the sender's namespaced
* access decision + unknown-sender policy + drop-audit recording. Without * user id and upsert the users row. Returns null when the payload doesn't
* a gate, core defaults to allow-all with `userId=null`. * carry enough info to identify a sender. Without the hook, every message
* * arrives at the gate with userId=null.
* Takes the raw event so the gate can read sender fields from
* `event.message.content`. Returns either allowed=true with a `userId`
* (null if unresolved) or allowed=false with a reason; core drops on refusal.
*/ */
export type InboundGateResult = export type SenderResolverFn = (event: InboundEvent) => string | null;
| { allowed: true; userId: string | null }
| { allowed: false; userId: string | null; reason: string };
export type InboundGateFn = (event: InboundEvent, mg: MessagingGroup, agentGroupId: string) => InboundGateResult; let senderResolver: SenderResolverFn | null = null;
let inboundGate: InboundGateFn | null = null; export function setSenderResolver(fn: SenderResolverFn): void {
if (senderResolver) {
export function setInboundGate(fn: InboundGateFn): void { log.warn('Sender resolver overwritten');
if (inboundGate) { }
log.warn('Inbound gate overwritten'); senderResolver = fn;
}
/**
* Access-gate hook. Runs after agent resolution.
*
* The permissions module registers this; without it, core defaults to
* allow-all. The gate receives the raw event so it can extract the sender
* name for audit-trail purposes, and it is responsible for recording its
* own `dropped_messages` row on refusal (structural drops are already
* recorded by core before the gate runs).
*/
export type AccessGateResult = { allowed: true } | { allowed: false; reason: string };
export type AccessGateFn = (
event: InboundEvent,
userId: string | null,
mg: MessagingGroup,
agentGroupId: string,
) => AccessGateResult;
let accessGate: AccessGateFn | null = null;
export function setAccessGate(fn: AccessGateFn): void {
if (accessGate) {
log.warn('Access gate overwritten');
}
accessGate = fn;
}
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
try {
return JSON.parse(raw);
} catch {
return { text: raw };
} }
inboundGate = fn;
} }
/** /**
@@ -95,7 +131,13 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
}); });
} }
// 2. Resolve agent groups wired to this messaging group. // 2. Sender resolution (permissions module upserts the users row as a
// side effect so later role/access lookups find a real record).
// Without the module, userId is null — downstream tolerates it.
const userId: string | null = senderResolver ? senderResolver(event) : null;
// 3. Resolve agent groups wired to this messaging group. Structural
// drops record to dropped_messages for audit.
const agents = getMessagingGroupAgents(mg.id); const agents = getMessagingGroupAgents(mg.id);
if (agents.length === 0) { if (agents.length === 0) {
log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', { log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', {
@@ -103,6 +145,16 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
channelType: event.channelType, channelType: event.channelType,
platformId: event.platformId, platformId: event.platformId,
}); });
const parsed = safeParseContent(event.message.content);
recordDroppedMessage({
channel_type: event.channelType,
platform_id: event.platformId,
user_id: userId,
sender_name: parsed.sender ?? null,
reason: 'no_agent_wired',
messaging_group_id: mg.id,
agent_group_id: null,
});
return; return;
} }
@@ -112,17 +164,25 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
messagingGroupId: mg.id, messagingGroupId: mg.id,
channelType: event.channelType, channelType: event.channelType,
}); });
const parsed = safeParseContent(event.message.content);
recordDroppedMessage({
channel_type: event.channelType,
platform_id: event.platformId,
user_id: userId,
sender_name: parsed.sender ?? null,
reason: 'no_trigger_match',
messaging_group_id: mg.id,
agent_group_id: null,
});
return; return;
} }
// 3. Inbound gate (if the permissions module is loaded). Otherwise // 4. Access gate (if the permissions module is loaded). Otherwise
// allow-all with userId=null — downstream code tolerates null. // allow-all.
let userId: string | null = null; if (accessGate) {
if (inboundGate) { const result = accessGate(event, userId, mg, match.agent_group_id);
const result = inboundGate(event, mg, match.agent_group_id);
userId = result.userId;
if (!result.allowed) { if (!result.allowed) {
log.info('MESSAGE DROPPED — inbound gate refused', { log.info('MESSAGE DROPPED — access gate refused', {
messagingGroupId: mg.id, messagingGroupId: mg.id,
agentGroupId: match.agent_group_id, agentGroupId: match.agent_group_id,
userId, userId,
@@ -132,7 +192,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
} }
} }
// 4. Resolve or create session. // 5. Resolve or create session.
// //
// Adapter thread policy overrides the wiring's session_mode: if the adapter // Adapter thread policy overrides the wiring's session_mode: if the adapter
// is threaded, each thread gets its own session regardless of what the // is threaded, each thread gets its own session regardless of what the
@@ -148,7 +208,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
} }
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode); const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
// 5. Write message to session DB // 6. Write message to session DB
writeSessionMessage(session.agent_group_id, session.id, { writeSessionMessage(session.agent_group_id, session.id, {
id: event.message.id || generateId(), id: event.message.id || generateId(),
kind: event.message.kind, kind: event.message.kind,
@@ -167,10 +227,10 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
created, created,
}); });
// 6. Show typing indicator while the agent processes. // 7. Show typing indicator while the agent processes.
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
// 7. Wake container // 8. Wake container
const freshSession = getSession(session.id); const freshSession = getSession(session.id);
if (freshSession) { if (freshSession) {
await wakeContainer(freshSession); await wakeContainer(freshSession);