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:
91
container/agent-runner/src/destinations.ts
Normal file
91
container/agent-runner/src/destinations.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Destination map loaded at container startup from
|
||||
* /workspace/.nanoclaw-destinations.json (written by the host on wake).
|
||||
*
|
||||
* The map is BOTH the routing table and the ACL — if a name/target
|
||||
* isn't in here, the agent can't reach it.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
export interface DestinationEntry {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'channel' | 'agent';
|
||||
channelType?: string;
|
||||
platformId?: string;
|
||||
agentGroupId?: string;
|
||||
}
|
||||
|
||||
const DEST_FILE = '/workspace/.nanoclaw-destinations.json';
|
||||
|
||||
let cache: DestinationEntry[] = [];
|
||||
|
||||
export function loadDestinations(): void {
|
||||
try {
|
||||
if (!fs.existsSync(DEST_FILE)) {
|
||||
cache = [];
|
||||
return;
|
||||
}
|
||||
const raw = fs.readFileSync(DEST_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { destinations?: DestinationEntry[] };
|
||||
cache = Array.isArray(parsed.destinations) ? parsed.destinations : [];
|
||||
} catch (err) {
|
||||
console.error(`[destinations] Failed to load: ${err instanceof Error ? err.message : String(err)}`);
|
||||
cache = [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllDestinations(): DestinationEntry[] {
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Test-only: inject destinations without touching the filesystem. */
|
||||
export function setDestinationsForTest(destinations: DestinationEntry[]): void {
|
||||
cache = destinations;
|
||||
}
|
||||
|
||||
export function findByName(name: string): DestinationEntry | undefined {
|
||||
return cache.find((d) => d.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: given routing fields from an inbound message, find
|
||||
* which destination they correspond to (what does this agent call the sender?).
|
||||
*/
|
||||
export function findByRouting(
|
||||
channelType: string | null | undefined,
|
||||
platformId: string | null | undefined,
|
||||
): DestinationEntry | undefined {
|
||||
if (!channelType || !platformId) return undefined;
|
||||
if (channelType === 'agent') {
|
||||
return cache.find((d) => d.type === 'agent' && d.agentGroupId === platformId);
|
||||
}
|
||||
return cache.find((d) => d.type === 'channel' && d.channelType === channelType && d.platformId === platformId);
|
||||
}
|
||||
|
||||
/** Generate the system-prompt addendum describing destinations and syntax. */
|
||||
export function buildSystemPromptAddendum(): string {
|
||||
if (cache.length === 0) {
|
||||
return [
|
||||
'## Sending messages',
|
||||
'',
|
||||
'You currently have no configured destinations. You cannot send messages until an admin wires one up.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
|
||||
for (const d of cache) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
|
||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user