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:
74
src/db/agent-destinations.ts
Normal file
74
src/db/agent-destinations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Per-agent destination map + ACL.
|
||||
*
|
||||
* Each row means: agent `agent_group_id` is allowed to send messages to
|
||||
* target (`target_type`, `target_id`), and refers to it locally as `local_name`.
|
||||
*
|
||||
* Names are local to each source agent — they exist only inside that agent's
|
||||
* namespace. The host uses this table both for routing (resolve name → ID)
|
||||
* and for permission checks (row exists ⇒ authorized).
|
||||
*/
|
||||
import type { AgentDestination } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
export function createDestination(row: AgentDestination): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function getDestinations(agentGroupId: string): AgentDestination[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?')
|
||||
.all(agentGroupId) as AgentDestination[];
|
||||
}
|
||||
|
||||
export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.get(agentGroupId, localName) as AgentDestination | undefined;
|
||||
}
|
||||
|
||||
/** Reverse lookup: what does this agent call the given target? */
|
||||
export function getDestinationByTarget(
|
||||
agentGroupId: string,
|
||||
targetType: 'channel' | 'agent',
|
||||
targetId: string,
|
||||
): AgentDestination | undefined {
|
||||
return getDb()
|
||||
.prepare(
|
||||
'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?',
|
||||
)
|
||||
.get(agentGroupId, targetType, targetId) as AgentDestination | undefined;
|
||||
}
|
||||
|
||||
/** Permission check: can this agent send to this target? */
|
||||
export function hasDestination(
|
||||
agentGroupId: string,
|
||||
targetType: 'channel' | 'agent',
|
||||
targetId: string,
|
||||
): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1',
|
||||
)
|
||||
.get(agentGroupId, targetType, targetId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function deleteDestination(agentGroupId: string, localName: string): void {
|
||||
getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName);
|
||||
}
|
||||
|
||||
/** Normalize a human-readable name into a lowercase, dash-separated identifier. */
|
||||
export function normalizeName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unnamed'
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,7 @@ describe('migrations', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number };
|
||||
expect(row.v).toBe(3);
|
||||
expect(row.v).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
81
src/db/migrations/004-agent-destinations.ts
Normal file
81
src/db/migrations/004-agent-destinations.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { migration003 } from './003-pending-approvals.js';
|
||||
import { migration004 } from './004-agent-destinations.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -11,7 +12,7 @@ export interface Migration {
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [migration001, migration002, migration003];
|
||||
const migrations: Migration[] = [migration001, migration002, migration003, migration004];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
|
||||
Reference in New Issue
Block a user