@sendinel/mcp-server 1.0.63 → 1.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ /** True when the action name is on the gated or protected list (the default-deny test uses this). */
2
+ export declare function isApprovalGatedAction(action: string): boolean;
1
3
  export type ApprovalTier = 'autonomous' | 'gated' | 'protected';
2
4
  export declare function getApprovalTier(action: string, params?: Record<string, unknown>): ApprovalTier;
3
5
  /**
@@ -64,3 +66,23 @@ export declare function checkApproval(approvalId: string, options?: {
64
66
  status: string;
65
67
  decided_by?: string;
66
68
  }>;
69
+ type ToolResult = {
70
+ content: Array<{
71
+ type: 'text';
72
+ text: string;
73
+ }>;
74
+ };
75
+ /**
76
+ * Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
77
+ * (no approval_id) records the exact arguments and returns the approval request without running
78
+ * the handler; the follow-up call with an approved approval_id runs the handler on those same
79
+ * arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
80
+ * cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
81
+ * Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
82
+ */
83
+ export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
84
+ display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
85
+ }): (args: A & {
86
+ approval_id?: string;
87
+ }, extra?: unknown) => Promise<R | ToolResult>;
88
+ export {};
@@ -32,6 +32,46 @@ const GATED_ACTIONS = new Set([
32
32
  'remove_team_member', // revokes workspace access
33
33
  'set_team_member_role', // changes workspace access privileges
34
34
  'delete_provider_credential', // permanently removes or revokes stored provider access
35
+ // #4005: every outward or destructive tool is approval-gated unless it sits on APPROVAL_EXEMPT
36
+ // (src/lib/approval-exempt.ts). These are wrapped with withApproval() in their tool files.
37
+ 'send_sms', // texts a real phone number
38
+ 'send_sms_campaign', // can activate an SMS broadcast
39
+ 'send_newsletter_issue', // sends an issue to every subscriber
40
+ 'schedule_newsletter_issue', // queues an issue to every subscriber
41
+ 'reply_to_comment', // queues a public LinkedIn reply
42
+ 'send_awaiting_reply', // sends a real email
43
+ 'send_test_email', // sends a real email to any address
44
+ 'remove_suppression', // re-enables mail to a suppressed address
45
+ 'schedule_social_post', // publishes to a connected social account
46
+ 'invite_team_member', // emails an outside person and grants access
47
+ 'set_member_project_access', // changes workspace access
48
+ 'revoke_invitation', // cancels a pending teammate invitation
49
+ 'create_api_key', // mints new access to the project
50
+ 'revoke_api_key', // breaks every integration using the key
51
+ 'tenant_export_full', // copies all customer data out of Sendinel
52
+ 'enroll_contact', // starts real emails to a contact
53
+ 'enroll_automation', // starts automated messages to a contact
54
+ 'enroll_in_escalation_ladder', // starts email/SMS escalation to a contact
55
+ 'set_contact_sms_consent', // changes the consent record that allows texting
56
+ 'bulk_update_subscribers', // bulk contact data write
57
+ 'update_webhook_subscription', // can redirect event data to a new URL
58
+ 'delete_webhook_subscription', // removes an outbound data channel
59
+ 'delete_campaign', // deletes a campaign and its steps
60
+ 'delete_campaign_step', // deletes an email from a campaign
61
+ 'delete_content_block', // deletes customer content
62
+ 'delete_custom_field', // deletes a field and its values
63
+ 'delete_landing_page', // takes a public page down and deletes it
64
+ 'delete_list', // deletes a contact list
65
+ 'delete_signature', // deletes a saved signature
66
+ 'delete_social_post', // deletes a social post record
67
+ 'delete_subscriber_view', // deletes a saved view
68
+ 'delete_template', // deletes an email template
69
+ 'import_brand_kit_text', // overwrites the brand kit
70
+ 'disconnect_platform', // deletes stored platform credentials
71
+ 'start_provider_switch', // moves a sending domain to another provider
72
+ 'update_landing_page', // publishing makes the page public (see special case below)
73
+ 'create_blog_post', // auto_publish posts to the customer's own site (see special case below)
74
+ 'replay_enrollment_dlq_rows', // confirmed replay sends real emails (see special case below)
35
75
  ]);
36
76
  const PROTECTED_ACTIONS = new Set([
37
77
  'delete_subscriber_data', // GDPR delete — irreversible
@@ -41,6 +81,10 @@ const PROTECTED_ACTIONS = new Set([
41
81
  'promote_ab_winner', // deletes losing variants
42
82
  'action_ccpa_request', // CCPA delete completion is irreversible
43
83
  ]);
84
+ /** True when the action name is on the gated or protected list (the default-deny test uses this). */
85
+ export function isApprovalGatedAction(action) {
86
+ return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
87
+ }
44
88
  export function getApprovalTier(action, params) {
45
89
  // CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
46
90
  // These are irreversible (GDPR delete, merge, etc.) — no opt-out.
@@ -48,6 +92,9 @@ export function getApprovalTier(action, params) {
48
92
  // Exception: identify_inactive in dry_run mode is safe (read-only report)
49
93
  if (action === 'identify_inactive' && params?.dry_run !== false)
50
94
  return 'autonomous';
95
+ // Without confirmed=true the tool only previews which variants would be deleted.
96
+ if (action === 'promote_ab_winner' && params?.confirmed !== true)
97
+ return 'autonomous';
51
98
  if (action === 'action_ccpa_request' && !(params?.status === 'completed' && params?.request_type === 'delete'))
52
99
  return 'autonomous';
53
100
  return 'protected';
@@ -73,6 +120,13 @@ export function getApprovalTier(action, params) {
73
120
  // in campaigns.ts — so the special case must live here, not just at the call site.
74
121
  if (action === 'update_campaign_status' && params?.status !== 'active')
75
122
  return 'autonomous';
123
+ // Only the publishing / sending form of these is outward; drafts, previews and dry runs are not.
124
+ if (action === 'update_landing_page' && params?.status !== 'published')
125
+ return 'autonomous';
126
+ if (action === 'create_blog_post' && params?.auto_publish !== true)
127
+ return 'autonomous';
128
+ if (action === 'replay_enrollment_dlq_rows' && params?.confirmed !== true)
129
+ return 'autonomous';
76
130
  if (GATED_ACTIONS.has(action))
77
131
  return 'gated';
78
132
  return 'autonomous';
@@ -216,9 +270,17 @@ export function buildApprovalSummary(action, params, display = {}) {
216
270
  remove_team_member: (p) => `Remove team member ${p.user_id} from the organization`,
217
271
  set_team_member_role: (p) => `Change team member ${p.user_id}'s role to ${p.role}`,
218
272
  delete_provider_credential: (p) => `Delete saved ${p.provider ?? 'provider'} credential ${p.credential_id} (${p.source})`,
273
+ send_sms: (p) => `Send an SMS to ${p.to_number ?? `contact ${p.contact_id ?? 'unknown'}`} (${p.classification ?? 'marketing'}): "${approvalExcerpt(p.body)}"`,
274
+ send_newsletter_issue: (p) => `Send newsletter issue ${display.issue_subject ? `"${display.issue_subject}"` : p.issue_id} now to every subscriber of newsletter ${display.newsletter_name ?? p.newsletter_id}`,
275
+ reply_to_comment: (p) => `Queue a LinkedIn reply to ${p.comment_url_or_urn}: "${approvalExcerpt(p.text)}"`,
276
+ send_awaiting_reply: (p) => `Send an email to ${p.to}${p.subject ? ` — "${approvalExcerpt(p.subject, 120)}"` : ''}${p.html || p.text ? ` — "${approvalExcerpt(p.html ?? p.text)}"` : ''} and track a reply for ${p.expect_reply_within}`,
277
+ remove_suppression: (p) => `Remove ${p.email} from the suppression list — mail to this address will resume`,
219
278
  };
220
279
  const fn = summaries[action];
221
- return fn ? fn(params) : `Execute ${action} with provided parameters`;
280
+ if (fn)
281
+ return fn(params);
282
+ const payload = JSON.stringify(normalizeApprovalParams(params));
283
+ return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
222
284
  }
223
285
  function approvalPageUrl(approvalId) {
224
286
  const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
@@ -337,3 +399,34 @@ export async function checkApproval(approvalId, options = {}) {
337
399
  decided_by: claimed.decided_by,
338
400
  };
339
401
  }
402
+ function toolText(value) {
403
+ return { content: [{ type: 'text', text: value }] };
404
+ }
405
+ /**
406
+ * Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
407
+ * (no approval_id) records the exact arguments and returns the approval request without running
408
+ * the handler; the follow-up call with an approved approval_id runs the handler on those same
409
+ * arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
410
+ * cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
411
+ * Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
412
+ */
413
+ export function withApproval(action, handler, options = {}) {
414
+ return async (args, extra) => {
415
+ const { approval_id, ...params } = args;
416
+ const channelContext = currentChannelContext();
417
+ const decision = assertChannelCanExecute(action, params, channelContext);
418
+ if (!decision.allowed)
419
+ return toolText(decision.reason ?? 'This channel cannot request this action.');
420
+ if (decision.tier !== 'autonomous') {
421
+ if (!approval_id) {
422
+ const display = options.display ? await options.display(params) : undefined;
423
+ return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
424
+ }
425
+ const check = await checkApproval(approval_id, { consume: true, action, params });
426
+ if (!check.approved) {
427
+ return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
428
+ }
429
+ }
430
+ return handler(params, extra);
431
+ };
432
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Write- and admin-scope MCP tools that are deliberately NOT approval-gated (#4005).
3
+ *
4
+ * __tests__/approval-default-deny.test.ts fails for any registered tool whose scope is write or
5
+ * admin unless it is approval-gated (listed in GATED_ACTIONS or PROTECTED_ACTIONS in
6
+ * action-approvals.ts AND wrapped with withApproval()/requestApproval()) or listed here with a
7
+ * one-line reason. Nothing that sends, posts or publishes outside Sendinel, or deletes or
8
+ * irreversibly changes customer data, belongs here: gate it instead. A new outward tool therefore
9
+ * cannot ship ungated by accident.
10
+ *
11
+ * Entries marked FOLLOW-UP are exempt today but flagged for a closer look.
12
+ */
13
+ export declare const APPROVAL_EXEMPT: Record<string, string>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Write- and admin-scope MCP tools that are deliberately NOT approval-gated (#4005).
3
+ *
4
+ * __tests__/approval-default-deny.test.ts fails for any registered tool whose scope is write or
5
+ * admin unless it is approval-gated (listed in GATED_ACTIONS or PROTECTED_ACTIONS in
6
+ * action-approvals.ts AND wrapped with withApproval()/requestApproval()) or listed here with a
7
+ * one-line reason. Nothing that sends, posts or publishes outside Sendinel, or deletes or
8
+ * irreversibly changes customer data, belongs here: gate it instead. A new outward tool therefore
9
+ * cannot ship ungated by accident.
10
+ *
11
+ * Entries marked FOLLOW-UP are exempt today but flagged for a closer look.
12
+ */
13
+ export const APPROVAL_EXEMPT = {
14
+ abuse_monitor_override: "admin-only override of Sendinel abuse monitoring on the caller own project; no outward send",
15
+ add_blackout_date: "adds a no-send date; reduces sending",
16
+ add_campaign_branch: "adds branching config to a campaign; recreatable and sends nothing itself",
17
+ add_campaign_step: "adds an email to a campaign; sends only when the campaign is activated (gated)",
18
+ add_subscriber: "adds one contact; contact count limits apply and nothing is sent",
19
+ add_suppression: "only stops mail to an address; reduces sending",
20
+ add_synced_folder: "starts syncing a Drive folder into the project own media library; inbound only",
21
+ add_white_label_domain: "adds a pending branding domain record; reversible and sends nothing",
22
+ approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
23
+ approve_draft: "marks a draft ready; sending still requires a gated send or activation",
24
+ cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
25
+ clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
26
+ clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
27
+ comment_on_post: "creates a review card; the tool description says it never posts",
28
+ commit_staged_import: "copies a staged file into the project own media library; internal",
29
+ configure_platform_trigger: "configures what an inbound platform event does; sends nothing itself",
30
+ connect_platform: "stores an inbound platform connection; sends nothing outward",
31
+ convert_imported_campaigns: "without create it only previews; with create it makes new drafts that cannot send until activated (gated)",
32
+ create_ab_test: "creates a draft A/B test; nothing sends until the campaign is activated (activation is gated)",
33
+ create_asset_link: "creates an email-gated download link; sends nothing",
34
+ create_campaign: "creates a DRAFT campaign; it must be activated through update_campaign_status (gated)",
35
+ create_campaign_set: "creates draft campaigns; activate_campaigns (gated) turns them on",
36
+ create_content_block: "creates reusable content; sends nothing",
37
+ create_custom_field: "adds a field definition; nothing sent or deleted",
38
+ create_custom_webhook: "creates an INBOUND webhook endpoint; no outward data flow",
39
+ create_draft: "creates a draft for review; sends nothing",
40
+ create_escalation_ladder: "creates ladder config; contacts are only messaged by enroll_in_escalation_ladder (gated)",
41
+ create_form: "creates a signup form; sends nothing",
42
+ create_landing_page: "creates a DRAFT page; publishing via update_landing_page status=published is gated",
43
+ create_list: "creates an empty list; sends nothing",
44
+ create_newsletter: "creates a newsletter definition; sends nothing",
45
+ create_newsletter_issue: "creates a draft issue; send_newsletter_issue and schedule_newsletter_issue are gated",
46
+ create_segment: "creates a saved audience definition; sends nothing",
47
+ create_segment_nl: "creates a saved audience definition from plain language; sends nothing",
48
+ create_sender: "creates a pending sender identity; sends nothing until verified and used",
49
+ create_short_link: "creates a tracked short link; sends nothing",
50
+ create_signature: "creates a saved signature; sends nothing",
51
+ create_site: "admin creates a site record; sends nothing",
52
+ create_social_campaign: "creates a UTM-tracked link record; posting is schedule_social_post (gated)",
53
+ create_social_post: "creates a social post record; publishing is schedule_social_post (gated)",
54
+ create_subscriber_view: "saves a filter view; internal",
55
+ create_survey: "creates a survey; sends nothing",
56
+ create_template: "creates a template; sends nothing",
57
+ create_template_from_brief: "creates a draft template; sends nothing",
58
+ diagnose_delivery_issue: "runs a diagnosis and writes an internal report",
59
+ discard_staged_import: "discards a staged import file the caller uploaded; no customer contacts or sends affected",
60
+ dismiss_ai_work_item: "dismisses an internal AI work item",
61
+ draft_social_reply: "drafts reply text for review; never posts",
62
+ enrich_subscriber: "adds enrichment data to one contact; sends nothing",
63
+ generate_blog_post_image: "generates an image for a draft post; sends nothing",
64
+ generate_email: "AI-writes email copy and returns it; sends nothing",
65
+ generate_qr_code: "generates a QR image; sends nothing",
66
+ get_audit_stats: "admin read of audit stats; changes nothing",
67
+ get_enrollment_status: "read-style lookup registered with write scope; changes nothing",
68
+ get_provider_switch_status: "read-style lookup registered with write scope; changes nothing",
69
+ ingest_brand_assets_by_url: "pulls assets into the project media library; SSRF-guarded, internal",
70
+ list_audit_log: "admin read of the audit log; changes nothing",
71
+ list_campaign_steps: "read-style lookup registered with write scope; changes nothing",
72
+ list_ccpa_requests: "admin read of CCPA requests; changes nothing",
73
+ list_deletion_log: "admin read of the deletion log; changes nothing",
74
+ list_invitations: "admin read of invitations; changes nothing",
75
+ mark_ai_work_item_seen: "marks an internal AI work item as seen",
76
+ mark_notifications_read: "marks the caller own notifications read",
77
+ migrate_campaigns_from: "imports other-platform campaigns as drafts; nothing sends until activated (gated)",
78
+ onboard_new_site: "creates a site and a pending sending-domain record; sends no mail and does not register the domain",
79
+ optimize_subject_lines: "generates subject-line suggestions; sends nothing",
80
+ pause_warmup: "pauses warmup; reduces sending",
81
+ post_ticket_reply: "replies on the caller own Sendinel support ticket; not a message to a customer contact",
82
+ promote_draft_to_campaign: "creates a campaign in draft status; activation is gated",
83
+ propose_campaign_from_data: "creates a proposal for review; sends nothing",
84
+ propose_social_reply: "creates a review card; a human Accept posts it",
85
+ register_domain: "creates a pending sending-domain record; does not register the domain with a provider",
86
+ rehost_brand_asset: "copies an asset into Sendinel own storage; internal",
87
+ reject_draft: "rejects a draft; reduces sending",
88
+ remove_blackout_date: "removes a no-send date; recreatable. FOLLOW-UP: it can allow sends on a blocked date",
89
+ remove_campaign_branch: "removes branching config; recreatable with add_campaign_branch, no customer records deleted",
90
+ remove_campaign_goal: "removes a goal definition; recreatable with set_campaign_goal, no customer records deleted",
91
+ remove_synced_folder: "stops a folder sync; does not delete imported assets or revoke the Drive share",
92
+ request_social_reply: "creates a review card from an external trigger; never posts",
93
+ resend_invitation: "extends an invitation expiry and returns the link; sends no email from the server",
94
+ restore_template_version: "restores an earlier template version; sends nothing",
95
+ resume_warmup: "resumes the owner own warmup schedule; sends only mail already scheduled",
96
+ retry_send_queue_rows: "makes already-queued retry rows eligible; no new audience is created",
97
+ review_campaign_portfolio: "writes an internal review report only",
98
+ rollback_provider_switch: "restores the prior provider state after a failed switch; start_provider_switch is gated",
99
+ run_list_hygiene: "suppresses invalid or bouncing addresses, which only reduces who receives mail",
100
+ run_portfolio_analysis: "writes an internal analysis report only",
101
+ set_active_project: "switches which project this connection targets; no data written",
102
+ set_campaign_goal: "sets a goal definition on a campaign; sends nothing",
103
+ set_subscriber_tags: "edits tags on one contact; tag triggers go through enrollment rules the owner configured. FOLLOW-UP: confirm tag-triggered automations are covered",
104
+ set_white_label_settings: "edits branding settings; reversible and sends nothing",
105
+ setup_campaign_from_brief: "creates a draft campaign from a brief; nothing sends until activated (gated)",
106
+ snooze_ai_work_item: "snoozes an internal AI work item",
107
+ start_platform_migration: "imports data into Sendinel from a connected platform; inbound only",
108
+ submit_ai_context_event: "records an internal context event for the AI worker",
109
+ submit_support_ticket: "files a ticket with Sendinel support from the caller own account; not a message to a customer contact",
110
+ subscribe_to_newsletter: "adds one subscriber who asked to join; the confirmation flow applies",
111
+ translate_template: "creates a translated template draft; sends nothing",
112
+ trigger_content_pipeline: "marks content sources ready for the cron; the pipeline lands drafts unless the owner enabled auto-publish. FOLLOW-UP: review the cron publish path",
113
+ unsubscribe_from_newsletter: "only stops mail to a subscriber; reduces sending",
114
+ trigger_automation: 'generates a draft into the approval queue (or returns it with dry_run); nothing is sent until a person approves the draft',
115
+ unsubscribe_subscriber: "only stops mail to a contact; reduces sending",
116
+ update_brand_voice: "edits brand voice text on the caller own project; internal",
117
+ update_campaign: "edits campaign settings; status changes go through update_campaign_status (gated). FOLLOW-UP: same live-edit caveat as update_campaign_step",
118
+ update_campaign_step: "edits email copy; sends only through an already-gated activation. FOLLOW-UP: editing a live campaign changes what goes out (#4005 summary)",
119
+ update_custom_field: "renames or edits a field definition; values are untouched",
120
+ update_hygiene_config: "changes hygiene settings; suppression runs are gated (identify_inactive) or only reduce sending",
121
+ update_list: "renames or edits a list; membership changes are gated",
122
+ update_newsletter: "edits newsletter settings; sends nothing",
123
+ update_scoring_rules: "edits scoring settings; sends nothing",
124
+ update_segment: "edits a saved audience definition; sends only through gated activations",
125
+ update_signature: "edits a saved signature; sends nothing",
126
+ update_site: "admin edits site settings; sends nothing",
127
+ update_social_post: "edits a social post record; publishing is schedule_social_post (gated)",
128
+ update_subscriber: "edits one contact record; recoverable and sends nothing",
129
+ update_subscriber_view: "edits a saved filter view; internal",
130
+ update_survey: "edits a survey; sends nothing",
131
+ update_template: "edits a template; previous versions can be restored with restore_template_version",
132
+ update_warmup_schedule: "edits the warmup schedule; reduces or paces sending",
133
+ upload_brand_asset: "adds an asset to the project media library; internal",
134
+ };
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { z } from 'zod';
2
3
  import { db } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
@@ -161,11 +162,12 @@ export function registerAbTestingTools(server) {
161
162
  },
162
163
  description: 'End an A/B test and keep only the winning variant. Requires confirmation (two-call pattern). Deletes the losing variant step(s) from campaign_steps.',
163
164
  inputSchema: {
165
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
164
166
  test_id: z.string().uuid(),
165
167
  winner: z.string().optional().describe('Override winner variant label (default: use statistically determined winner)'),
166
168
  confirmed: z.boolean().optional().describe('Set true to execute after previewing'),
167
169
  },
168
- }, async ({ test_id, winner, confirmed }) => {
170
+ }, withApproval('promote_ab_winner', async ({ test_id, winner, confirmed }) => {
169
171
  const projectId = getProjectId();
170
172
  const { data: test, error: testErr } = await db()
171
173
  .from('ab_tests')
@@ -228,5 +230,5 @@ export function registerAbTestingTools(server) {
228
230
  deleted_variants: losingVariants,
229
231
  message: `A/B test "${test.name}" completed. Variant "${winnerLabel}" promoted, ${loserSteps.length} losing step(s) removed.`,
230
232
  });
231
- });
233
+ }));
232
234
  }
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { fetchAllRows, readAllRows } from '@sendinel/domain';
2
3
  import { z } from 'zod';
3
4
  import { db, adminDb, requestContext } from '../db.js';
@@ -1452,13 +1453,14 @@ export function registerAnalyticsTools(server) {
1452
1453
  },
1453
1454
  description: 'Schedule a social campaign post via a connected BYO publisher (UploadPost, Buffer). Returns publisherPostId on success.',
1454
1455
  inputSchema: {
1456
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
1455
1457
  campaign_id: z.string().uuid().describe('UUID of the social campaign in Sendinel'),
1456
1458
  content: z.string().min(1).describe('Post body text'),
1457
1459
  publisher: z.enum(socialPublishers).describe('Which BYO publisher to route through'),
1458
1460
  scheduled_at: z.string().optional().describe('ISO 8601 datetime. Omit for immediate publish.'),
1459
1461
  media_urls: z.array(z.string().url()).optional().describe('Optional image/video URLs'),
1460
1462
  },
1461
- }, async (p) => {
1463
+ }, withApproval('schedule_social_post', async (p) => {
1462
1464
  const projectId = getProjectId();
1463
1465
  const scheduledAt = p.scheduled_at ? new Date(p.scheduled_at) : undefined;
1464
1466
  if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
@@ -1535,5 +1537,5 @@ export function registerAnalyticsTools(server) {
1535
1537
  .eq('id', p.campaign_id);
1536
1538
  return txt(`Error: ${message}`);
1537
1539
  }
1538
- });
1540
+ }));
1539
1541
  }
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { z } from 'zod';
2
3
  import Anthropic from '@anthropic-ai/sdk';
3
4
  import { db } from '../db.js';
@@ -133,6 +134,7 @@ export function registerAutomationTools(server) {
133
134
  },
134
135
  description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
135
136
  inputSchema: {
137
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
136
138
  subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
137
139
  campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
138
140
  site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
@@ -143,7 +145,7 @@ export function registerAutomationTools(server) {
143
145
  next_send_at: z.string().datetime().optional().describe('First send time. Defaults to now.'),
144
146
  context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
145
147
  },
146
- }, enrollSubscriberIntoTriggeredCampaigns);
148
+ }, withApproval('enroll_automation', enrollSubscriberIntoTriggeredCampaigns));
147
149
  server.registerTool('create_escalation_ladder', {
148
150
  title: 'Create Escalation Ladder',
149
151
  annotations: {
@@ -214,12 +216,13 @@ export function registerAutomationTools(server) {
214
216
  },
215
217
  description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
216
218
  inputSchema: {
219
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
217
220
  subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
218
221
  campaign_id: z.string().uuid().describe('Triggered campaign UUID with ladder steps'),
219
222
  next_send_at: z.string().datetime().optional().describe('First eligible send time. Defaults to now.'),
220
223
  context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
221
224
  },
222
- }, async ({ subscriber_id, campaign_id, next_send_at, context }) => {
225
+ }, withApproval('enroll_in_escalation_ladder', async ({ subscriber_id, campaign_id, next_send_at, context }) => {
223
226
  const projectId = getProjectId();
224
227
  const { data: gate, error: gateError } = await db()
225
228
  .from('campaign_step_gates')
@@ -237,7 +240,7 @@ export function registerAutomationTools(server) {
237
240
  next_send_at,
238
241
  context: { ...(context ?? {}), escalation_ladder: true, project_id: projectId },
239
242
  });
240
- });
243
+ }));
241
244
  server.registerTool('list_escalation_ladder_status', {
242
245
  title: 'List Escalation Ladder Status',
243
246
  annotations: {
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { z } from 'zod';
2
3
  import { adminDb } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
@@ -37,12 +38,13 @@ export function registerBrandKitTools(server) {
37
38
  },
38
39
  description: 'Import raw Brand Core / Brand Brain / Content Library / Brand Kit markdown text into the current project.',
39
40
  inputSchema: {
41
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
40
42
  text: z.string().min(1).describe('Raw markdown text from the Brand Core / Brand Kit companion output.'),
41
43
  },
42
- }, async ({ text }) => {
44
+ }, withApproval('import_brand_kit_text', async ({ text }) => {
43
45
  const result = await callBrandKitApi('/api/v1/brand-kit/import-text', { text });
44
46
  return result.ok ? j(result.data) : j({ error: result.error });
45
- });
47
+ }));
46
48
  server.registerTool('rehost_brand_asset', {
47
49
  title: 'Rehost Drive-Linked Brand Asset',
48
50
  annotations: {
@@ -12,7 +12,7 @@ import { getProjectId } from '../project.js';
12
12
  import { checkPlanLimit } from '../plan-limits.js';
13
13
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
14
14
  import { fireWebhookEvent } from '../lib/webhook-events.js';
15
- import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
15
+ import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
16
16
  import { resolveSendAudience } from '../lib/send-audience.js';
17
17
  import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
18
18
  import { getAnthropicModelId } from '../anthropic-model.js';
@@ -873,11 +873,12 @@ export function registerCampaignTools(server) {
873
873
  },
874
874
  description: 'Enroll a single contact in a campaign',
875
875
  inputSchema: {
876
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
876
877
  campaign_id: z.string().uuid(),
877
878
  contact_id: z.string().uuid(),
878
879
  next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
879
880
  },
880
- }, async ({ campaign_id, contact_id, next_send_at }) => {
881
+ }, withApproval('enroll_contact', async ({ campaign_id, contact_id, next_send_at }) => {
881
882
  const { data: contact, error: contactError } = await db()
882
883
  .from('subscribers')
883
884
  .select('id, global_unsubscribed, cooled_at')
@@ -905,7 +906,7 @@ export function registerCampaignTools(server) {
905
906
  await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + 1 }).eq('id', campaign_id);
906
907
  }
907
908
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
908
- });
909
+ }));
909
910
  server.registerTool('get_enrollment_status', {
910
911
  title: 'Get Enrollment Status',
911
912
  annotations: {
@@ -995,11 +996,12 @@ export function registerCampaignTools(server) {
995
996
  },
996
997
  description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
997
998
  inputSchema: {
999
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
998
1000
  campaign_id: z.string().uuid(),
999
1001
  site_id: z.string().uuid(),
1000
1002
  next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
1001
1003
  },
1002
- }, async ({ campaign_id, site_id, next_send_at }) => {
1004
+ }, withApproval('enroll_segment', async ({ campaign_id, site_id, next_send_at }) => {
1003
1005
  const sendAt = next_send_at ?? new Date().toISOString();
1004
1006
  const { data: subs, error: subErr } = await db()
1005
1007
  .from('site_subscriptions')
@@ -1034,7 +1036,7 @@ export function registerCampaignTools(server) {
1034
1036
  await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + eligible.length }).eq('id', campaign_id);
1035
1037
  }
1036
1038
  return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
1037
- });
1039
+ }));
1038
1040
  server.registerTool('broadcast_to_segment', {
1039
1041
  title: 'Broadcast To Segment',
1040
1042
  annotations: {
@@ -2217,6 +2219,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2217
2219
  },
2218
2220
  description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
2219
2221
  inputSchema: {
2222
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2220
2223
  to_email: z.string().email().describe('Email address to send the test to'),
2221
2224
  site_id: z.string().uuid().describe('Site UUID (determines from address)'),
2222
2225
  step_id: z.string().uuid().optional(),
@@ -2225,7 +2228,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2225
2228
  body_html: z.string().optional(),
2226
2229
  body_text: z.string().optional(),
2227
2230
  },
2228
- }, async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
2231
+ }, withApproval('send_test_email', async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
2229
2232
  const projectId = getProjectId();
2230
2233
  let subj = subject ?? '';
2231
2234
  let html = body_html ?? '';
@@ -2308,7 +2311,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2308
2311
  provider_message_id: sendResult.messageId,
2309
2312
  resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
2310
2313
  });
2311
- });
2314
+ }));
2312
2315
  server.registerTool('send_transactional', {
2313
2316
  title: 'Send Transactional',
2314
2317
  annotations: {
@@ -2319,6 +2322,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2319
2322
  },
2320
2323
  description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
2321
2324
  inputSchema: {
2325
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2322
2326
  to: z.string().email().describe('Recipient email address'),
2323
2327
  site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
2324
2328
  subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
@@ -2329,7 +2333,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2329
2333
  template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
2330
2334
  template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
2331
2335
  },
2332
- }, async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
2336
+ }, withApproval('send_transactional', async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
2333
2337
  const projectId = getProjectId();
2334
2338
  if (template_id && html)
2335
2339
  return txt("Error: Provide either 'template_id' or 'html', not both");
@@ -2459,7 +2463,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2459
2463
  provider_message_id: sendResult.messageId,
2460
2464
  resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
2461
2465
  });
2462
- });
2466
+ }));
2463
2467
  function parseReplyWindow(input) {
2464
2468
  const match = /^(\d{1,2})d$/.exec(input.trim());
2465
2469
  if (!match)
@@ -2485,8 +2489,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
2485
2489
  idempotentHint: false,
2486
2490
  openWorldHint: true,
2487
2491
  },
2488
- description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only.',
2492
+ description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
2489
2493
  inputSchema: {
2494
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
2490
2495
  to: z.string().email().describe('Recipient email address'),
2491
2496
  site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
2492
2497
  subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
@@ -2498,7 +2503,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2498
2503
  template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
2499
2504
  template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
2500
2505
  },
2501
- }, async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
2506
+ }, withApproval('send_awaiting_reply', async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
2502
2507
  const projectId = getProjectId();
2503
2508
  if (template_id && html)
2504
2509
  return txt("Error: Provide either 'template_id' or 'html', not both");
@@ -2694,7 +2699,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2694
2699
  provider_message_id: sendResult.messageId,
2695
2700
  resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
2696
2701
  });
2697
- });
2702
+ }));
2698
2703
  server.registerTool('get_reply_status', {
2699
2704
  title: 'Get Reply Status',
2700
2705
  annotations: {
@@ -3190,8 +3195,8 @@ Return ONLY valid JSON with this exact shape:
3190
3195
  openWorldHint: false,
3191
3196
  },
3192
3197
  description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
3193
- inputSchema: { step_id: z.string().uuid() },
3194
- }, async ({ step_id }) => {
3198
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), step_id: z.string().uuid() },
3199
+ }, withApproval('delete_campaign_step', async ({ step_id }) => {
3195
3200
  const projectId = getProjectId();
3196
3201
  const { data: step } = await db()
3197
3202
  .from('campaign_steps')
@@ -3218,7 +3223,7 @@ Return ONLY valid JSON with this exact shape:
3218
3223
  if (error)
3219
3224
  return j({ error: error.message });
3220
3225
  return j({ ok: true, deleted: step_id });
3221
- });
3226
+ }));
3222
3227
  server.registerTool('delete_campaign', {
3223
3228
  title: 'Delete Campaign',
3224
3229
  annotations: {
@@ -3228,8 +3233,8 @@ Return ONLY valid JSON with this exact shape:
3228
3233
  openWorldHint: false,
3229
3234
  },
3230
3235
  description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
3231
- inputSchema: { campaign_id: z.string().uuid() },
3232
- }, async ({ campaign_id }) => {
3236
+ inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), campaign_id: z.string().uuid() },
3237
+ }, withApproval('delete_campaign', async ({ campaign_id }) => {
3233
3238
  const projectId = getProjectId();
3234
3239
  const { data: campaign, error: fetchErr } = await db()
3235
3240
  .from('campaigns')
@@ -3250,7 +3255,7 @@ Return ONLY valid JSON with this exact shape:
3250
3255
  if (error)
3251
3256
  return j({ error: error.message });
3252
3257
  return j({ success: true, deleted: campaign.name });
3253
- });
3258
+ }));
3254
3259
  server.registerTool('update_campaign', {
3255
3260
  title: 'Update Campaign',
3256
3261
  annotations: {