feat: named destinations + permission enforcement + fire-and-forget self-mod

Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with
per-agent named destination maps. Agents reference channels and peer
agents by local names; the host re-validates every outbound route against
a new agent_destinations table that is both the routing map and the ACL.

Model changes:
- New migration 004 adds agent_destinations (agent_group_id, local_name,
  target_type, target_id). Backfills from existing messaging_group_agents.
- Host writes /workspace/.nanoclaw-destinations.json before every container
  wake so admin changes take effect on next start.
- Container loads map at startup, appends system-prompt addendum listing
  available destinations and the <message to="name">…</message> syntax.
- Agent main output is parsed for <message to="..."> blocks; each block
  becomes a messages_out row with routing resolved via the local map.
  Untagged text and <internal>…</internal> are scratchpad (logged only).
- send_message MCP tool now takes `to` (destination name) instead of raw
  routing fields. send_to_agent deleted (redundant — agents are just
  destinations). send_file/edit_message/add_reaction route via map too.
- Inbound formatter adds from="name" attribute via reverse-lookup so the
  agent sees a consistent namespace in both directions.

Permission enforcement:
- Host checks hasDestination() before every channel delivery AND every
  agent-to-agent route. Unauthorized messages dropped and logged.
- routeAgentMessage simplified: ~15 lines, no JSON parse, content copied
  verbatim (target formatter resolves the sender via its own local map).
- create_agent is admin-only, checked at both the container (tool not
  registered for non-admins) and the host (re-check on receive). Inserts
  bidirectional destination rows so parent↔child comms work immediately.
  Includes path-traversal guard on folder name.

Self-modification cleanup:
- add_mcp_server now requires admin approval (previously had none).
- install_packages validates package names on BOTH sides (container tool
  + host receiver) with strict regex. Max 20 packages per request.
- All three self-mod tools are fire-and-forget: write request, return
  immediately with "submitted" message. Admin approval triggers a chat
  notification to the requesting agent — no tool-call polling, no 5-min
  holds. On rebuild/mcp_server approval, the container is killed so the
  next wake picks up new config/image.
- Approval delivery extracted into requestApproval() helper (the one
  place where three call sites were literally identical).

Also folded in the phase-1 dynamic import cleanup (create_agent no longer
does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID
/ CHANNEL_TYPE / THREAD_ID env-var routing entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-10 16:31:37 +03:00
parent 4004a6b284
commit e83ffbc103
21 changed files with 942 additions and 418 deletions

View File

@@ -1,7 +1,13 @@
/**
* Agent-to-agent MCP tools: send_to_agent, create_agent.
* Agent management MCP tools: create_agent.
*
* send_to_agent was removed — sending to another agent is now just
* send_message(to="agent-name") since agents and channels share the
* unified destinations namespace.
*
* create_agent is admin-only. Non-admin containers never see this tool
* (see mcp-tools/index.ts). The host re-checks permission on receive.
*/
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
@@ -21,55 +27,16 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const sendToAgent: McpToolDefinition = {
tool: {
name: 'send_to_agent',
description: 'Send a message to another agent group.',
inputSchema: {
type: 'object' as const,
properties: {
agentGroupId: { type: 'string', description: 'Target agent group ID' },
text: { type: 'string', description: 'Message content' },
sessionId: { type: 'string', description: 'Target specific session (optional)' },
},
required: ['agentGroupId', 'text'],
},
},
async handler(args) {
const agentGroupId = args.agentGroupId as string;
const text = args.text as string;
if (!agentGroupId || !text) return err('agentGroupId and text are required');
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
channel_type: 'agent',
platform_id: agentGroupId,
thread_id: (args.sessionId as string) || null,
content: JSON.stringify({ text }),
});
log(`send_to_agent: ${id}${agentGroupId}`);
return ok(`Message sent to agent ${agentGroupId} (id: ${id})`);
},
};
export const createAgent: McpToolDefinition = {
tool: {
name: 'create_agent',
description: 'Create a new agent group dynamically. Returns the new agent group ID.',
description:
'Create a new child agent with a given name. The name you choose becomes the destination name you use to message this agent. Admin-only. Fire-and-forget — you will receive a notification when the agent is created.',
inputSchema: {
type: 'object' as const,
properties: {
name: { type: 'string', description: 'Agent display name' },
instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' },
folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' },
name: { type: 'string', description: 'Human-readable name (also becomes your destination name for this agent)' },
instructions: { type: 'string', description: 'CLAUDE.md content for the new agent (personality, role, instructions)' },
},
required: ['name'],
},
@@ -79,7 +46,6 @@ export const createAgent: McpToolDefinition = {
if (!name) return err('name is required');
const requestId = generateId();
writeMessageOut({
id: requestId,
kind: 'system',
@@ -88,28 +54,12 @@ export const createAgent: McpToolDefinition = {
requestId,
name,
instructions: (args.instructions as string) || null,
folder: (args.folder as string) || null,
}),
});
log(`create_agent: ${requestId} → "${name}"`);
// Poll for host response
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const response = findQuestionResponse(requestId);
if (response) {
const parsed = JSON.parse(response.content);
markCompleted([response.id]);
if (parsed.status === 'success') {
return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`);
}
return err(parsed.result?.error || 'Failed to create agent');
}
await sleep(1000);
}
return err('Timed out waiting for agent creation response');
return ok(`Creating agent "${name}". You will be notified when it is ready.`);
},
};
export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent];
export const agentTools: McpToolDefinition[] = [createAgent];

View File

@@ -1,10 +1,16 @@
/**
* Core MCP tools: send_message, send_file, edit_message, add_reaction.
*
* All outbound tools resolve destinations via the local destination map
* (see destinations.ts). Agents reference destinations by name; the map
* translates name → routing tuple. Permission enforcement happens on
* the host side in delivery.ts via the agent_destinations table.
*/
import fs from 'fs';
import path from 'path';
import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -15,14 +21,6 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
@@ -31,68 +29,89 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function destinationList(): string {
const all = getAllDestinations();
if (all.length === 0) return '(none)';
return all.map((d) => d.name).join(', ');
}
function resolveRouting(
to: string,
): { channel_type: string; platform_id: string } | { error: string } {
const dest = findByName(to);
if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
if (dest.type === 'channel') {
return { channel_type: dest.channelType!, platform_id: dest.platformId! };
}
return { channel_type: 'agent', platform_id: dest.agentGroupId! };
}
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description: 'Send a chat message to the current conversation or a specified destination.',
description:
'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' },
text: { type: 'string', description: 'Message content' },
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
platformId: { type: 'string', description: 'Target platform ID' },
threadId: { type: 'string', description: 'Target thread ID' },
},
required: ['text'],
required: ['to', 'text'],
},
},
async handler(args) {
const to = args.to as string;
const text = args.text as string;
if (!text) return err('text is required');
if (!to || !text) return err('to and text are required');
const routing = resolveRouting(to);
if ('error' in routing) return err(routing.error);
const id = generateId();
const r = routing();
const seq = writeMessageOut({
id,
kind: 'chat',
platform_id: (args.platformId as string) || r.platform_id,
channel_type: (args.channel as string) || r.channel_type,
thread_id: (args.threadId as string) || r.thread_id,
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: null,
content: JSON.stringify({ text }),
});
log(`send_message: #${seq} ${id}${r.channel_type || 'default'}/${r.platform_id || 'default'}`);
return ok(`Message sent (id: ${seq})`);
log(`send_message: #${seq} ${to}`);
return ok(`Message sent to ${to} (id: ${seq})`);
},
};
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
description: 'Send a file to the current conversation.',
description: 'Send a file to a named destination.',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name' },
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
required: ['path'],
required: ['to', 'path'],
},
},
async handler(args) {
const to = args.to as string;
const filePath = args.path as string;
if (!filePath) return err('path is required');
if (!to || !filePath) return err('to and path are required');
const routing = resolveRouting(to);
if ('error' in routing) return err(routing.error);
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
const r = routing();
// Copy file to outbox
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
@@ -100,21 +119,21 @@ export const sendFile: McpToolDefinition = {
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: null,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
log(`send_file: ${id}${filename}`);
return ok(`File sent (id: ${id}, filename: ${filename})`);
log(`send_file: ${id}${to} (${filename})`);
return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`);
},
};
export const editMessage: McpToolDefinition = {
tool: {
name: 'edit_message',
description: 'Edit a previously sent message.',
description: 'Edit a previously sent message. Targets the same destination the original message was sent to.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -132,15 +151,18 @@ export const editMessage: McpToolDefinition = {
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const id = generateId();
const r = routing();
const routing = getRoutingBySeq(seq);
if (!routing || !routing.channel_type || !routing.platform_id) {
return err(`Cannot determine destination for message #${seq}`);
}
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'edit', messageId: platformId, text }),
});
@@ -170,15 +192,18 @@ export const addReaction: McpToolDefinition = {
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const id = generateId();
const r = routing();
const routing = getRoutingBySeq(seq);
if (!routing || !routing.channel_type || !routing.platform_id) {
return err(`Cannot determine destination for message #${seq}`);
}
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }),
});

View File

@@ -9,6 +9,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { loadDestinations } from '../destinations.js';
import type { McpToolDefinition } from './types.js';
import { coreTools } from './core.js';
import { schedulingTools } from './scheduling.js';
@@ -20,7 +21,23 @@ function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools];
// Load the destination map — this process is spawned fresh for each container
// wake, so the map file is always fresh (written by the host before spawn).
loadDestinations();
// Only admin agents get the create_agent tool. Non-admins never see it in the
// listTools response; the host also re-checks permission on receive as defense
// in depth (see delivery.ts create_agent handler).
const isAdmin = process.env.NANOCLAW_IS_ADMIN === '1';
const conditionalAgentTools = isAdmin ? agentTools : [];
const allTools: McpToolDefinition[] = [
...coreTools,
...schedulingTools,
...interactiveTools,
...conditionalAgentTools,
...selfModTools,
];
const toolMap = new Map<string, McpToolDefinition>();
for (const t of allTools) {

View File

@@ -1,11 +1,13 @@
/**
* Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild.
*
* These tools request changes to the agent's container configuration.
* install_packages and request_rebuild require admin approval.
* add_mcp_server takes effect on next container restart without approval.
* All three are fire-and-forget — the tool writes a system action row and
* returns immediately. The host processes the request (including admin
* approval) and notifies the agent via a chat message when complete.
*
* Package names are sanitized here at the tool boundary AND re-validated on
* the host side (defense in depth).
*/
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
@@ -25,37 +27,20 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollForResponse(requestId: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = findQuestionResponse(requestId);
if (response) {
const parsed = JSON.parse(response.content);
markCompleted([response.id]);
if (parsed.status === 'success') {
return ok(JSON.stringify(parsed.result || 'Success'));
}
return err(parsed.result?.error || parsed.selectedOption || 'Request denied');
}
await sleep(2000);
}
return err(`Request timed out after ${timeoutMs / 1000}s`);
}
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;
export const installPackages: McpToolDefinition = {
tool: {
name: 'install_packages',
description:
'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.',
'Request installation of apt or npm packages. Requires admin approval. Fire-and-forget: you will receive a notification when the request is approved or rejected. After approval, call request_rebuild to apply the changes.',
inputSchema: {
type: 'object' as const,
properties: {
apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' },
npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' },
apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install (names only, no version specs or flags)' },
npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally (names only, no version specs)' },
reason: { type: 'string', description: 'Why these packages are needed' },
},
},
@@ -64,6 +49,12 @@ export const installPackages: McpToolDefinition = {
const apt = (args.apt as string[]) || [];
const npm = (args.npm as string[]) || [];
if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required');
if (apt.length + npm.length > MAX_PACKAGES) return err(`Maximum ${MAX_PACKAGES} packages per request`);
const invalidApt = apt.find((p) => !APT_RE.test(p));
if (invalidApt) return err(`Invalid apt package name: "${invalidApt}". Only lowercase letters, digits, and ._+- allowed.`);
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
if (invalidNpm) return err(`Invalid npm package name: "${invalidNpm}". No version specs or shell characters.`);
const requestId = generateId();
writeMessageOut({
@@ -71,7 +62,6 @@ export const installPackages: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'install_packages',
requestId,
apt,
npm,
reason: (args.reason as string) || '',
@@ -79,7 +69,7 @@ export const installPackages: McpToolDefinition = {
});
log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`);
return await pollForResponse(requestId, 300_000);
return ok(`Package install request submitted. You will be notified when admin approves or rejects.`);
},
};
@@ -87,7 +77,7 @@ export const addMcpServer: McpToolDefinition = {
tool: {
name: 'add_mcp_server',
description:
"Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).",
"Request adding an MCP server to this agent's configuration. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new server.",
inputSchema: {
type: 'object' as const,
properties: {
@@ -110,7 +100,6 @@ export const addMcpServer: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'add_mcp_server',
requestId,
name,
command,
args: (args.args as string[]) || [],
@@ -119,7 +108,7 @@ export const addMcpServer: McpToolDefinition = {
});
log(`add_mcp_server: ${requestId} → "${name}" (${command})`);
return await pollForResponse(requestId, 30_000);
return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`);
},
};
@@ -127,7 +116,7 @@ export const requestRebuild: McpToolDefinition = {
tool: {
name: 'request_rebuild',
description:
'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.',
'Request a container rebuild to apply pending package installations. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new image on the next message.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -142,13 +131,12 @@ export const requestRebuild: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'request_rebuild',
requestId,
reason: (args.reason as string) || '',
}),
});
log(`request_rebuild: ${requestId}`);
return await pollForResponse(requestId, 300_000);
return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`);
},
};