Moves user-roles / users / agent-group-members / user-dms / dropped-messages / user-dm / canAccessAgentGroup into src/modules/permissions/. Module registers a single inbound-gate that owns sender resolution, access decision, unknown-sender policy, and drop-audit recording. Router slimmed from 357 → 179 lines; the inline fallback chain (extractAndUpsertUser / enforceAccess / handleUnknownSender / recordDroppedMessage) is gone — without the permissions module core defaults to allow-all with userId=null. container-runner's admin-ID query is now inline SQL guarded by sqlite_master on user_roles, keeping core free of any import from the permissions module. The container-side formatter falls back to permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender with an identifiable senderId is treated as admin. Module contract doc formalizes the tier model and the dependency rule (core ← default modules ← optional modules). One transitional violation flagged: src/access.ts (core) imports from the permissions module for its remaining approver-picking helpers; resolves in the planned PR #7 re-tier. Validation: host build clean, 137/137 host tests, 17/17 container tests, typecheck clean, service boots to "NanoClaw running" with permissions module registering its gate and clean SIGTERM shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
/**
|
|
* Approval routing helpers (temporary home).
|
|
*
|
|
* These functions pick an approver for a sensitive action and resolve the
|
|
* DM messaging_group they should be delivered to. They're called only from
|
|
* the approvals module.
|
|
*
|
|
* PR #5 moved the access-decision half of this file (canAccessAgentGroup +
|
|
* AccessDecision) into src/modules/permissions/. The approver-picking half
|
|
* stays here as a temporary shim — PR #7 relocates it into a new default
|
|
* approvals-primitive module alongside the approvals re-tier.
|
|
*
|
|
* Tier note: this file lives in core but imports from the permissions
|
|
* optional module. That's a deliberate temporary violation; see the module
|
|
* contract + REFACTOR_PLAN open question #3.
|
|
*/
|
|
import {
|
|
getAdminsOfAgentGroup,
|
|
getGlobalAdmins,
|
|
getOwners,
|
|
} from './modules/permissions/db/user-roles.js';
|
|
import { ensureUserDm } from './modules/permissions/user-dm.js';
|
|
import type { MessagingGroup } from './types.js';
|
|
|
|
/**
|
|
* Ordered list of user IDs eligible to approve an action for the given agent
|
|
* group. Preference: admins @ that group → global admins → owners.
|
|
*/
|
|
export function pickApprover(agentGroupId: string | null): string[] {
|
|
const approvers: string[] = [];
|
|
const seen = new Set<string>();
|
|
const add = (id: string): void => {
|
|
if (!seen.has(id)) {
|
|
seen.add(id);
|
|
approvers.push(id);
|
|
}
|
|
};
|
|
|
|
if (agentGroupId) {
|
|
for (const r of getAdminsOfAgentGroup(agentGroupId)) add(r.user_id);
|
|
}
|
|
for (const r of getGlobalAdmins()) add(r.user_id);
|
|
for (const r of getOwners()) add(r.user_id);
|
|
|
|
return approvers;
|
|
}
|
|
|
|
/**
|
|
* Walk the approver list and return the first (approverId, messagingGroup)
|
|
* pair we can actually deliver to. Returns null if nobody is reachable.
|
|
*
|
|
* Tie-break rule (per model): prefer approvers reachable on the same channel
|
|
* kind as the origin; else first in list. Resolution uses ensureUserDm,
|
|
* which may trigger a platform openDM call on cache miss — that's how we
|
|
* support cold DMs to users who have never messaged the bot.
|
|
*/
|
|
export async function pickApprovalDelivery(
|
|
approvers: string[],
|
|
originChannelType: string,
|
|
): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> {
|
|
// Pass 1: approvers whose channel matches the origin (prefix on user id).
|
|
if (originChannelType) {
|
|
for (const userId of approvers) {
|
|
if (channelTypeOf(userId) !== originChannelType) continue;
|
|
const mg = await ensureUserDm(userId);
|
|
if (mg) return { userId, messagingGroup: mg };
|
|
}
|
|
}
|
|
// Pass 2: any reachable approver, in order.
|
|
for (const userId of approvers) {
|
|
const mg = await ensureUserDm(userId);
|
|
if (mg) return { userId, messagingGroup: mg };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function channelTypeOf(userId: string): string {
|
|
const idx = userId.indexOf(':');
|
|
return idx < 0 ? '' : userId.slice(0, idx);
|
|
}
|