Files
nanoclaw/src/access.ts
gavrielc 7cc4ecc3be refactor(modules): extract permissions as optional module
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>
2026-04-18 17:42:14 +03:00

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);
}