@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.
- package/build/lib/action-approvals.d.ts +22 -0
- package/build/lib/action-approvals.js +94 -1
- package/build/lib/approval-exempt.d.ts +13 -0
- package/build/lib/approval-exempt.js +134 -0
- package/build/server.d.ts +9 -0
- package/build/server.js +27 -3
- package/build/tools/ab-testing.js +4 -2
- package/build/tools/analytics.js +4 -2
- package/build/tools/automations.js +6 -3
- package/build/tools/brand-kit.js +4 -2
- package/build/tools/campaigns.js +23 -18
- package/build/tools/contacts.js +17 -13
- package/build/tools/content.js +7 -5
- package/build/tools/data.js +13 -10
- package/build/tools/delivery-ops.js +4 -3
- package/build/tools/landing-pages.js +7 -5
- package/build/tools/lists.js +4 -4
- package/build/tools/newsletters.js +8 -5
- package/build/tools/org.js +13 -10
- package/build/tools/segments.js +4 -2
- package/build/tools/signatures.js +4 -3
- package/build/tools/sms.js +11 -7
- package/build/tools/social-posts.js +4 -3
- package/build/tools/social-replies.js +4 -3
- package/build/tools/templates.js +4 -3
- package/build/tools/webhooks.js +10 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (!
|
|
295
|
-
|
|
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
|
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -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: {
|
package/build/tools/brand-kit.js
CHANGED
|
@@ -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: {
|