Files
nanoclaw/src/db/migrations/004-agent-destinations.ts
gavrielc e83ffbc103 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>
2026-04-10 16:31:37 +03:00

82 lines
2.8 KiB
TypeScript

import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
/**
* Agent destinations: per-agent named map of allowed message targets.
*
* This table is BOTH the routing map and the ACL. A row exists iff the
* source agent is permitted to send to the target. No row = unauthorized.
*
* target_type: 'channel' references messaging_groups(id)
* target_type: 'agent' references agent_groups(id)
*
* Names are scoped per source agent — worker-1 may call the admin "parent"
* while admin calls the child "worker-1". The (agent_group_id, local_name)
* PK enforces uniqueness within a single agent's namespace only.
*/
export const migration004: Migration = {
version: 4,
name: 'agent-destinations',
up(db: Database.Database) {
db.exec(`
CREATE TABLE agent_destinations (
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
local_name TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (agent_group_id, local_name)
);
CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id);
`);
// Backfill from existing messaging_group_agents wirings.
// For each wired (agent, messaging_group), create a destination row
// using the messaging group's name (normalized) as the local name.
// Collisions get a -2, -3 suffix within each agent's namespace.
const rows = db
.prepare(
`SELECT mga.agent_group_id, mga.messaging_group_id, mg.channel_type, mg.name
FROM messaging_group_agents mga
JOIN messaging_groups mg ON mg.id = mga.messaging_group_id`,
)
.all() as Array<{
agent_group_id: string;
messaging_group_id: string;
channel_type: string;
name: string | null;
}>;
const takenByAgent = new Map<string, Set<string>>();
const insert = db.prepare(
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES (?, ?, 'channel', ?, ?)`,
);
const now = new Date().toISOString();
for (const row of rows) {
const base = normalizeName(row.name || `${row.channel_type}-${row.messaging_group_id.slice(0, 8)}`);
const taken = takenByAgent.get(row.agent_group_id) ?? new Set<string>();
let localName = base;
let suffix = 2;
while (taken.has(localName)) {
localName = `${base}-${suffix}`;
suffix++;
}
taken.add(localName);
takenByAgent.set(row.agent_group_id, taken);
insert.run(row.agent_group_id, localName, row.messaging_group_id, now);
}
},
};
function normalizeName(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unnamed'
);
}