diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 84e919f8a..97da838d3 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -16,6 +16,7 @@ import { migration015 } from './015-cli-scope.js'; import { migration016 } from './016-messaging-group-instance.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; +import { moduleApprovalsApprover } from './module-approvals-approver.js'; export interface Migration { version: number; @@ -39,6 +40,7 @@ export const migrations: Migration[] = [ moduleAgentToAgentDestinations, moduleAgentMessagePolicies, moduleApprovalsTitleOptions, + moduleApprovalsApprover, migration008, migration009, migration010, diff --git a/src/db/migrations/module-approvals-approver.ts b/src/db/migrations/module-approvals-approver.ts new file mode 100644 index 000000000..25a6092ad --- /dev/null +++ b/src/db/migrations/module-approvals-approver.ts @@ -0,0 +1,14 @@ +import type { Migration } from './index.js'; + +/** + * `approver_user_id` on `pending_approvals`: when an approval names a specific + * approver (an a2a message-gate policy's approver), only that exact user may + * resolve it. NULL keeps the existing group/owner authorization path. + */ +export const moduleApprovalsApprover: Migration = { + version: 18, + name: 'approvals-approver-user-id', + up(db) { + db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_user_id TEXT;`); + }, +}; diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 504aa2660..060f58645 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -155,11 +155,11 @@ export function createPendingApproval( `INSERT OR IGNORE INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, - title, options_json) + title, options_json, approver_user_id) VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at, @agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status, - @title, @options_json)`, + @title, @options_json, @approver_user_id)`, ) .run({ session_id: null, @@ -169,6 +169,7 @@ export function createPendingApproval( platform_message_id: null, expires_at: null, status: 'pending', + approver_user_id: null, ...pa, }); return result.changes > 0; diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 33b02ff56..bfd8c450f 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -243,7 +243,6 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess platform_id: targetAgentGroupId, content: msg.content, in_reply_to: msg.in_reply_to, - approver, }, }); log.info('Agent message held for approval', { diff --git a/src/modules/agent-to-agent/message-gate.test.ts b/src/modules/agent-to-agent/message-gate.test.ts index 6ed21e412..fa1a485f6 100644 --- a/src/modules/agent-to-agent/message-gate.test.ts +++ b/src/modules/agent-to-agent/message-gate.test.ts @@ -144,7 +144,7 @@ describe('agent message policies', () => { const opts = vi.mocked(requestApproval).mock.calls[0][0]; expect(opts.action).toBe('a2a_message_gate'); expect(opts.approverUserId).toBe('telegram:dana'); - expect(opts.payload).toMatchObject({ id: 'm2', platform_id: B, approver: 'telegram:dana' }); + expect(opts.payload).toMatchObject({ id: 'm2', platform_id: B }); expect(JSON.parse(String(opts.payload.content)).text).toBe('sensitive'); }); diff --git a/src/modules/approvals/primitive.ts b/src/modules/approvals/primitive.ts index a1a6b538e..e27a22d6e 100644 --- a/src/modules/approvals/primitive.ts +++ b/src/modules/approvals/primitive.ts @@ -237,6 +237,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise { expect(getPendingApproval('appr-3')).toBeUndefined(); }); - it('an approval naming an approver in its payload is resolvable by that user, not a non-assignee', async () => { + it('an approval with approver_user_id is resolvable by that user, not a non-assignee', async () => { const { registerApprovalHandler } = await import('./primitive.js'); const { handleApprovalsResponse } = await import('./response-handler.js'); const handler = vi.fn().mockResolvedValue(undefined); - registerApprovalHandler('payload_approver_action', handler); + registerApprovalHandler('assigned_approver_action', handler); createPendingApproval({ approval_id: 'appr-4', session_id: 'sess-1', request_id: 'appr-4', - action: 'payload_approver_action', - payload: JSON.stringify({ approver: 'telegram:dana' }), + action: 'assigned_approver_action', + payload: JSON.stringify({}), created_at: now(), title: 'Assigned approval', options_json: JSON.stringify([]), + approver_user_id: 'telegram:dana', }); // A non-assignee (no global/owner role) cannot resolve it. diff --git a/src/modules/approvals/response-handler.ts b/src/modules/approvals/response-handler.ts index 67c058f06..e69ec8d76 100644 --- a/src/modules/approvals/response-handler.ts +++ b/src/modules/approvals/response-handler.ts @@ -126,9 +126,8 @@ function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponseP if (!userId) return false; // An approval may name a specific approver; only that exact user may resolve it. - const assignee = approvalAssignee(approval); - if (assignee) { - return userId === assignee; + if (approval.approver_user_id) { + return userId === approval.approver_user_id; } const agentGroupId = @@ -140,13 +139,3 @@ function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponseP return hasAdminPrivilege(userId, agentGroupId); } - -/** The `approver` user-id named in the stored approval payload (not the click payload), if any. */ -function approvalAssignee(approval: PendingApproval): string | null { - try { - const parsed = JSON.parse(approval.payload) as { approver?: unknown }; - return typeof parsed.approver === 'string' ? parsed.approver : null; - } catch { - return null; - } -} diff --git a/src/types.ts b/src/types.ts index d2e700afc..b5c5bdc3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -204,6 +204,8 @@ export interface PendingApproval { status: 'pending' | 'approved' | 'rejected' | 'expired'; title: string; options_json: string; + /** When set, only this exact user may resolve the approval. */ + approver_user_id: string | null; } // ── Agent destinations (central DB) ──