@sendinel/mcp-server 1.0.63 → 1.0.65

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
+ };
package/build/server.d.ts CHANGED
@@ -8,6 +8,15 @@ export declare const ALL_TOOL_GROUPS: string[];
8
8
  export type ToolGroup = keyof typeof GROUP_REGISTRY;
9
9
  export type ToolGroupMap = Record<string, string>;
10
10
  export declare function getRequiredScope(name: string): string;
11
+ /**
12
+ * Marker scope for a tester connection (#4011), a new OAuth grant until an owner promotes it. It adds no
13
+ * permission level: such a connection sees every read tool plus only the approval-gated tools.
14
+ */
15
+ export declare const TESTER_CONNECTION_SCOPE = "tester";
16
+ export declare function isTesterConnection(scopes: readonly string[] | null | undefined): boolean;
17
+ /** Tools a tester connection may see and call: reads, plus anything that goes through a person's approval. */
18
+ export declare function testerConnectionAllowsTool(name: string): boolean;
19
+ export declare function testerConnectionRefusal(name: string): string;
11
20
  export declare const GATEWAY_TOOL_NAMES: string[];
12
21
  /**
13
22
  * Collects tool-name → group metadata from the same group registry used by
package/build/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runWithToolContext } from './db.js';
2
+ import { isApprovalGatedAction } from './lib/action-approvals.js';
2
3
  import { registerAnalyticsTools } from './tools/analytics.js';
3
4
  import { registerCampaignTools } from './tools/campaigns.js';
4
5
  import { registerCampaignSetTools } from './tools/campaign-sets.js';
@@ -175,6 +176,24 @@ function scopeAllows(keyScopes, required) {
175
176
  const requiredLevel = SCOPE_HIERARCHY[required] ?? 99;
176
177
  return keyScopes.some((s) => (SCOPE_HIERARCHY[s] ?? 0) >= requiredLevel);
177
178
  }
179
+ /**
180
+ * Marker scope for a tester connection (#4011), a new OAuth grant until an owner promotes it. It adds no
181
+ * permission level: such a connection sees every read tool plus only the approval-gated tools.
182
+ */
183
+ export const TESTER_CONNECTION_SCOPE = 'tester';
184
+ export function isTesterConnection(scopes) {
185
+ return !!scopes?.includes(TESTER_CONNECTION_SCOPE);
186
+ }
187
+ /** Tools a tester connection may see and call: reads, plus anything that goes through a person's approval. */
188
+ export function testerConnectionAllowsTool(name) {
189
+ return getRequiredScope(name) === 'read'
190
+ || name === 'send_broadcast'
191
+ || name === 'activate_campaigns'
192
+ || isApprovalGatedAction(name);
193
+ }
194
+ export function testerConnectionRefusal(name) {
195
+ return `"${name}" is not available to a tester connection. A tester connection can read, and can ask a person to approve outward actions: call an approval-gated tool (for example send_broadcast) and a person approves it from the approve_url it returns. To change this tool directly, ask the workspace owner to promote the connection in Dashboard > Agent Connect.`;
196
+ }
178
197
  // Names of the meta-tools gateway mode registers in place of the real catalog
179
198
  // (see tools/gateway.ts). Grouped as 'gateway' rather than any real tool
180
199
  // group -- a gateway-mode client's toolGroups list holds the groups it can
@@ -285,14 +304,19 @@ function createScopeProxy(server, scopes, toolGroup) {
285
304
  // ── Scope enforcement (only when scopes are provided) ─────────────
286
305
  if (scopes !== null) {
287
306
  const required = getRequiredScope(name);
288
- if (!scopeAllows(scopes, required)) {
307
+ const tester = isTesterConnection(scopes);
308
+ const allowed = () => tester
309
+ ? testerConnectionAllowsTool(name)
310
+ : scopeAllows(scopes, required);
311
+ if (!allowed()) {
289
312
  // Don't register tools the key can't access — they won't appear in tool list
290
313
  return;
291
314
  }
292
315
  // Wrap handler with runtime scope check (defense in depth)
293
316
  const guardedHandler = (...args) => {
294
- if (!scopeAllows(scopes, required)) {
295
- return { content: [{ type: 'text', text: `Forbidden: this action requires "${required}" scope.` }] };
317
+ if (!allowed()) {
318
+ const text = tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
319
+ return { content: [{ type: 'text', text }] };
296
320
  }
297
321
  return runWithToolContext(toolGroup, name, () => handler(...args));
298
322
  };
@@ -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: {