Skip to content

Commit 886c657

Browse files
authored
Merge pull request #2793 from nanocoai/feat/a2a-approval-policies
feat(agent-to-agent): per-message approval policies on connected agents
2 parents 070714e + 9977af6 commit 886c657

16 files changed

Lines changed: 506 additions & 11 deletions

src/cli/resources/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import './users.js';
99
import './roles.js';
1010
import './members.js';
1111
import './destinations.js';
12+
import './policies.js';
1213
import './user-dms.js';
1314
import './dropped-messages.js';
1415
import './approvals.js';

src/cli/resources/policies.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { getAgentGroup } from '../../db/agent-groups.js';
2+
import { removeMessagePolicy, setMessagePolicy } from '../../modules/agent-to-agent/db/agent-message-policies.js';
3+
import { registerResource } from '../crud.js';
4+
5+
registerResource({
6+
name: 'policy',
7+
plural: 'policies',
8+
table: 'agent_message_policies',
9+
description:
10+
'Agent-to-agent approval policy. A row requires every message from one agent to another to be approved by a human before delivery — without un-wiring the connection. No row = free flow. Directed and per-pair: gate both directions with two policies. Operator-only (agents cannot manage their own gates).',
11+
idColumn: 'from_agent_group_id',
12+
columns: [
13+
{ name: 'from_agent_group_id', type: 'string', description: 'Source agent group. References agent_groups.id.' },
14+
{ name: 'to_agent_group_id', type: 'string', description: 'Target agent group. References agent_groups.id.' },
15+
{
16+
name: 'approver',
17+
type: 'string',
18+
description: 'User-id who approves each gated message (required). Only this user (or an owner) can approve.',
19+
},
20+
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
21+
],
22+
operations: { list: 'open' },
23+
customOperations: {
24+
set: {
25+
access: 'approval',
26+
description:
27+
'Require approval for messages from one agent to another. Use --from <agent-group-id> --to <agent-group-id> --approver <user-id>. Only the named approver (or an owner) can approve.',
28+
handler: async (args) => {
29+
const from = args.from as string;
30+
const to = args.to as string;
31+
const approver = args.approver as string;
32+
if (!from) throw new Error('--from is required');
33+
if (!to) throw new Error('--to is required');
34+
if (!approver) throw new Error('--approver is required');
35+
if (from === to) throw new Error('--from and --to must differ (self-messages are never gated)');
36+
if (!getAgentGroup(from)) throw new Error(`source agent group not found: ${from}`);
37+
if (!getAgentGroup(to)) throw new Error(`target agent group not found: ${to}`);
38+
39+
setMessagePolicy(from, to, approver, new Date().toISOString());
40+
return { from_agent_group_id: from, to_agent_group_id: to, approver };
41+
},
42+
},
43+
remove: {
44+
access: 'approval',
45+
description: 'Remove an approval policy (back to free flow). Use --from <agent-group-id> --to <agent-group-id>.',
46+
handler: async (args) => {
47+
const from = args.from as string;
48+
const to = args.to as string;
49+
if (!from) throw new Error('--from is required');
50+
if (!to) throw new Error('--to is required');
51+
if (!removeMessagePolicy(from, to)) throw new Error('policy not found');
52+
return { removed: { from_agent_group_id: from, to_agent_group_id: to } };
53+
},
54+
},
55+
},
56+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type Database from 'better-sqlite3';
2+
3+
import type { Migration } from './index.js';
4+
5+
/** Per-message approval gate on an agent-to-agent connection; no row = free flow. */
6+
export const migration017: Migration = {
7+
version: 17,
8+
name: 'agent-message-policies',
9+
up(db: Database.Database) {
10+
db.exec(`
11+
CREATE TABLE agent_message_policies (
12+
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
13+
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
14+
approver TEXT NOT NULL,
15+
created_at TEXT NOT NULL,
16+
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
17+
);
18+
`);
19+
},
20+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Migration } from './index.js';
2+
3+
/**
4+
* `approver_user_id` on `pending_approvals`: when an approval names a specific
5+
* approver (an a2a message-gate policy's approver), only that exact user may
6+
* resolve it. NULL keeps the existing group/owner authorization path.
7+
*/
8+
export const migration018: Migration = {
9+
version: 18,
10+
name: 'approvals-approver-user-id',
11+
up(db) {
12+
db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_user_id TEXT;`);
13+
},
14+
};

src/db/migrations/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
44
import { migration001 } from './001-initial.js';
55
import { migration002 } from './002-chat-sdk-state.js';
66
import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js';
7+
import { migration017 } from './017-agent-message-policies.js';
78
import { migration008 } from './008-dropped-messages.js';
89
import { migration009 } from './009-drop-pending-credentials.js';
910
import { migration010 } from './010-engage-modes.js';
@@ -15,6 +16,7 @@ import { migration015 } from './015-cli-scope.js';
1516
import { migration016 } from './016-messaging-group-instance.js';
1617
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
1718
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
19+
import { migration018 } from './018-approvals-approver-user-id.js';
1820

1921
export interface Migration {
2022
version: number;
@@ -36,7 +38,9 @@ export const migrations: Migration[] = [
3638
migration002,
3739
moduleApprovalsPendingApprovals,
3840
moduleAgentToAgentDestinations,
41+
migration017,
3942
moduleApprovalsTitleOptions,
43+
migration018,
4044
migration008,
4145
migration009,
4246
migration010,

src/db/sessions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,11 @@ export function createPendingApproval(
155155
`INSERT OR IGNORE INTO pending_approvals
156156
(approval_id, session_id, request_id, action, payload, created_at,
157157
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
158-
title, options_json)
158+
title, options_json, approver_user_id)
159159
VALUES
160160
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
161161
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status,
162-
@title, @options_json)`,
162+
@title, @options_json, @approver_user_id)`,
163163
)
164164
.run({
165165
session_id: null,
@@ -169,6 +169,7 @@ export function createPendingApproval(
169169
platform_message_id: null,
170170
expires_at: null,
171171
status: 'pending',
172+
approver_user_id: null,
172173
...pa,
173174
});
174175
return result.changes > 0;

src/modules/agent-to-agent/agent-route.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import { wakeContainer } from '../../container-runner.js';
2929
import { log } from '../../log.js';
3030
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
3131
import type { Session } from '../../types.js';
32+
import { requestApproval } from '../approvals/index.js';
3233
import { hasDestination } from './db/agent-destinations.js';
34+
import { getMessagePolicy } from './db/agent-message-policies.js';
3335

3436
export { isSafeAttachmentName };
3537

@@ -208,21 +210,87 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
208210
}
209211

210212
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
213+
const sourceAgentGroupId = session.agent_group_id;
211214
const targetAgentGroupId = msg.platform_id;
212215
if (!targetAgentGroupId) {
213216
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
214217
}
215-
if (
216-
targetAgentGroupId !== session.agent_group_id &&
217-
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
218-
) {
219-
throw new Error(
220-
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
221-
);
218+
const isSelf = targetAgentGroupId === sourceAgentGroupId;
219+
if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) {
220+
throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`);
222221
}
223222
if (!getAgentGroup(targetAgentGroupId)) {
224223
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
225224
}
225+
226+
// Gated edge: hold the message and return (not throw) so the delivery loop
227+
// consumes the outbound row; `applyA2aMessageGate` re-routes it on approve.
228+
if (!isSelf) {
229+
const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId);
230+
if (policy) {
231+
const { approver } = policy;
232+
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
233+
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
234+
await requestApproval({
235+
session,
236+
agentName: sourceName,
237+
action: A2A_MESSAGE_GATE_ACTION,
238+
approverUserId: approver,
239+
title: 'Message approval',
240+
question: buildGateQuestion(sourceName, targetName, msg.content),
241+
payload: {
242+
id: msg.id,
243+
platform_id: targetAgentGroupId,
244+
content: msg.content,
245+
in_reply_to: msg.in_reply_to,
246+
},
247+
});
248+
log.info('Agent message held for approval', {
249+
from: sourceAgentGroupId,
250+
to: targetAgentGroupId,
251+
msgId: msg.id,
252+
});
253+
return;
254+
}
255+
}
256+
257+
await performAgentRoute(msg, session, targetAgentGroupId);
258+
}
259+
260+
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
261+
262+
const GATE_CARD_BODY_MAX = 1500;
263+
264+
function parseMessageContent(contentStr: string): { text: string; files: string[] } {
265+
try {
266+
const parsed = JSON.parse(contentStr) as { text?: unknown; files?: unknown };
267+
return {
268+
text: typeof parsed.text === 'string' ? parsed.text : '',
269+
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
270+
};
271+
} catch {
272+
return { text: contentStr, files: [] };
273+
}
274+
}
275+
276+
function buildGateQuestion(sourceName: string, targetName: string, contentStr: string): string {
277+
const { text, files } = parseMessageContent(contentStr);
278+
const body = text.length > GATE_CARD_BODY_MAX ? `${text.slice(0, GATE_CARD_BODY_MAX)}… (truncated)` : text;
279+
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
280+
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
281+
lines.push('', 'Approve delivery?');
282+
return lines.join('\n');
283+
}
284+
285+
/**
286+
* Cross-session route: pick the target session, forward files, write to its
287+
* inbound DB, wake it. Authorization is the caller's responsibility.
288+
*/
289+
export async function performAgentRoute(
290+
msg: RoutableAgentMessage,
291+
session: Session,
292+
targetAgentGroupId: string,
293+
): Promise<void> {
226294
const targetSession = resolveTargetSession(msg, session, targetAgentGroupId);
227295
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
228296

src/modules/agent-to-agent/db/agent-destinations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
*/
3737
import type { AgentDestination } from '../../../types.js';
3838
import { getDb } from '../../../db/connection.js';
39+
import { deletePoliciesTouching, removeMessagePolicy } from './agent-message-policies.js';
3940

4041
/**
4142
* ⚠️ Caller responsibility: after this returns, call
@@ -89,9 +90,16 @@ export function hasDestination(agentGroupId: string, targetType: 'channel' | 'ag
8990
* so the deletion propagates to the running container's inbound.db.
9091
*/
9192
export function deleteDestination(agentGroupId: string, localName: string): void {
93+
// Resolve the target first so we can drop a matching policy for this edge (no ghost gate on re-wire).
94+
const row = getDb()
95+
.prepare('SELECT target_type, target_id FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
96+
.get(agentGroupId, localName) as { target_type: string; target_id: string } | undefined;
9297
getDb()
9398
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
9499
.run(agentGroupId, localName);
100+
if (row?.target_type === 'agent') {
101+
removeMessagePolicy(agentGroupId, row.target_id);
102+
}
95103
}
96104

97105
/**
@@ -108,6 +116,7 @@ export function deleteAllDestinationsTouching(agentGroupId: string): void {
108116
getDb()
109117
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? OR (target_type = ? AND target_id = ?)')
110118
.run(agentGroupId, 'agent', agentGroupId);
119+
deletePoliciesTouching(agentGroupId);
111120
}
112121

113122
/**
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/** Per-message approval policies for agent-to-agent connections; no row = free flow. */
2+
import type { AgentMessagePolicy } from '../../../types.js';
3+
import { getDb } from '../../../db/connection.js';
4+
5+
export function getMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): AgentMessagePolicy | undefined {
6+
return getDb()
7+
.prepare('SELECT * FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
8+
.get(fromAgentGroupId, toAgentGroupId) as AgentMessagePolicy | undefined;
9+
}
10+
11+
export function setMessagePolicy(
12+
fromAgentGroupId: string,
13+
toAgentGroupId: string,
14+
approver: string,
15+
createdAt: string,
16+
): void {
17+
getDb()
18+
.prepare(
19+
`INSERT INTO agent_message_policies (from_agent_group_id, to_agent_group_id, approver, created_at)
20+
VALUES (@from_agent_group_id, @to_agent_group_id, @approver, @created_at)
21+
ON CONFLICT (from_agent_group_id, to_agent_group_id) DO UPDATE SET approver = excluded.approver`,
22+
)
23+
.run({ from_agent_group_id: fromAgentGroupId, to_agent_group_id: toAgentGroupId, approver, created_at: createdAt });
24+
}
25+
26+
export function removeMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): boolean {
27+
const info = getDb()
28+
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
29+
.run(fromAgentGroupId, toAgentGroupId);
30+
return info.changes > 0;
31+
}
32+
33+
/** Delete every policy touching this agent group, so none outlives its connection. */
34+
export function deletePoliciesTouching(agentGroupId: string): void {
35+
getDb()
36+
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? OR to_agent_group_id = ?')
37+
.run(agentGroupId, agentGroupId);
38+
}

src/modules/agent-to-agent/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
*/
2323
import { registerDeliveryAction } from '../../delivery.js';
2424
import { registerApprovalHandler } from '../approvals/index.js';
25+
import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js';
2526
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
27+
import { applyA2aMessageGate } from './message-gate.js';
2628

2729
registerDeliveryAction('create_agent', handleCreateAgent);
2830
registerApprovalHandler('create_agent', applyCreateAgent);
31+
32+
registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate);

0 commit comments

Comments
 (0)