diff --git a/src/access.ts b/src/access.ts deleted file mode 100644 index 818e5e8..0000000 --- a/src/access.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 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(); - 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); -} diff --git a/src/modules/approvals/index.ts b/src/modules/approvals/index.ts index e159deb..2bd8446 100644 --- a/src/modules/approvals/index.ts +++ b/src/modules/approvals/index.ts @@ -1,30 +1,29 @@ /** - * Approvals module — admin-gated self-modification and OneCLI credential flow. + * Approvals module — admin approval primitive + response plumbing. + * + * Default-tier module. Ships with main. Other modules depend on it by + * importing `requestApproval` / `registerApprovalHandler` from this module. * * Registers: - * - Three delivery actions the container writes via self-mod MCP tools: - * install_packages, request_rebuild, add_mcp_server. - * - A response handler that claims `pending_approvals` rows (agent-initiated - * approvals) + OneCLI credential approvals (resolved via in-memory Promise). + * - A response handler that claims pending_approvals rows and dispatches + * to whatever module registered for the row's `action` string. Also + * resolves in-memory OneCLI credential approvals. * - An adapter-ready callback that starts the OneCLI manual-approval handler * once the delivery adapter is set. * - A shutdown callback that stops the OneCLI handler cleanly. + * + * Self-mod flows (install_packages, request_rebuild, add_mcp_server) moved + * out to `src/modules/self-mod/` in PR #7 — they now register delivery + * actions + approval handlers via this module's public API. */ -import { registerDeliveryAction, onDeliveryAdapterReady } from '../../delivery.js'; +import { onDeliveryAdapterReady } from '../../delivery.js'; import { registerResponseHandler, onShutdown } from '../../response-registry.js'; -import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request-approval.js'; import { handleApprovalsResponse } from './response-handler.js'; import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js'; -registerDeliveryAction('install_packages', async (content, session) => { - await handleInstallPackages(content, session); -}); -registerDeliveryAction('request_rebuild', async (content, session) => { - await handleRequestRebuild(content, session); -}); -registerDeliveryAction('add_mcp_server', async (content, session) => { - await handleAddMcpServer(content, session); -}); +// Public API re-exports so consumers import from the module root. +export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js'; +export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js'; registerResponseHandler(handleApprovalsResponse); diff --git a/src/modules/approvals/onecli-approvals.ts b/src/modules/approvals/onecli-approvals.ts index 096935a..1594a82 100644 --- a/src/modules/approvals/onecli-approvals.ts +++ b/src/modules/approvals/onecli-approvals.ts @@ -19,7 +19,7 @@ */ import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk'; -import { pickApprovalDelivery, pickApprover } from '../../access.js'; +import { pickApprovalDelivery, pickApprover } from './primitive.js'; import { ONECLI_URL } from '../../config.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { diff --git a/src/modules/approvals/picks.test.ts b/src/modules/approvals/picks.test.ts new file mode 100644 index 0000000..508aa35 --- /dev/null +++ b/src/modules/approvals/picks.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for pickApprover + pickApprovalDelivery — the approver-selection + * half of what used to live in src/access.ts. Moved here in PR #7 alongside + * the approvals re-tier. + */ +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; + +import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js'; +import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js'; +import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../../db/index.js'; +import { createUser } from '../permissions/db/users.js'; +import { grantRole } from '../permissions/db/user-roles.js'; +import { pickApprovalDelivery, pickApprover } from './primitive.js'; + +function now(): string { + return new Date().toISOString(); +} + +beforeEach(() => { + const db = initTestDb(); + runMigrations(db); +}); + +afterEach(async () => { + await teardownChannelAdapters(); + closeDb(); +}); + +async function mountMockAdapter( + channelType: string, + openDM?: (handle: string) => Promise, +): Promise<{ delivered: OutboundMessage[]; openDMCalls: string[] }> { + const delivered: OutboundMessage[] = []; + const openDMCalls: string[] = []; + const adapter: ChannelAdapter = { + name: channelType, + channelType, + supportsThreads: false, + async setup() {}, + async teardown() {}, + isConnected() { + return true; + }, + async deliver(_platformId, _threadId, message) { + delivered.push(message); + return undefined; + }, + async setTyping() {}, + }; + if (openDM) { + adapter.openDM = async (handle: string) => { + openDMCalls.push(handle); + return openDM(handle); + }; + } + registerChannelAdapter(channelType, { factory: () => adapter }); + await initChannelAdapters(() => ({ + conversations: [], + onInbound: () => {}, + onMetadata: () => {}, + onAction: () => {}, + })); + return { delivered, openDMCalls }; +} + +function seedAgentGroup(id: string): void { + createAgentGroup({ + id, + name: id.toUpperCase(), + folder: id, + agent_provider: null, + created_at: now(), + }); +} + +function seedUser(id: string, kind: string): void { + createUser({ id, kind, display_name: null, created_at: now() }); +} + +describe('pickApprover', () => { + beforeEach(() => { + seedAgentGroup('ag-1'); + seedAgentGroup('ag-2'); + }); + + it('prefers scoped admins, then globals, then owners — deduplicated', () => { + seedUser('u-owner', 'telegram'); + seedUser('u-ga', 'telegram'); + seedUser('u-sa', 'telegram'); + grantRole({ user_id: 'u-owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() }); + grantRole({ user_id: 'u-ga', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() }); + + expect(pickApprover('ag-1')).toEqual(['u-sa', 'u-ga', 'u-owner']); + expect(pickApprover('ag-2')).toEqual(['u-ga', 'u-owner']); + expect(pickApprover(null)).toEqual(['u-ga', 'u-owner']); + }); + + it('returns empty list when nobody is privileged', () => { + expect(pickApprover('ag-1')).toEqual([]); + }); +}); + +describe('pickApprovalDelivery', () => { + beforeEach(() => { + seedAgentGroup('ag-1'); + }); + + it('returns the first reachable approver', async () => { + await mountMockAdapter('telegram'); + seedUser('telegram:111', 'telegram'); + seedUser('telegram:222', 'telegram'); + + const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram'); + expect(result?.userId).toBe('telegram:111'); + expect(result?.messagingGroup.platform_id).toBe('111'); + }); + + it('prefers same-channel-kind approver on tie-break', async () => { + await mountMockAdapter('telegram'); + await mountMockAdapter('discord', async (h) => `dm-${h}`); + seedUser('telegram:111', 'telegram'); + seedUser('discord:222', 'discord'); + + const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord'); + expect(result?.userId).toBe('discord:222'); + }); + + it('falls through to any reachable approver when none match origin', async () => { + await mountMockAdapter('telegram'); + seedUser('telegram:111', 'telegram'); + + const result = await pickApprovalDelivery(['telegram:111'], 'discord'); + expect(result?.userId).toBe('telegram:111'); + }); + + it('returns null when nobody is reachable', async () => { + seedUser('telegram:111', 'telegram'); + expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull(); + }); +}); diff --git a/src/modules/approvals/primitive.ts b/src/modules/approvals/primitive.ts new file mode 100644 index 0000000..45620fa --- /dev/null +++ b/src/modules/approvals/primitive.ts @@ -0,0 +1,220 @@ +/** + * Approvals primitive — the public API that other modules call. + * + * Two surfaces: + * - `requestApproval()` — queue an approval request, deliver the card to + * the right admin DM, record the pending_approvals row. Used by any + * module that needs admin confirmation before doing something sensitive. + * - `registerApprovalHandler(action, handler)` — called at module import + * time. When the admin approves a pending row with matching `action`, + * the response handler dispatches into the registered callback. Optional + * modules (self-mod, future module gates) register here. + * + * Approver picking lives here too — it used to sit in src/access.ts and got + * folded in with the PR #7 re-tier. The picks functions walk user_roles + * (owner, global admin, scoped admin) and resolve to a reachable DM via the + * permissions module's user-dm helper. + * + * Tier: default module. Permissions is an optional module, so importing from + * it here is technically a tier inversion — but the host bundles both with + * main, and the alternative (a third "permissions-primitive" default module + * exposing just user-roles/user-dms) is more churn than it's worth. Revisit + * if either module becomes genuinely optional (see REFACTOR_PLAN open q #3). + */ +import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; +import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { createPendingApproval, getSession } from '../../db/sessions.js'; +import { getDeliveryAdapter } from '../../delivery.js'; +import { wakeContainer } from '../../container-runner.js'; +import { log } from '../../log.js'; +import { writeSessionMessage } from '../../session-manager.js'; +import type { MessagingGroup, Session } from '../../types.js'; +import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js'; +import { ensureUserDm } from '../permissions/user-dm.js'; + +/** Two-button approval UI — the only options the primitive supports today. */ +const APPROVAL_OPTIONS: RawOption[] = [ + { label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' }, + { label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' }, +]; + +// ── Approval handler registry ── +// Modules that want to be called back when an admin approves a pending row +// register here at import time, keyed by the `action` string they used in +// their `requestApproval()` calls. + +export interface ApprovalHandlerContext { + session: Session; + payload: Record; + /** User ID of the admin who approved. Empty string if unknown. */ + userId: string; + /** Send a system chat message to the requesting agent's session. */ + notify: (text: string) => void; +} + +export type ApprovalHandler = (ctx: ApprovalHandlerContext) => Promise; + +const approvalHandlers = new Map(); + +export function registerApprovalHandler(action: string, handler: ApprovalHandler): void { + if (approvalHandlers.has(action)) { + log.warn('Approval handler re-registered (overwriting)', { action }); + } + approvalHandlers.set(action, handler); +} + +export function getApprovalHandler(action: string): ApprovalHandler | undefined { + return approvalHandlers.get(action); +} + +// ── Approver picking ── + +/** + * 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(); + 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: 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. + */ +export async function pickApprovalDelivery( + approvers: string[], + originChannelType: string, +): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> { + if (originChannelType) { + for (const userId of approvers) { + if (channelTypeOf(userId) !== originChannelType) continue; + const mg = await ensureUserDm(userId); + if (mg) return { userId, messagingGroup: mg }; + } + } + 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); +} + +// ── Request API ── + +/** Send a system chat to the agent's session. Used by callers and by the response handler. */ +export function notifyAgent(session: Session, text: string): void { + writeSessionMessage(session.agent_group_id, session.id, { + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), + }); + const fresh = getSession(session.id); + if (fresh) { + wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err })); + } +} + +export interface RequestApprovalOptions { + session: Session; + agentName: string; + /** Free-form action identifier. Must match the key the consumer registered via registerApprovalHandler. */ + action: string; + /** JSON-serializable opaque payload. Carried on the pending_approvals row, handed to the handler on approve. */ + payload: Record; + /** Card title shown to the admin. */ + title: string; + /** Card body shown to the admin. */ + question: string; +} + +/** + * Queue an approval request. Picks an approver, delivers the card to their + * DM, and records the pending_approvals row. Fire-and-forget from the + * caller's perspective — the admin's response kicks off the registered + * approval handler for this action via the response dispatcher. + */ +export async function requestApproval(opts: RequestApprovalOptions): Promise { + const { session, action, payload, title, question, agentName } = opts; + + const approvers = pickApprover(session.agent_group_id); + if (approvers.length === 0) { + notifyAgent(session, `${action} failed: no owner or admin configured to approve.`); + return; + } + + const originChannelType = session.messaging_group_id + ? (getMessagingGroup(session.messaging_group_id)?.channel_type ?? '') + : ''; + + const target = await pickApprovalDelivery(approvers, originChannelType); + if (!target) { + notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`); + return; + } + + const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS); + createPendingApproval({ + approval_id: approvalId, + session_id: session.id, + request_id: approvalId, + action, + payload: JSON.stringify(payload), + created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(normalizedOptions), + }); + + const adapter = getDeliveryAdapter(); + if (adapter) { + 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, + }), + ); + } catch (err) { + log.error('Failed to deliver approval card', { action, approvalId, err }); + notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`); + return; + } + } + + log.info('Approval requested', { action, approvalId, agentName, approver: target.userId }); +} diff --git a/src/modules/approvals/request-approval.ts b/src/modules/approvals/request-approval.ts deleted file mode 100644 index eecd8b5..0000000 --- a/src/modules/approvals/request-approval.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Delivery-action handlers for agent-initiated approval requests. - * - * Three actions the container can write into messages_out (via self-mod - * MCP tools): install_packages, request_rebuild, add_mcp_server. Each one - * delivers an approval card to an admin's DM and records a pending_approvals - * row. The admin clicks a button → handleApprovalResponse picks it up. - * - * Host-side sanitization for install_packages is defense-in-depth (the MCP - * tool validates first). Both layers matter — the DB row and eventual - * shell-exec trust it. - */ -import { pickApprovalDelivery, pickApprover } from '../../access.js'; -import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; -import { getAgentGroup } from '../../db/agent-groups.js'; -import { getMessagingGroup } from '../../db/messaging-groups.js'; -import { createPendingApproval, getSession } from '../../db/sessions.js'; -import { getDeliveryAdapter } from '../../delivery.js'; -import { wakeContainer } from '../../container-runner.js'; -import { log } from '../../log.js'; -import { writeSessionMessage } from '../../session-manager.js'; -import type { Session } from '../../types.js'; - -const APPROVAL_OPTIONS: RawOption[] = [ - { label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' }, - { label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' }, -]; - -/** Inline copy of delivery.ts's notifyAgent — sends a system chat to the agent. */ -function notifyAgent(session: Session, text: string): void { - writeSessionMessage(session.agent_group_id, session.id, { - id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind: 'chat', - timestamp: new Date().toISOString(), - platformId: session.agent_group_id, - channelType: 'agent', - threadId: null, - content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), - }); - const fresh = getSession(session.id); - if (fresh) { - wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err })); - } -} - -/** - * Send an approval request to a privileged user's DM and record a - * pending_approval row. Routing: admin @ originating agent group → owner. - * Tie-break: prefer an approver reachable on the same channel kind as the - * originating session's messaging group. Delivery always lands in the - * approver's DM (not the origin group), regardless of where the action - * was triggered. - */ -async function requestApproval( - session: Session, - agentName: string, - action: 'install_packages' | 'request_rebuild' | 'add_mcp_server', - payload: Record, - title: string, - question: string, -): Promise { - const approvers = pickApprover(session.agent_group_id); - if (approvers.length === 0) { - notifyAgent(session, `${action} failed: no owner or admin configured to approve.`); - return; - } - - const originChannelType = session.messaging_group_id - ? (getMessagingGroup(session.messaging_group_id)?.channel_type ?? '') - : ''; - - const target = await pickApprovalDelivery(approvers, originChannelType); - if (!target) { - notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`); - return; - } - - const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS); - createPendingApproval({ - approval_id: approvalId, - session_id: session.id, - request_id: approvalId, - action, - payload: JSON.stringify(payload), - created_at: new Date().toISOString(), - title, - options_json: JSON.stringify(normalizedOptions), - }); - - const adapter = getDeliveryAdapter(); - if (adapter) { - 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, - }), - ); - } catch (err) { - log.error('Failed to deliver approval card', { action, approvalId, err }); - notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`); - return; - } - } - - log.info('Approval requested', { action, approvalId, agentName, approver: target.userId }); -} - -export async function handleInstallPackages( - content: Record, - session: Session, -): Promise { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notifyAgent(session, 'install_packages failed: agent group not found.'); - return; - } - - const apt = (content.apt as string[]) || []; - const npm = (content.npm as string[]) || []; - const reason = (content.reason as string) || ''; - - const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; - const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; - const MAX_PACKAGES = 20; - if (apt.length + npm.length === 0) { - notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.'); - return; - } - if (apt.length + npm.length > MAX_PACKAGES) { - notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`); - return; - } - const invalidApt = apt.find((p) => !APT_RE.test(p)); - if (invalidApt) { - notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`); - log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt }); - return; - } - const invalidNpm = npm.find((p) => !NPM_RE.test(p)); - if (invalidNpm) { - notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`); - log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm }); - return; - } - - const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', '); - await requestApproval( - session, - agentGroup.name, - 'install_packages', - { apt, npm, reason }, - 'Install Packages Request', - `Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, - ); -} - -export async function handleRequestRebuild( - content: Record, - session: Session, -): Promise { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notifyAgent(session, 'request_rebuild failed: agent group not found.'); - return; - } - const reason = (content.reason as string) || ''; - await requestApproval( - session, - agentGroup.name, - 'request_rebuild', - { reason }, - 'Rebuild Request', - `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`, - ); -} - -export async function handleAddMcpServer( - content: Record, - session: Session, -): Promise { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notifyAgent(session, 'add_mcp_server failed: agent group not found.'); - return; - } - const serverName = content.name as string; - const command = content.command as string; - if (!serverName || !command) { - notifyAgent(session, 'add_mcp_server failed: name and command are required.'); - return; - } - await requestApproval( - session, - agentGroup.name, - 'add_mcp_server', - { - name: serverName, - command, - args: (content.args as string[]) || [], - env: (content.env as Record) || {}, - }, - 'Add MCP Request', - `Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`, - ); -} diff --git a/src/modules/approvals/response-handler.ts b/src/modules/approvals/response-handler.ts index 803268a..bd0c2c5 100644 --- a/src/modules/approvals/response-handler.ts +++ b/src/modules/approvals/response-handler.ts @@ -2,25 +2,24 @@ * Handle an admin's response to an approval card. * * Two categories of pending_approvals rows exist: - * 1. Agent-initiated actions (install_packages, request_rebuild, add_mcp_server). - * Fire-and-forget from the agent's perspective: we notify via chat on - * approve/reject, rebuild the image if applicable, then kill the container - * so the next wake picks up the new image. - * 2. OneCLI credential approvals (action = 'onecli_credential'). Resolved + * 1. Module-initiated actions — the module called `requestApproval()` with + * some free-form `action` string and registered a handler via + * `registerApprovalHandler(action, handler)`. On approve, we look up the + * handler and call it; on reject, we notify the agent and move on. + * 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved * via an in-memory Promise — see onecli-approvals.ts. * * The response handler is registered via core's `registerResponseHandler`; * core iterates handlers and the first one to return `true` claims the response. */ -import { updateContainerConfig } from '../../container-config.js'; -import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; -import { getAgentGroup } from '../../db/agent-groups.js'; +import { wakeContainer } from '../../container-runner.js'; import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js'; import type { ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { PendingApproval } from '../../types.js'; import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js'; +import { getApprovalHandler } from './primitive.js'; export async function handleApprovalsResponse(payload: ResponsePayload): Promise { // OneCLI credential approvals — resolved via in-memory Promise first. @@ -33,17 +32,17 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise if (!approval) return false; if (approval.action === ONECLI_ACTION) { - // Row exists but the in-memory resolver is gone (timer fired or process + // Row exists but the in-memory resolver is gone (timer fired or the process // was in a weird state). Nothing to do — just drop the row. deletePendingApproval(payload.questionId); return true; } - await handleAgentApproval(approval, payload.value, payload.userId ?? ''); + await handleRegisteredApproval(approval, payload.value, payload.userId ?? ''); return true; } -async function handleAgentApproval( +async function handleRegisteredApproval( approval: PendingApproval, selectedOption: string, userId: string, @@ -78,77 +77,26 @@ async function handleAgentApproval( return; } + // Approved — dispatch to the module that registered for this action. + const handler = getApprovalHandler(approval.action); + if (!handler) { + log.warn('No approval handler registered — row dropped', { + approvalId: approval.approval_id, + action: approval.action, + }); + notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`); + deletePendingApproval(approval.approval_id); + await wakeContainer(session); + return; + } + const payload = JSON.parse(approval.payload); - - if (approval.action === 'install_packages') { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notify('install_packages approved but agent group missing.'); - return; - } - updateContainerConfig(agentGroup.folder, (cfg) => { - if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); - if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); - }); - - const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', '); - log.info('Package install approved', { approvalId: approval.approval_id, userId }); - try { - await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); - // Schedule a follow-up prompt a few seconds after kill so the host sweep - // respawns the container on the new image and the agent verifies + reports. - writeSessionMessage(session.agent_group_id, session.id, { - id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind: 'chat', - timestamp: new Date().toISOString(), - platformId: session.agent_group_id, - channelType: 'agent', - threadId: null, - content: JSON.stringify({ - text: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`, - sender: 'system', - senderId: 'system', - }), - processAfter: new Date(Date.now() + 5000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''), - }); - log.info('Container rebuild completed (bundled with install)', { approvalId: approval.approval_id }); - } catch (e) { - notify( - `Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`, - ); - log.error('Bundled rebuild failed after install approval', { approvalId: approval.approval_id, err: e }); - } - } else if (approval.action === 'request_rebuild') { - try { - await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); - notify('Container image rebuilt. Your container will restart with the new image on the next message.'); - log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); - } catch (e) { - notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`); - log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); - } - } else if (approval.action === 'add_mcp_server') { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) { - notify('add_mcp_server approved but agent group missing.'); - return; - } - updateContainerConfig(agentGroup.folder, (cfg) => { - cfg.mcpServers[payload.name as string] = { - command: payload.command as string, - args: (payload.args as string[]) || [], - env: (payload.env as Record) || {}, - }; - }); - - killContainer(session.id, 'mcp server added'); - notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); - log.info('MCP server add approved', { approvalId: approval.approval_id, userId }); + try { + await handler({ session, payload, userId, notify }); + log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId }); + } catch (err) { + log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err }); + notify(`Your ${approval.action} was approved, but applying it failed: ${err instanceof Error ? err.message : String(err)}.`); } deletePendingApproval(approval.approval_id); diff --git a/src/modules/index.ts b/src/modules/index.ts index 27e9517..2df4477 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -13,9 +13,13 @@ * Registry-based modules (installed via /add- skills, pulled from the * `modules` branch): append imports below. */ -import './interactive/index.js'; +// Approvals (default tier) must load before self-mod (optional) so the +// registerApprovalHandler / requestApproval symbols are bound when self-mod +// registers its handlers at import time. import './approvals/index.js'; +import './interactive/index.js'; import './scheduling/index.js'; import './permissions/index.js'; import './agent-to-agent/index.js'; +import './self-mod/index.js'; diff --git a/src/access.test.ts b/src/modules/permissions/permissions.test.ts similarity index 64% rename from src/access.test.ts rename to src/modules/permissions/permissions.test.ts index 92c01ce..d76d0d6 100644 --- a/src/access.test.ts +++ b/src/modules/permissions/permissions.test.ts @@ -1,15 +1,19 @@ +/** + * Tests for the permissions module — canAccessAgentGroup, role helpers, and + * ensureUserDm. Moved here from src/access.test.ts in PR #7 alongside the + * approvals re-tier that deleted src/access.ts. + */ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; -import { pickApprovalDelivery, pickApprover } from './access.js'; -import type { ChannelAdapter, OutboundMessage } from './channels/adapter.js'; -import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from './channels/channel-registry.js'; -import { closeDb, createAgentGroup, createMessagingGroup, initTestDb, runMigrations } from './db/index.js'; -import { canAccessAgentGroup } from './modules/permissions/access.js'; -import { addMember, isMember } from './modules/permissions/db/agent-group-members.js'; -import { createUser } from './modules/permissions/db/users.js'; -import { grantRole, hasAnyOwner, isOwner } from './modules/permissions/db/user-roles.js'; -import { getUserDm } from './modules/permissions/db/user-dms.js'; -import { ensureUserDm } from './modules/permissions/user-dm.js'; +import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js'; +import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js'; +import { closeDb, createAgentGroup, createMessagingGroup, initTestDb, runMigrations } from '../../db/index.js'; +import { canAccessAgentGroup } from './access.js'; +import { addMember, isMember } from './db/agent-group-members.js'; +import { createUser } from './db/users.js'; +import { grantRole, hasAnyOwner, isOwner } from './db/user-roles.js'; +import { getUserDm } from './db/user-dms.js'; +import { ensureUserDm } from './user-dm.js'; function now(): string { return new Date().toISOString(); @@ -25,11 +29,6 @@ afterEach(async () => { closeDb(); }); -/** - * Register and activate a mock adapter for tests. `openDM` optional — omit - * to simulate direct-addressable channels (Telegram/WhatsApp), provide to - * simulate resolution-required channels (Discord/Slack). - */ async function mountMockAdapter( channelType: string, openDM?: (handle: string) => Promise, @@ -160,33 +159,9 @@ describe('role helpers', () => { }); }); -describe('pickApprover', () => { - beforeEach(() => { - seedAgentGroup('ag-1'); - seedAgentGroup('ag-2'); - }); - - it('prefers scoped admins, then globals, then owners — deduplicated', () => { - seedUser('u-owner', 'telegram'); - seedUser('u-ga', 'telegram'); - seedUser('u-sa', 'telegram'); - grantRole({ user_id: 'u-owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() }); - grantRole({ user_id: 'u-ga', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); - grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() }); - - expect(pickApprover('ag-1')).toEqual(['u-sa', 'u-ga', 'u-owner']); - expect(pickApprover('ag-2')).toEqual(['u-ga', 'u-owner']); - expect(pickApprover(null)).toEqual(['u-ga', 'u-owner']); - }); - - it('returns empty list when nobody is privileged', () => { - expect(pickApprover('ag-1')).toEqual([]); - }); -}); - describe('ensureUserDm', () => { it('adapter without openDM: falls through to using the bare handle as platform_id', async () => { - await mountMockAdapter('nodm'); // no openDM → direct-addressable fallback + await mountMockAdapter('nodm'); seedUser('nodm:123', 'nodm'); const mg = await ensureUserDm('nodm:123'); @@ -195,16 +170,11 @@ describe('ensureUserDm', () => { expect(mg!.platform_id).toBe('123'); expect(mg!.is_group).toBe(0); - // Cache row written const cached = getUserDm('nodm:123', 'nodm'); expect(cached?.messaging_group_id).toBe(mg!.id); }); it('Telegram via chat-sdk-bridge: adapter.openDM returns prefixed platform_id', async () => { - // Post-fix bridge behavior: the bridged Telegram adapter exposes openDM - // that delegates to the underlying @chat-adapter/telegram adapter, whose - // channelIdFromThreadId returns "telegram:". That's the same - // encoding onInbound stores in messaging_groups, so cache hits on repeat. const mock = await mountMockAdapter('telegram', async (handle) => `telegram:${handle}`); seedUser('telegram:6037840640', 'telegram'); @@ -213,7 +183,6 @@ describe('ensureUserDm', () => { expect(mg!.platform_id).toBe('telegram:6037840640'); expect(mock.openDMCalls).toEqual(['6037840640']); - // Second call hits the user_dms cache, not openDM again. const mg2 = await ensureUserDm('telegram:6037840640'); expect(mg2!.id).toBe(mg!.id); expect(mock.openDMCalls).toEqual(['6037840640']); @@ -228,10 +197,9 @@ describe('ensureUserDm', () => { expect(mg!.platform_id).toBe('dm-channel-user-1'); expect(mock.openDMCalls).toEqual(['user-1']); - // Second call should hit the cache, not openDM. const mg2 = await ensureUserDm('discord:user-1'); expect(mg2!.id).toBe(mg!.id); - expect(mock.openDMCalls).toEqual(['user-1']); // unchanged + expect(mock.openDMCalls).toEqual(['user-1']); }); it('returns null when the adapter is not registered', async () => { @@ -245,7 +213,6 @@ describe('ensureUserDm', () => { }); seedUser('slack:u1', 'slack'); expect(await ensureUserDm('slack:u1')).toBeNull(); - // No cache row should be written on failure expect(getUserDm('slack:u1', 'slack')).toBeUndefined(); }); @@ -268,45 +235,3 @@ describe('ensureUserDm', () => { expect(getUserDm('telegram:555', 'telegram')?.messaging_group_id).toBe('mg-preexisting'); }); }); - -describe('pickApprovalDelivery', () => { - beforeEach(() => { - seedAgentGroup('ag-1'); - }); - - it('returns the first reachable approver', async () => { - await mountMockAdapter('telegram'); - seedUser('telegram:111', 'telegram'); - seedUser('telegram:222', 'telegram'); - - // Both users are reachable (direct-addressable), so the first wins. - const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram'); - expect(result?.userId).toBe('telegram:111'); - expect(result?.messagingGroup.platform_id).toBe('111'); - }); - - it('prefers same-channel-kind approver on tie-break', async () => { - await mountMockAdapter('telegram'); - await mountMockAdapter('discord', async (h) => `dm-${h}`); - seedUser('telegram:111', 'telegram'); - seedUser('discord:222', 'discord'); - - // Origin is discord → discord approver wins even though telegram is first. - const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord'); - expect(result?.userId).toBe('discord:222'); - }); - - it('falls through to any reachable approver when none match origin', async () => { - await mountMockAdapter('telegram'); - seedUser('telegram:111', 'telegram'); - - const result = await pickApprovalDelivery(['telegram:111'], 'discord'); - expect(result?.userId).toBe('telegram:111'); - }); - - it('returns null when nobody is reachable', async () => { - // No adapter registered → no user is reachable. - seedUser('telegram:111', 'telegram'); - expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull(); - }); -}); diff --git a/src/modules/self-mod/agent.md b/src/modules/self-mod/agent.md new file mode 100644 index 0000000..33bca9b --- /dev/null +++ b/src/modules/self-mod/agent.md @@ -0,0 +1,31 @@ +# Self-modification + +You can install additional OS or npm packages, rebuild your container image, +or add new MCP servers — but only with admin approval. + +## Tools + +- `install_packages({ apt?: string[], npm?: string[], reason?: string })` — + adds the listed packages to your container config and rebuilds the image + after admin approval. Package names are validated strictly (`[a-z0-9._+-]` + for apt, standard npm naming with optional scope). Max 20 packages per + request. + +- `request_rebuild({ reason?: string })` — rebuilds your container image + without config changes. Useful if the image has drifted from config. + +- `add_mcp_server({ name, command, args?, env? })` — adds a new MCP server + to your container config. The container restarts on next message so the + new server is available. + +## Flow + +You call one of these tools → the host asks an admin via DM → admin approves +or rejects. On approve, the config is applied and the container is killed; +the host respawns it on the next message. You'll get a system chat message +confirming the outcome (either "Packages installed..." or a failure reason). + +On reject you'll see "Your X request was rejected by admin." + +If no admin is configured or reachable, the request fails immediately with +a chat notification explaining why. diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts new file mode 100644 index 0000000..da33fd0 --- /dev/null +++ b/src/modules/self-mod/apply.ts @@ -0,0 +1,89 @@ +/** + * Approval handlers for self-modification actions. + * + * The approvals module calls these when an admin clicks Approve on a + * pending_approvals row whose action matches. Each handler mutates the + * container config, rebuilds/kills the container as needed, and lets the + * host sweep respawn it on the new image on the next message. + */ +import { updateContainerConfig } from '../../container-config.js'; +import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; +import { getAgentGroup } from '../../db/agent-groups.js'; +import { log } from '../../log.js'; +import { writeSessionMessage } from '../../session-manager.js'; +import type { ApprovalHandler } from '../approvals/index.js'; + +export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notify('install_packages approved but agent group missing.'); + return; + } + updateContainerConfig(agentGroup.folder, (cfg) => { + if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); + if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); + }); + + const pkgs = [...((payload.apt as string[] | undefined) || []), ...((payload.npm as string[] | undefined) || [])].join(', '); + log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); + try { + await buildAgentGroupImage(session.agent_group_id); + killContainer(session.id, 'rebuild applied'); + // Schedule a follow-up prompt a few seconds after kill so the host sweep + // respawns the container on the new image and the agent verifies + reports. + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`, + sender: 'system', + senderId: 'system', + }), + processAfter: new Date(Date.now() + 5000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''), + }); + log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); + } catch (e) { + notify( + `Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`, + ); + log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e }); + } +}; + +export const applyRequestRebuild: ApprovalHandler = async ({ session, userId, notify }) => { + try { + await buildAgentGroupImage(session.agent_group_id); + killContainer(session.id, 'rebuild applied'); + notify('Container image rebuilt. Your container will restart with the new image on the next message.'); + log.info('Container rebuild approved and completed', { agentGroupId: session.agent_group_id, userId }); + } catch (e) { + notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`); + log.error('Container rebuild failed', { agentGroupId: session.agent_group_id, err: e }); + } +}; + +export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notify('add_mcp_server approved but agent group missing.'); + return; + } + updateContainerConfig(agentGroup.folder, (cfg) => { + cfg.mcpServers[payload.name as string] = { + command: payload.command as string, + args: (payload.args as string[]) || [], + env: (payload.env as Record) || {}, + }; + }); + + killContainer(session.id, 'mcp server added'); + notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId }); +}; diff --git a/src/modules/self-mod/index.ts b/src/modules/self-mod/index.ts new file mode 100644 index 0000000..aedf1dc --- /dev/null +++ b/src/modules/self-mod/index.ts @@ -0,0 +1,28 @@ +/** + * Self-modification module — admin-approved container mutations. + * + * Optional tier. Depends on the approvals default module for the request/ + * handler plumbing. On install the module registers: + * - Three delivery actions (install_packages, request_rebuild, add_mcp_server) + * that validate input and queue an approval via requestApproval(). + * - Three matching approval handlers that run on approve: mutate the + * container config, rebuild the image, kill the container so the next + * wake picks up the change. + * + * Without this module: the three MCP tools in the container still write + * outbound system messages with these actions, but delivery logs + * "Unknown system action" and drops them. Admin never sees a card; nothing + * changes. + */ +import { registerDeliveryAction } from '../../delivery.js'; +import { registerApprovalHandler } from '../approvals/index.js'; +import { applyAddMcpServer, applyInstallPackages, applyRequestRebuild } from './apply.js'; +import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request.js'; + +registerDeliveryAction('install_packages', handleInstallPackages); +registerDeliveryAction('request_rebuild', handleRequestRebuild); +registerDeliveryAction('add_mcp_server', handleAddMcpServer); + +registerApprovalHandler('install_packages', applyInstallPackages); +registerApprovalHandler('request_rebuild', applyRequestRebuild); +registerApprovalHandler('add_mcp_server', applyAddMcpServer); diff --git a/src/modules/self-mod/project.md b/src/modules/self-mod/project.md new file mode 100644 index 0000000..bb6a0ec --- /dev/null +++ b/src/modules/self-mod/project.md @@ -0,0 +1,34 @@ +# Self-mod module + +Optional-tier module that gives agents admin-gated self-modification: +installing OS/npm packages, rebuilding the container image, and registering +new MCP servers. All three paths go through the approvals module's request +primitive — no unapproved changes ever land. + +## What this module adds + +- Three delivery actions (`install_packages`, `request_rebuild`, `add_mcp_server`) + that the container's self-mod MCP tools write into outbound.db. On the host, + each handler validates input and queues an approval via + `approvals.requestApproval()`. +- Three matching approval handlers that run on approve: mutate the container + config via `updateContainerConfig`, rebuild the image via + `buildAgentGroupImage`, and kill the container so the host sweep respawns + it on the new image. + +## Dependency + +Self-mod depends on the approvals default module for: +- `requestApproval()` to enqueue admin confirmation cards +- `registerApprovalHandler(action, handler)` to run orchestration on approve +- `notifyAgent()` to send failure feedback back to the requesting agent + +It also calls core's `buildAgentGroupImage`, `killContainer`, and +`updateContainerConfig`. + +## Removing the module + +Delete `src/modules/self-mod/` and its import line in `src/modules/index.ts`. +The container's self-mod MCP tools will still write outbound system messages, +but core delivery will log `"Unknown system action"` and drop them — no +admin card, no container mutation. diff --git a/src/modules/self-mod/request.ts b/src/modules/self-mod/request.ts new file mode 100644 index 0000000..d965616 --- /dev/null +++ b/src/modules/self-mod/request.ts @@ -0,0 +1,106 @@ +/** + * Delivery-action handlers for agent-initiated self-modification requests. + * + * Three actions the container can write into messages_out (via the self-mod + * MCP tools): install_packages, request_rebuild, add_mcp_server. Each one + * validates input and queues an approval request. The admin's approval + * triggers the matching approval handler in ./apply.ts. + * + * Host-side sanitization for install_packages is defense-in-depth — the MCP + * tool validates first. Both layers matter: the DB row carries the payload + * verbatim through to shell exec on apply. + */ +import { getAgentGroup } from '../../db/agent-groups.js'; +import { log } from '../../log.js'; +import type { Session } from '../../types.js'; +import { notifyAgent, requestApproval } from '../approvals/index.js'; + +export async function handleInstallPackages(content: Record, session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notifyAgent(session, 'install_packages failed: agent group not found.'); + return; + } + + const apt = (content.apt as string[]) || []; + const npm = (content.npm as string[]) || []; + const reason = (content.reason as string) || ''; + + const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; + const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; + const MAX_PACKAGES = 20; + if (apt.length + npm.length === 0) { + notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.'); + return; + } + if (apt.length + npm.length > MAX_PACKAGES) { + notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`); + return; + } + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) { + notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`); + log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt }); + return; + } + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) { + notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`); + log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm }); + return; + } + + const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', '); + await requestApproval({ + session, + agentName: agentGroup.name, + action: 'install_packages', + payload: { apt, npm, reason }, + title: 'Install Packages Request', + question: `Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + }); +} + +export async function handleRequestRebuild(content: Record, session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notifyAgent(session, 'request_rebuild failed: agent group not found.'); + return; + } + const reason = (content.reason as string) || ''; + await requestApproval({ + session, + agentName: agentGroup.name, + action: 'request_rebuild', + payload: { reason }, + title: 'Rebuild Request', + question: `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`, + }); +} + +export async function handleAddMcpServer(content: Record, session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notifyAgent(session, 'add_mcp_server failed: agent group not found.'); + return; + } + const serverName = content.name as string; + const command = content.command as string; + if (!serverName || !command) { + notifyAgent(session, 'add_mcp_server failed: name and command are required.'); + return; + } + await requestApproval({ + session, + agentName: agentGroup.name, + action: 'add_mcp_server', + payload: { + name: serverName, + command, + args: (content.args as string[]) || [], + env: (content.env as Record) || {}, + }, + title: 'Add MCP Request', + question: `Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`, + }); +}