@sendinel/mcp-server 1.0.229 → 1.0.230

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/db.d.ts CHANGED
@@ -54,6 +54,12 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
54
54
  export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T, scopes?: readonly string[] | null): Promise<T>;
55
55
  /** Keep the consumed-approval marker inside this async call; model input cannot set it. */
56
56
  export declare function runWithApprovedTesterRequest<T>(action: string, fn: () => Promise<T> | T): Promise<T>;
57
+ /**
58
+ * #4853: server-only marker that the tester gate table, param carve-outs, a positive non-sandbox
59
+ * read and (for state-dependent tools) a positive draft read all allowed this one call without a
60
+ * person's approval. It is not a consumed approval: inner gates (live trigger edits) stay armed.
61
+ */
62
+ export declare function runWithPolicyTesterRequest<T>(action: string, fn: () => Promise<T> | T): Promise<T>;
57
63
  /**
58
64
  * Request-scoped identity set via runWithClient() (HTTP transport).
59
65
  * Undefined under stdio, where the process env is the single-project source of truth.
@@ -61,6 +67,7 @@ export declare function runWithApprovedTesterRequest<T>(action: string, fn: () =
61
67
  export declare function requestContext(): {
62
68
  scopes?: readonly string[];
63
69
  approvedTesterAction?: string;
70
+ policyTesterAction?: string;
64
71
  projectId?: string;
65
72
  approvalMode?: string;
66
73
  channel?: string;
package/build/db.js CHANGED
@@ -61,6 +61,17 @@ export async function runWithApprovedTesterRequest(action, fn) {
61
61
  throw new Error('Tester application requires request context');
62
62
  return _requestStore.run({ ...current, approvedTesterAction: action }, fn);
63
63
  }
64
+ /**
65
+ * #4853: server-only marker that the tester gate table, param carve-outs, a positive non-sandbox
66
+ * read and (for state-dependent tools) a positive draft read all allowed this one call without a
67
+ * person's approval. It is not a consumed approval: inner gates (live trigger edits) stay armed.
68
+ */
69
+ export async function runWithPolicyTesterRequest(action, fn) {
70
+ const current = _requestStore.getStore();
71
+ if (!current)
72
+ throw new Error('Tester policy application requires request context');
73
+ return _requestStore.run({ ...current, policyTesterAction: action }, fn);
74
+ }
64
75
  /**
65
76
  * Request-scoped identity set via runWithClient() (HTTP transport).
66
77
  * Undefined under stdio, where the process env is the single-project source of truth.
@@ -71,6 +82,7 @@ export function requestContext() {
71
82
  ? {
72
83
  scopes: ctx.scopes,
73
84
  approvedTesterAction: ctx.approvedTesterAction,
85
+ policyTesterAction: ctx.policyTesterAction,
74
86
  projectId: ctx.projectId,
75
87
  approvalMode: ctx.approvalMode,
76
88
  channel: ctx.channel,
@@ -6,7 +6,8 @@ import { z } from 'zod';
6
6
  import { GATED_ACTIONS, PROTECTED_ACTIONS } from './approval-actions.js';
7
7
  export { isApprovalGatedAction } from './approval-actions.js';
8
8
  import { isHumanRequestableAdminTool } from './admin-tools.js';
9
- import { isTesterRequest, isApplyingTesterRequest } from './tester-policy.js';
9
+ import { isTesterRequest, isApplyingTesterRequest, isPolicyApplyingTesterRequest } from './tester-policy.js';
10
+ import { credentialGateClass } from './credential-gate-table.js';
10
11
  import { logTesterRequestAudit } from './tester-request-audit.js';
11
12
  import { approvalStore } from './tester-requests-store.js';
12
13
  import { createHash } from 'node:crypto';
@@ -26,8 +27,18 @@ export function getApprovalTier(action, params) {
26
27
  if (scopes && !scopes.includes('admin') && !scopes.includes('tester') && !scopes.includes('agent') &&
27
28
  isHumanRequestableAdminTool(action))
28
29
  return PROTECTED_ACTIONS.has(action) ? 'protected' : 'gated';
29
- if (isTesterRequest(action))
30
- return isApplyingTesterRequest(action) ? 'autonomous' : 'gated';
30
+ if (isTesterRequest(action)) {
31
+ if (isApplyingTesterRequest(action))
32
+ return 'autonomous';
33
+ // #4853: a policy-allowed draft edit falls through to the same rules as any write credential,
34
+ // so inner gates (live trigger edits, activation) stay armed. Everything else stays gated.
35
+ if (!isPolicyApplyingTesterRequest(action))
36
+ return 'gated';
37
+ }
38
+ else if (scopes && (scopes.includes('tester') || scopes.includes('agent')) && !credentialGateClass(action)) {
39
+ // #4853: an agent/tester call to a tool missing from the gate table fails closed.
40
+ return PROTECTED_ACTIONS.has(action) ? 'protected' : 'gated';
41
+ }
31
42
  // CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
32
43
  // These are irreversible (GDPR delete, merge, etc.) — no opt-out.
33
44
  if (PROTECTED_ACTIONS.has(action)) {
@@ -17,6 +17,7 @@ export declare function humanApprovalEligible(action: string, projectId: string,
17
17
  export declare function chatConfirmConnectionEnabled(identity?: {
18
18
  scopes?: readonly string[];
19
19
  approvedTesterAction?: string;
20
+ policyTesterAction?: string;
20
21
  projectId?: string;
21
22
  approvalMode?: string;
22
23
  channel?: string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * #4853 gate classification for agent and tester credentials on NON-sandbox projects.
3
+ * Kevin, 2026-11-12: "Gate only sends + deletes (Recommended)".
4
+ *
5
+ * One entry per registered MCP tool (plus every GATED_ACTIONS/PROTECTED_ACTIONS name), and
6
+ * __tests__/credential-gate-table.test.ts fails when a registered tool has no entry. A name that
7
+ * is not in this table is `gated` (fail closed): getApprovalTier returns gated for an
8
+ * agent/tester call to it, and resolveTesterGate never lets it through.
9
+ *
10
+ * autonomous applies without a person; tester setup writes still write a tester_applied audit row
11
+ * whose approver is the policy, not a person
12
+ * draft_only autonomous only while the target campaign is a draft (never activated, no
13
+ * enrollments, no queued sends, not scheduled); gated otherwise or when unreadable
14
+ * gated a person approves (sandbox projects keep their own auto-approve rules)
15
+ * protected a person approves with a typed confirmation
16
+ *
17
+ * The entry is the tier of the tool's sending/deleting form. Existing param rules in
18
+ * getApprovalTier (dry runs, non-active status changes, unpublished pages) are unchanged, and
19
+ * TESTER_PARAM_GATES below gates specific forms of otherwise autonomous tester setup tools.
20
+ *
21
+ * This table decides only whether a person must click. It never decides whether a credential
22
+ * may call a tool at all: agentConnectionAllowsTool / testerConnectionAllowsTool still refuse
23
+ * every delete, send, access and import tool for those connections. Person connections, API
24
+ * keys and service credentials do not use this table.
25
+ */
26
+ export type CredentialGate = 'autonomous' | 'draft_only' | 'gated' | 'protected';
27
+ export declare const CREDENTIAL_GATE_TABLE: Readonly<Record<string, CredentialGate>>;
28
+ /** Undefined means unclassified, which every caller must treat as gated. */
29
+ export declare function credentialGateClass(tool: string): CredentialGate | undefined;
30
+ /**
31
+ * Param forms of autonomous/draft_only tester setup tools that still need a person. Each returns a
32
+ * reason when gated. A value that is not exactly the safe value is gated (fail closed).
33
+ */
34
+ export declare const TESTER_PARAM_GATES: Readonly<Record<string, (params: Record<string, unknown>) => string | null>>;
@@ -0,0 +1,386 @@
1
+ export const CREDENTIAL_GATE_TABLE = Object.freeze({
2
+ // ── protected: Irreversible data loss or deletes: a person approves with a typed confirmation.
3
+ action_ccpa_request: 'protected',
4
+ delete_segment: 'protected',
5
+ delete_subscriber_data: 'protected',
6
+ identify_inactive: 'protected',
7
+ merge_subscribers: 'protected',
8
+ promote_ab_winner: 'protected',
9
+ // ── gated: Sends or enqueues to real people, deletes or data loss, access/credential/billing, bulk imports, public publishing.
10
+ abuse_monitor_override: 'gated', // overrides a Sendinel safety control
11
+ activate_campaigns: 'gated',
12
+ add_list_members: 'gated',
13
+ add_list_members_by_filter: 'gated',
14
+ approve_approval: 'gated', // is a person's approval decision; agent/tester credentials can never decide
15
+ archive_form: 'gated',
16
+ broadcast_to_segment: 'gated',
17
+ bulk_update_subscribers: 'gated',
18
+ commit_staged_import: 'gated', // imports a staged file; imports stay gated
19
+ configure_platform_trigger: 'gated', // an inbound event can start sends to contacts
20
+ confirm_approval: 'gated', // is a person's approval decision; agent/tester credentials can never decide
21
+ connect_platform: 'gated', // stores platform credentials (access/credential)
22
+ create_agent_mailbox: 'gated',
23
+ create_api_key: 'gated',
24
+ create_blog_post: 'gated',
25
+ create_custom_webhook: 'gated', // mints an inbound signing secret (access/credential)
26
+ delete_campaign: 'gated',
27
+ delete_campaign_step: 'gated',
28
+ delete_content_block: 'gated',
29
+ delete_custom_field: 'gated',
30
+ delete_landing_page: 'gated',
31
+ delete_list: 'gated',
32
+ delete_provider_credential: 'gated',
33
+ delete_signature: 'gated',
34
+ delete_social_post: 'gated',
35
+ delete_subscriber_view: 'gated',
36
+ delete_template: 'gated',
37
+ delete_webhook_subscription: 'gated',
38
+ discard_staged_import: 'gated', // discards uploaded data (data loss)
39
+ disconnect_platform: 'gated',
40
+ draft_agent_reply: 'gated',
41
+ emit_contact_event: 'gated',
42
+ enroll_automation: 'gated',
43
+ enroll_contact: 'gated',
44
+ enroll_in_escalation_ladder: 'gated',
45
+ enroll_segment: 'gated',
46
+ identify_subscriber: 'gated',
47
+ import_brand_kit_text: 'gated',
48
+ import_subscribers: 'gated',
49
+ install_connector_schema: 'gated',
50
+ install_connector_view: 'gated',
51
+ invite_team_member: 'gated',
52
+ launch_campaign: 'gated',
53
+ publish_dmarc_record: 'gated',
54
+ recheck_domain_dns: 'gated',
55
+ remove_blackout_date: 'gated', // can allow sends on a blocked date
56
+ remove_campaign_branch: 'gated', // removes saved config (delete)
57
+ remove_campaign_goal: 'gated', // removes saved config (delete)
58
+ remove_list_members: 'gated',
59
+ remove_suppression: 'gated',
60
+ remove_synced_folder: 'gated', // removes a sync (delete)
61
+ remove_team_member: 'gated',
62
+ replay_enrollment_dlq_rows: 'gated',
63
+ reply_to_comment: 'gated',
64
+ resend_invitation: 'gated', // extends workspace access (access)
65
+ resume_warmup: 'gated', // resumes sending
66
+ resync_connector: 'gated',
67
+ retry_send_queue_rows: 'gated', // re-enqueues sends to real people
68
+ revoke_api_key: 'gated',
69
+ revoke_invitation: 'gated',
70
+ rollback_provider_switch: 'gated', // changes the live sending provider
71
+ rotate_custom_webhook_secret: 'gated',
72
+ run_list_hygiene: 'gated', // bulk suppression of contacts
73
+ schedule_newsletter_issue: 'gated',
74
+ schedule_social_post: 'gated',
75
+ send_awaiting_reply: 'gated',
76
+ send_broadcast: 'gated',
77
+ send_newsletter_issue: 'gated',
78
+ send_sms: 'gated',
79
+ send_sms_campaign: 'gated',
80
+ send_test_email: 'gated',
81
+ send_transactional: 'gated',
82
+ set_connector_mapping: 'gated',
83
+ set_contact_sms_consent: 'gated',
84
+ set_link_domain: 'gated',
85
+ set_member_project_access: 'gated',
86
+ set_subscriber_tags: 'gated', // a tag can trigger campaigns that send
87
+ set_team_member_role: 'gated',
88
+ start_platform_migration: 'gated', // bulk contact import
89
+ start_provider_switch: 'gated',
90
+ subscribe_to_newsletter: 'gated', // sends a confirmation email to a real person
91
+ subscribe_webhook: 'gated',
92
+ tenant_export_full: 'gated',
93
+ trigger_content_pipeline: 'gated', // can auto-publish when the owner enabled it
94
+ update_asset_link: 'gated',
95
+ update_campaign_status: 'gated',
96
+ update_form: 'gated',
97
+ update_landing_page: 'gated',
98
+ update_short_link: 'gated',
99
+ update_webhook_subscription: 'gated',
100
+ // ── draft_only: State-dependent: autonomous only while the target campaign is a draft (resolveTesterGate reads it).
101
+ add_campaign_branch: 'draft_only', // branching changes the path of live mail once active
102
+ add_campaign_step: 'draft_only', // adds an email to the campaign; live mail changes once it is active
103
+ create_ab_test: 'draft_only', // adds variants that send once the campaign is active
104
+ set_campaign_goal: 'draft_only', // a goal exits contacts from live mail once active
105
+ set_campaign_trigger: 'draft_only', // changes who starts the campaign; live once active
106
+ update_campaign: 'draft_only', // name/segment/trigger/schedule; a live campaign changes who gets mail
107
+ update_campaign_step: 'draft_only', // edits email copy; live mail changes once the campaign is active
108
+ // ── autonomous: Reads, drafts, templates, segments, forms, consented single contacts and config: no person needed, still audited.
109
+ abuse_monitor_status: 'autonomous',
110
+ adapt_social_post: 'autonomous',
111
+ add_blackout_date: 'autonomous',
112
+ add_subscriber: 'autonomous',
113
+ add_suppression: 'autonomous',
114
+ add_synced_folder: 'autonomous',
115
+ add_white_label_domain: 'autonomous',
116
+ approve_data_proposal: 'autonomous',
117
+ approve_draft: 'autonomous',
118
+ campaign_advisor: 'autonomous',
119
+ cancel_approval: 'autonomous',
120
+ cancel_send_queue_rows: 'autonomous',
121
+ check_ab_significance: 'autonomous',
122
+ check_approval: 'autonomous',
123
+ check_content_consistency: 'autonomous',
124
+ check_link_domain: 'autonomous',
125
+ clone_campaign: 'autonomous',
126
+ clone_sequence_template: 'autonomous',
127
+ comment_on_post: 'autonomous',
128
+ convert_imported_campaigns: 'autonomous',
129
+ create_asset_link: 'autonomous',
130
+ create_campaign: 'autonomous',
131
+ create_campaign_set: 'autonomous',
132
+ create_campaign_with_content: 'autonomous', // drafts only; activate=true stays gated (TESTER_PARAM_GATES)
133
+ create_content_block: 'autonomous',
134
+ create_custom_field: 'autonomous',
135
+ create_draft: 'autonomous',
136
+ create_escalation_ladder: 'autonomous',
137
+ create_form: 'autonomous',
138
+ create_landing_page: 'autonomous',
139
+ create_list: 'autonomous',
140
+ create_newsletter: 'autonomous',
141
+ create_newsletter_issue: 'autonomous',
142
+ create_segment: 'autonomous',
143
+ create_segment_nl: 'autonomous',
144
+ create_sender: 'autonomous',
145
+ create_short_link: 'autonomous',
146
+ create_signature: 'autonomous',
147
+ create_site: 'autonomous',
148
+ create_social_campaign: 'autonomous',
149
+ create_social_post: 'autonomous',
150
+ create_subscriber_view: 'autonomous',
151
+ create_survey: 'autonomous',
152
+ create_template: 'autonomous',
153
+ create_template_from_brief: 'autonomous',
154
+ deliverability_check: 'autonomous',
155
+ describe_tool: 'autonomous',
156
+ diagnose_delivery_issue: 'autonomous',
157
+ dismiss_ai_work_item: 'autonomous',
158
+ dnsbl_check: 'autonomous',
159
+ dnsbl_delisting_draft: 'autonomous',
160
+ draft_social_reply: 'autonomous',
161
+ enrich_subscriber: 'autonomous',
162
+ estimate_segment_size: 'autonomous',
163
+ explain_contact_score: 'autonomous',
164
+ export_data: 'autonomous',
165
+ find_duplicates: 'autonomous',
166
+ generate_blog_post_image: 'autonomous',
167
+ generate_email: 'autonomous',
168
+ generate_qr_code: 'autonomous',
169
+ generate_signature_from_brand_kit: 'autonomous',
170
+ get_agent_proposal: 'autonomous',
171
+ get_agent_thread: 'autonomous',
172
+ get_ai_work_item: 'autonomous',
173
+ get_approval_level: 'autonomous',
174
+ get_asset_link_views: 'autonomous',
175
+ get_attention_decision: 'autonomous',
176
+ get_audit_stats: 'autonomous',
177
+ get_blog_post: 'autonomous',
178
+ get_brand_voice: 'autonomous',
179
+ get_campaign: 'autonomous',
180
+ get_campaign_calendar: 'autonomous',
181
+ get_connector_status: 'autonomous',
182
+ get_contact_neighborhood: 'autonomous',
183
+ get_contact_schema: 'autonomous',
184
+ get_cron_runs: 'autonomous',
185
+ get_dmarc_status: 'autonomous',
186
+ get_domain_health: 'autonomous',
187
+ get_draft: 'autonomous',
188
+ get_effective_signature: 'autonomous',
189
+ get_engagement_insights: 'autonomous',
190
+ get_enrollment_dlq: 'autonomous',
191
+ get_enrollment_status: 'autonomous',
192
+ get_form_stats: 'autonomous',
193
+ get_hygiene_config: 'autonomous',
194
+ get_landing_page: 'autonomous',
195
+ get_model_registry: 'autonomous',
196
+ get_newsletter: 'autonomous',
197
+ get_newsletter_stats: 'autonomous',
198
+ get_operator_skill: 'autonomous',
199
+ get_outbound_digest: 'autonomous',
200
+ get_plan_limits: 'autonomous',
201
+ get_plan_usage: 'autonomous',
202
+ get_portfolio_analytics: 'autonomous',
203
+ get_portfolio_stats: 'autonomous',
204
+ get_provider_switch_status: 'autonomous',
205
+ get_queue_status: 'autonomous',
206
+ get_reply_status: 'autonomous',
207
+ get_revenue_analytics: 'autonomous',
208
+ get_schedule: 'autonomous',
209
+ get_scheduled_touches: 'autonomous',
210
+ get_scoring_rules: 'autonomous',
211
+ get_send_dlq: 'autonomous',
212
+ get_send_queue_rows: 'autonomous',
213
+ get_short_link_stats: 'autonomous',
214
+ get_site_insights: 'autonomous',
215
+ get_sites: 'autonomous',
216
+ get_sms_provider_status: 'autonomous',
217
+ get_social_engagement: 'autonomous',
218
+ get_social_post: 'autonomous',
219
+ get_stats: 'autonomous',
220
+ get_subscriber: 'autonomous',
221
+ get_subscriber_timeline: 'autonomous',
222
+ get_survey: 'autonomous',
223
+ get_survey_results: 'autonomous',
224
+ get_template: 'autonomous',
225
+ get_warmup_status: 'autonomous',
226
+ get_white_label_settings: 'autonomous',
227
+ how_to: 'autonomous',
228
+ ingest_brand_assets_by_url: 'autonomous',
229
+ invoke_tool: 'autonomous',
230
+ list_ab_tests: 'autonomous',
231
+ list_agent_inbox: 'autonomous',
232
+ list_agent_proposals: 'autonomous',
233
+ list_ai_work_items: 'autonomous',
234
+ list_api_keys: 'autonomous',
235
+ list_asset_links: 'autonomous',
236
+ list_audit_actions: 'autonomous',
237
+ list_audit_log: 'autonomous',
238
+ list_automations: 'autonomous',
239
+ list_awaiting_replies: 'autonomous',
240
+ list_blackout_dates: 'autonomous',
241
+ list_blog_destinations: 'autonomous',
242
+ list_blog_posts: 'autonomous',
243
+ list_brand_assets: 'autonomous',
244
+ list_campaign_branches: 'autonomous',
245
+ list_campaign_goals: 'autonomous',
246
+ list_campaign_steps: 'autonomous',
247
+ list_campaigns: 'autonomous',
248
+ list_ccpa_requests: 'autonomous',
249
+ list_companies: 'autonomous',
250
+ list_connected_platforms: 'autonomous',
251
+ list_connector_tables: 'autonomous',
252
+ list_content_blocks: 'autonomous',
253
+ list_content_sources: 'autonomous',
254
+ list_custom_fields: 'autonomous',
255
+ list_custom_webhooks: 'autonomous',
256
+ list_data_proposals: 'autonomous',
257
+ list_deletion_log: 'autonomous',
258
+ list_dmarc_reports: 'autonomous',
259
+ list_domains: 'autonomous',
260
+ list_drafts: 'autonomous',
261
+ list_email_log: 'autonomous',
262
+ list_escalation_ladder_status: 'autonomous',
263
+ list_event_log: 'autonomous',
264
+ list_forms: 'autonomous',
265
+ list_hygiene_audit: 'autonomous',
266
+ list_import_history: 'autonomous',
267
+ list_invitations: 'autonomous',
268
+ list_landing_pages: 'autonomous',
269
+ list_lists: 'autonomous',
270
+ list_my_support_tickets: 'autonomous',
271
+ list_newsletter_issues: 'autonomous',
272
+ list_newsletter_subscribers: 'autonomous',
273
+ list_newsletters: 'autonomous',
274
+ list_notifications: 'autonomous',
275
+ list_operator_skills: 'autonomous',
276
+ list_pending_approvals: 'autonomous',
277
+ list_platform_events: 'autonomous',
278
+ list_projects: 'autonomous',
279
+ list_provider_credentials: 'autonomous',
280
+ list_recommended_segments: 'autonomous',
281
+ list_sandbox_webhook_events: 'autonomous',
282
+ list_segments: 'autonomous',
283
+ list_sequence_templates: 'autonomous',
284
+ list_signatures: 'autonomous',
285
+ list_sms_inbound: 'autonomous',
286
+ list_sms_log: 'autonomous',
287
+ list_social_campaigns: 'autonomous',
288
+ list_social_comments: 'autonomous',
289
+ list_social_connections: 'autonomous',
290
+ list_social_engagements: 'autonomous',
291
+ list_social_posts: 'autonomous',
292
+ list_staged_imports: 'autonomous',
293
+ list_subscriber_views: 'autonomous',
294
+ list_subscribers: 'autonomous',
295
+ list_subscribers_by_segment: 'autonomous',
296
+ list_suppressions: 'autonomous',
297
+ list_surveys: 'autonomous',
298
+ list_synced_folders: 'autonomous',
299
+ list_team_members: 'autonomous',
300
+ list_templates: 'autonomous',
301
+ list_webhook_subscriptions: 'autonomous',
302
+ list_win_back_draft: 'autonomous',
303
+ mark_agent_message: 'autonomous',
304
+ mark_ai_work_item_seen: 'autonomous',
305
+ mark_notifications_read: 'autonomous',
306
+ migrate_campaigns_from: 'autonomous',
307
+ onboard_new_site: 'autonomous',
308
+ optimize_subject_lines: 'autonomous',
309
+ pause_warmup: 'autonomous',
310
+ performance_report: 'autonomous',
311
+ post_ticket_reply: 'autonomous',
312
+ preview_automation: 'autonomous',
313
+ preview_campaign_audience: 'autonomous',
314
+ preview_email: 'autonomous',
315
+ preview_segment: 'autonomous',
316
+ preview_signature: 'autonomous',
317
+ preview_sms_cost: 'autonomous',
318
+ promote_draft_to_campaign: 'autonomous',
319
+ propose_campaign_from_data: 'autonomous',
320
+ propose_social_reply: 'autonomous',
321
+ read_agent_message: 'autonomous',
322
+ recommend_blog_post_images: 'autonomous',
323
+ recovery_plan: 'autonomous',
324
+ refresh_domain_dns: 'autonomous',
325
+ register_domain: 'autonomous',
326
+ rehost_brand_asset: 'autonomous',
327
+ reject_draft: 'autonomous',
328
+ request_social_reply: 'autonomous',
329
+ restore_template_version: 'autonomous',
330
+ review_campaign_portfolio: 'autonomous',
331
+ run_portfolio_analysis: 'autonomous',
332
+ search_tools: 'autonomous',
333
+ set_active_project: 'autonomous',
334
+ set_brand_logo: 'autonomous',
335
+ set_white_label_settings: 'autonomous',
336
+ setup_campaign_from_brief: 'autonomous',
337
+ snooze_ai_work_item: 'autonomous',
338
+ submit_ai_context_event: 'autonomous',
339
+ submit_support_ticket: 'autonomous',
340
+ subscriber_export: 'autonomous',
341
+ suggest_content_topics: 'autonomous',
342
+ suggest_send_time: 'autonomous',
343
+ translate_template: 'autonomous',
344
+ trigger_automation: 'autonomous',
345
+ unenroll_contact: 'autonomous',
346
+ unsubscribe_from_newsletter: 'autonomous',
347
+ unsubscribe_subscriber: 'autonomous',
348
+ update_brand_voice: 'autonomous',
349
+ update_custom_field: 'autonomous',
350
+ update_hygiene_config: 'autonomous',
351
+ update_list: 'autonomous',
352
+ update_newsletter: 'autonomous',
353
+ update_scoring_rules: 'autonomous',
354
+ update_segment: 'autonomous',
355
+ update_signature: 'autonomous',
356
+ update_site: 'autonomous',
357
+ update_social_post: 'autonomous',
358
+ update_subscriber: 'autonomous',
359
+ update_subscriber_view: 'autonomous',
360
+ update_survey: 'autonomous',
361
+ update_template: 'autonomous',
362
+ update_warmup_schedule: 'autonomous',
363
+ upload_brand_asset: 'autonomous',
364
+ validate_template: 'autonomous',
365
+ });
366
+ /** Undefined means unclassified, which every caller must treat as gated. */
367
+ export function credentialGateClass(tool) {
368
+ return Object.prototype.hasOwnProperty.call(CREDENTIAL_GATE_TABLE, tool) ? CREDENTIAL_GATE_TABLE[tool] : undefined;
369
+ }
370
+ /**
371
+ * Param forms of autonomous/draft_only tester setup tools that still need a person. Each returns a
372
+ * reason when gated. A value that is not exactly the safe value is gated (fail closed).
373
+ */
374
+ export const TESTER_PARAM_GATES = Object.freeze({
375
+ // Safety carve-out: turning either unsubscribe flag off removes the unsubscribe link from live mail.
376
+ update_site: (params) => (params.unsub_link_enabled !== undefined && params.unsub_link_enabled !== true)
377
+ || (params.unsub_one_click_enabled !== undefined && params.unsub_one_click_enabled !== true)
378
+ ? 'unsubscribe_link_disabled' : null,
379
+ // Turning off the activation approval flag weakens a later send gate.
380
+ create_campaign: (params) => params.requires_approval === false ? 'activation_approval_off'
381
+ : params.send_at !== undefined && params.send_at !== null ? 'schedules_send' : null,
382
+ // activate=true creates and turns on sending in one call.
383
+ create_campaign_with_content: (params) => params.activate !== false ? 'activates_campaign' : null,
384
+ // Scheduling a send is a send decision even on a draft.
385
+ update_campaign: (params) => params.send_at !== undefined && params.send_at !== null ? 'schedules_send' : null,
386
+ });
@@ -0,0 +1,22 @@
1
+ /** Why a tester setup call still needs a person. `null` reason means the policy allowed it. */
2
+ export type TesterGateDecision = {
3
+ tier: 'autonomous';
4
+ reason: null;
5
+ } | {
6
+ tier: 'gated';
7
+ reason: string;
8
+ };
9
+ /**
10
+ * A campaign is a draft for #4853 only when every read succeeds and shows: status draft, no
11
+ * schedule or recurrence, zero enrollment rows and zero send_queue rows. There is no
12
+ * activated-at column, so "never activated" is inferred from enrollments and queued sends.
13
+ * Any error, missing row or unknown count returns false (gated).
14
+ */
15
+ export declare function campaignIsDraft(campaignId: unknown): Promise<boolean>;
16
+ /**
17
+ * #4853: decide whether one tester setup call needs a person. Autonomous only when ALL hold:
18
+ * the table classifies the tool autonomous/draft_only, no TESTER_PARAM_GATES rule fires, the
19
+ * project reads as NOT a sandbox (sandbox projects keep their existing auto-approve path
20
+ * unchanged) and, for draft_only tools, the target campaign reads as a draft.
21
+ */
22
+ export declare function resolveTesterGate(action: string, params: Record<string, unknown>): Promise<TesterGateDecision>;
@@ -0,0 +1,82 @@
1
+ import { getProjectId } from '../project.js';
2
+ import { credentialGateClass, TESTER_PARAM_GATES } from './credential-gate-table.js';
3
+ import { sandboxAutoApproveUnavailableReason } from './sandbox-approvals.js';
4
+ import { approvalStore } from './tester-requests-store.js';
5
+ const gated = (reason) => ({ tier: 'gated', reason });
6
+ /**
7
+ * A campaign is a draft for #4853 only when every read succeeds and shows: status draft, no
8
+ * schedule or recurrence, zero enrollment rows and zero send_queue rows. There is no
9
+ * activated-at column, so "never activated" is inferred from enrollments and queued sends.
10
+ * Any error, missing row or unknown count returns false (gated).
11
+ */
12
+ export async function campaignIsDraft(campaignId) {
13
+ if (typeof campaignId !== 'string' || !campaignId)
14
+ return false;
15
+ try {
16
+ const store = approvalStore();
17
+ const projectId = getProjectId();
18
+ const { data, error } = await store.from('campaigns')
19
+ .select('id, project_id, status, send_at, recurrence_rule, recurrence_parent_id')
20
+ .eq('id', campaignId).eq('project_id', projectId).maybeSingle();
21
+ if (error || !data || data.id !== campaignId || data.project_id !== projectId)
22
+ return false;
23
+ if (data.status !== 'draft' || data.send_at != null || data.recurrence_rule != null || data.recurrence_parent_id != null)
24
+ return false;
25
+ const enrollments = await store.from('campaign_enrollments')
26
+ .select('id', { count: 'exact', head: true }).eq('campaign_id', campaignId);
27
+ if (enrollments.error || typeof enrollments.count !== 'number' || enrollments.count !== 0)
28
+ return false;
29
+ const queued = await store.from('send_queue')
30
+ .select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('campaign_id', campaignId);
31
+ if (queued.error || typeof queued.count !== 'number' || queued.count !== 0)
32
+ return false;
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** update_campaign_step names a step, never a campaign: resolve it in this project first. */
40
+ async function stepCampaignId(stepId) {
41
+ if (typeof stepId !== 'string' || !stepId)
42
+ return undefined;
43
+ try {
44
+ const { data, error } = await approvalStore().from('campaign_steps')
45
+ .select('id, campaign_id').eq('id', stepId).maybeSingle();
46
+ if (error || !data || data.id !== stepId || typeof data.campaign_id !== 'string')
47
+ return undefined;
48
+ return data.campaign_id;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * #4853: decide whether one tester setup call needs a person. Autonomous only when ALL hold:
56
+ * the table classifies the tool autonomous/draft_only, no TESTER_PARAM_GATES rule fires, the
57
+ * project reads as NOT a sandbox (sandbox projects keep their existing auto-approve path
58
+ * unchanged) and, for draft_only tools, the target campaign reads as a draft.
59
+ */
60
+ export async function resolveTesterGate(action, params) {
61
+ const gate = credentialGateClass(action);
62
+ if (gate !== 'autonomous' && gate !== 'draft_only')
63
+ return gated(gate ? `table_${gate}` : 'unclassified');
64
+ const paramReason = TESTER_PARAM_GATES[action]?.(params);
65
+ if (paramReason)
66
+ return gated(paramReason);
67
+ let sandbox;
68
+ try {
69
+ sandbox = await sandboxAutoApproveUnavailableReason(getProjectId(), action);
70
+ }
71
+ catch {
72
+ return gated('project_unreadable');
73
+ }
74
+ if (sandbox !== 'not_sandbox')
75
+ return gated(sandbox === null ? 'sandbox_project' : 'project_unreadable');
76
+ if (gate === 'draft_only') {
77
+ const campaignId = action === 'update_campaign_step' ? await stepCampaignId(params.step_id) : params.campaign_id;
78
+ if (!await campaignIsDraft(campaignId))
79
+ return gated('campaign_not_draft_or_unreadable');
80
+ }
81
+ return { tier: 'autonomous', reason: null };
82
+ }
@@ -4,3 +4,8 @@ export declare const TESTER_SITE_FIELDS: ReadonlySet<string>;
4
4
  export declare function isTesterRequest(action: string): boolean;
5
5
  /** Server-only context set after the shared core has consumed a human-approved payload. */
6
6
  export declare function isApplyingTesterRequest(action: string): boolean;
7
+ /**
8
+ * Server-only context set after the #4853 gate table allowed this tester call without a person
9
+ * (a draft, never a send or delete). Model input cannot set it; see resolveTesterGate.
10
+ */
11
+ export declare function isPolicyApplyingTesterRequest(action: string): boolean;
@@ -16,3 +16,10 @@ export function isTesterRequest(action) {
16
16
  export function isApplyingTesterRequest(action) {
17
17
  return isTesterRequest(action) && requestContext()?.approvedTesterAction === action;
18
18
  }
19
+ /**
20
+ * Server-only context set after the #4853 gate table allowed this tester call without a person
21
+ * (a draft, never a send or delete). Model input cannot set it; see resolveTesterGate.
22
+ */
23
+ export function isPolicyApplyingTesterRequest(action) {
24
+ return isTesterRequest(action) && requestContext()?.policyTesterAction === action;
25
+ }
@@ -1,2 +1,2 @@
1
1
  /** Central audit infrastructure, including when the edited site belongs to a BYOD project. */
2
- export declare function logTesterRequestAudit(event: 'tester_request' | 'tester_applied', action: string, approvalId: string, approver: string | null): Promise<void>;
2
+ export declare function logTesterRequestAudit(event: 'tester_request' | 'tester_applied', action: string, approvalId: string | null, approver: string | null, extra?: Record<string, unknown>): Promise<void>;
@@ -2,13 +2,13 @@ import { requestContext } from '../db.js';
2
2
  import { getProjectId } from '../project.js';
3
3
  import { approvalStore } from './tester-requests-store.js';
4
4
  /** Central audit infrastructure, including when the edited site belongs to a BYOD project. */
5
- export async function logTesterRequestAudit(event, action, approvalId, approver) {
5
+ export async function logTesterRequestAudit(event, action, approvalId, approver, extra = {}) {
6
6
  const ctx = requestContext();
7
7
  const { error } = await approvalStore().from('audit_log').insert({
8
8
  project_id: getProjectId(), actor_type: ctx?.actorType ?? 'mcp', actor_id: ctx?.actorId ?? null,
9
9
  action: `mcp_connection.${event}`, resource_type: 'mcp_connection', resource_id: ctx?.oauthTokenId,
10
10
  channel: ctx?.channel ?? 'mcp',
11
- details: { connection_id: ctx?.oauthTokenId, approval_id: approvalId, tool: action, approver },
11
+ details: { connection_id: ctx?.oauthTokenId, approval_id: approvalId, tool: action, approver, ...extra },
12
12
  });
13
13
  if (error)
14
14
  throw new Error(`Failed to audit tester ${event}: ${error.message}`);
@@ -1,13 +1,41 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { z } from 'zod';
3
- import { requestContext, runWithApprovedTesterRequest } from '../db.js';
3
+ import { requestContext, runWithApprovedTesterRequest, runWithPolicyTesterRequest } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
- import { withApproval } from './action-approvals.js';
5
+ import { assertResourcesBelongToActiveProject, currentChannelContext, ResourceProjectRefusal, withApproval } from './action-approvals.js';
6
+ import { plainRefusalResult } from './db-error.js';
6
7
  import { withoutApprovalSnapshot } from './approval-snapshot.js';
7
8
  import { approvalStore } from './tester-requests-store.js';
8
9
  import { logTesterRequestAudit } from './tester-request-audit.js';
9
10
  import { TESTER_SITE_FIELDS } from './tester-policy.js';
11
+ import { resolveTesterGate } from './tester-policy-gate.js';
12
+ import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
10
13
  const text = (message) => ({ content: [{ type: 'text', text: message }] });
14
+ function appliedOk(result) {
15
+ if (result?.isError)
16
+ return false;
17
+ try {
18
+ const value = JSON.parse(result.content.filter((block) => block.type === 'text')
19
+ .map((block) => block.text).join('\n'));
20
+ // A saved campaign also has a requires_approval column: only an approval response carries approval_id.
21
+ const approvalResponse = value.requires_approval === true && typeof value.approval_id === 'string';
22
+ return !value.error && !approvalResponse && !!(value.id || value.campaign_id || value.ok === true);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /**
29
+ * #4853 policy path: no approval row, but every successful write still writes a tester_applied
30
+ * audit row whose approver is the policy, never a person. An audit failure surfaces as an error.
31
+ */
32
+ async function applyByPolicy(action, params, run) {
33
+ const result = await runWithPolicyTesterRequest(action, run);
34
+ if (appliedOk(result)) {
35
+ await logTesterRequestAudit('tester_applied', action, null, 'policy', { policy: 'credential_gate_table' });
36
+ }
37
+ return result;
38
+ }
11
39
  /** Only installed for an authenticated tester connection, never selected by tool arguments. */
12
40
  export function testerRequestTool(action, config, handler) {
13
41
  const original = config.inputSchema instanceof z.ZodObject ? config.inputSchema : z.object(config.inputSchema);
@@ -39,13 +67,35 @@ export function testerRequestTool(action, config, handler) {
39
67
  return text(`Invalid request: ${parsed.error.message}`);
40
68
  params = parsed.data;
41
69
  delete params.approval_id;
42
- // This human approval replaces the compound tool's model confirmation preview.
43
- if (action === 'create_campaign_with_content')
44
- params.confirmed = true;
45
70
  }
46
71
  if (action === 'update_site' && Object.keys(params).some(key => key !== 'site_id' && params[key] !== undefined && !TESTER_SITE_FIELDS.has(key))) {
47
72
  return text('Tester connections may request only site footer and unsubscribe settings.');
48
73
  }
74
+ if (!approval_id) {
75
+ // #4853: drafts and config apply without a person on non-sandbox projects; anything else
76
+ // (sends, live campaigns, unsubscribe-off, unreadable state, sandbox) keeps the approval flow.
77
+ // An unverified channel never skips a person; requestApproval then refuses it as before.
78
+ const gate = currentChannelContext().verified ? await resolveTesterGate(action, params) : { tier: 'gated' };
79
+ // The approval path's target check (site, segment, campaign, step in THIS project). If any
80
+ // target cannot be read here, fall back to the approval path, which refuses it as before.
81
+ const targetsReadable = gate.tier === 'autonomous'
82
+ && await captureTesterRequestSnapshot(action, params, getProjectId()).then(() => true, () => false);
83
+ if (targetsReadable) {
84
+ // #4447: the same one-sentence ownership refusal requestApproval gives, before any write.
85
+ try {
86
+ await assertResourcesBelongToActiveProject(action, params);
87
+ }
88
+ catch (error) {
89
+ if (error instanceof ResourceProjectRefusal)
90
+ return plainRefusalResult(error.message);
91
+ throw error;
92
+ }
93
+ return applyByPolicy(action, params, () => handler(params, extra));
94
+ }
95
+ // This human approval replaces the compound tool's model confirmation preview.
96
+ if (action === 'create_campaign_with_content')
97
+ params.confirmed = true;
98
+ }
49
99
  return withApproval(action, async (frozen) => {
50
100
  const result = await runWithApprovedTesterRequest(action, () => handler(frozen, extra));
51
101
  // All seven setup tools report a successful apply as a resource id or an explicit ok.
package/build/server.js CHANGED
@@ -207,7 +207,7 @@ export function isProjectPinnedWriteTool(name, scopes) {
207
207
  return (isTesterConnection(scopes) && TESTER_REQUEST_TOOLS.has(name)) || name === 'send_broadcast' || name === 'activate_campaigns' || isApprovalGatedAction(name);
208
208
  }
209
209
  export function testerConnectionRefusal(name) {
210
- return `"${name}" is not available to a tester connection. Testers can read and request human approval for campaign setup: ${[...TESTER_REQUEST_TOOLS].join(', ')}. update_site is limited to footer/unsubscribe fields. Every setup call returns approval_id, approve_url and preview_text; after a person approves, retry with only approval_id. Deletes, sends, access/role/key/team tools, provider credentials, imports and create_agent_mailbox are refused. Ask the workspace owner to promote the connection in Dashboard > Agent Connect for direct writes.`;
210
+ return `"${name}" is not available to a tester connection. Testers can read and set up campaigns with: ${[...TESTER_REQUEST_TOOLS].join(', ')}. update_site is limited to footer/unsubscribe fields. Draft campaigns, steps, triggers on drafts and footer edits apply right away. Changes that can reach real people (a live or scheduled campaign, activate=true, turning the unsubscribe link off) return approval_id, approve_url and preview_text instead; after a person approves, retry with only approval_id. Deletes, sends, access/role/key/team tools, provider credentials, imports and create_agent_mailbox are refused. Ask the workspace owner to promote the connection in Dashboard > Agent Connect for direct writes.`;
211
211
  }
212
212
  // Names of the meta-tools gateway mode registers in place of the real catalog
213
213
  // (see tools/gateway.ts). Grouped as 'gateway' rather than any real tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.229",
3
+ "version": "1.0.230",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",