@sendinel/mcp-server 1.0.229 → 1.0.231
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 +7 -0
- package/build/db.js +12 -0
- package/build/lib/action-approvals.js +14 -3
- package/build/lib/approval-human-eligibility.d.ts +1 -0
- package/build/lib/credential-gate-table.d.ts +34 -0
- package/build/lib/credential-gate-table.js +386 -0
- package/build/lib/tester-policy-gate.d.ts +22 -0
- package/build/lib/tester-policy-gate.js +82 -0
- package/build/lib/tester-policy.d.ts +5 -0
- package/build/lib/tester-policy.js +7 -0
- package/build/lib/tester-request-audit.d.ts +1 -1
- package/build/lib/tester-request-audit.js +2 -2
- package/build/lib/tester-requests.js +55 -5
- package/build/server.js +1 -1
- package/build/tools/analytics.js +32 -8
- package/build/tools/campaigns.js +9 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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/build/tools/analytics.js
CHANGED
|
@@ -198,6 +198,27 @@ function buildTrueConversionAnalytics(params) {
|
|
|
198
198
|
.sort((a, b) => b.count - a.count || b.revenue - a.revenue),
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* #4838: send allowlist visibility without exposing addresses. NULL = no restriction (inactive);
|
|
203
|
+
* an array (even empty) is active and recipients must match an exact address on it. Anything else
|
|
204
|
+
* (row or column not loaded) is unknown, reported as null, never as "inactive": the send gate fails
|
|
205
|
+
* closed on an unreadable list, so this must not claim there is none.
|
|
206
|
+
*/
|
|
207
|
+
function sendAllowlistSummary(list) {
|
|
208
|
+
if (Array.isArray(list))
|
|
209
|
+
return { active: true, count: list.length };
|
|
210
|
+
return list === null ? { active: false, count: null } : { active: null, count: null };
|
|
211
|
+
}
|
|
212
|
+
function sendAllowlistFields(projectList, orgList) {
|
|
213
|
+
const project = sendAllowlistSummary(projectList);
|
|
214
|
+
const org = sendAllowlistSummary(orgList);
|
|
215
|
+
return {
|
|
216
|
+
send_allowlist_active: project.active,
|
|
217
|
+
send_allowlist_count: project.count,
|
|
218
|
+
org_send_allowlist_active: org.active,
|
|
219
|
+
org_send_allowlist_count: org.count,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
201
222
|
export function registerAnalyticsTools(server) {
|
|
202
223
|
server.registerTool('list_event_log', {
|
|
203
224
|
title: 'List Event Log',
|
|
@@ -273,7 +294,7 @@ export function registerAnalyticsTools(server) {
|
|
|
273
294
|
idempotentHint: true,
|
|
274
295
|
openWorldHint: false,
|
|
275
296
|
},
|
|
276
|
-
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account.',
|
|
297
|
+
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account. Each project also shows its send limits: sandbox_allowed_domains (recipient domains allowed while sandbox_mode is on), send_allowlist_active/send_allowlist_count (project list of exact addresses) and org_send_allowlist_active/org_send_allowlist_count (organization list of exact addresses). A recipient must pass every active list; addresses are not shown. An active value of null means that list could not be read.',
|
|
277
298
|
inputSchema: {},
|
|
278
299
|
}, async () => {
|
|
279
300
|
const ctx = requestContext();
|
|
@@ -297,10 +318,10 @@ export function registerAnalyticsTools(server) {
|
|
|
297
318
|
const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
|
|
298
319
|
const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
|
|
299
320
|
directProjectIds.length
|
|
300
|
-
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('id', directProjectIds)
|
|
321
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('id', directProjectIds)
|
|
301
322
|
: Promise.resolve({ data: [], error: null }),
|
|
302
323
|
ownedOrgIds.length
|
|
303
|
-
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('org_id', ownedOrgIds)
|
|
324
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('org_id', ownedOrgIds)
|
|
304
325
|
: Promise.resolve({ data: [], error: null }),
|
|
305
326
|
]);
|
|
306
327
|
if (directProjectsError)
|
|
@@ -317,11 +338,12 @@ export function registerAnalyticsTools(server) {
|
|
|
317
338
|
}
|
|
318
339
|
const orgIds = Array.from(new Set(Array.from(byId.values()).map((project) => project.org_id).filter((id) => Boolean(id))));
|
|
319
340
|
const { data: organizations, error: organizationsError } = orgIds.length
|
|
320
|
-
? await adminDb().from('organizations').select('id, name').in('id', orgIds)
|
|
341
|
+
? await adminDb().from('organizations').select('id, name, send_allowlist').in('id', orgIds)
|
|
321
342
|
: { data: [], error: null };
|
|
322
343
|
if (organizationsError)
|
|
323
344
|
return txt(`Error: ${dbToolError(organizationsError).error}`);
|
|
324
345
|
const orgNames = new Map((organizations ?? []).map((org) => [org.id, org.name]));
|
|
346
|
+
const orgAllowlists = new Map((organizations ?? []).map((org) => [org.id, org.send_allowlist]));
|
|
325
347
|
return j({
|
|
326
348
|
projects: Array.from(byId.values())
|
|
327
349
|
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
|
|
@@ -332,6 +354,7 @@ export function registerAnalyticsTools(server) {
|
|
|
332
354
|
org_name: project.org_id ? orgNames.get(project.org_id) ?? null : null,
|
|
333
355
|
sandbox_mode: project.sandbox_mode,
|
|
334
356
|
sandbox_allowed_domains: project.sandbox_allowed_domains,
|
|
357
|
+
...sendAllowlistFields(project.send_allowlist, project.org_id ? orgAllowlists.get(project.org_id) : null),
|
|
335
358
|
active: project.id === ctx.projectId,
|
|
336
359
|
})),
|
|
337
360
|
});
|
|
@@ -341,19 +364,19 @@ export function registerAnalyticsTools(server) {
|
|
|
341
364
|
// Look up the org for this project, then list all projects in that org.
|
|
342
365
|
const { data: project, error: projErr } = await adminDb()
|
|
343
366
|
.from('projects')
|
|
344
|
-
.select('id, name, org_id, sandbox_mode, sandbox_allowed_domains')
|
|
367
|
+
.select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist')
|
|
345
368
|
.eq('id', projectId)
|
|
346
369
|
.maybeSingle();
|
|
347
370
|
if (projErr || !project)
|
|
348
371
|
return txt(`Error: could not find project ${projectId}`);
|
|
349
372
|
if (!project.org_id) {
|
|
350
373
|
// No org — return just this project
|
|
351
|
-
return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, current: true }] });
|
|
374
|
+
return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, ...sendAllowlistFields(project.send_allowlist, null), current: true }] });
|
|
352
375
|
}
|
|
353
376
|
// List all projects in the same organization
|
|
354
377
|
const { data: projects, error: listErr } = await adminDb()
|
|
355
378
|
.from('projects')
|
|
356
|
-
.select('id, name, sandbox_mode, sandbox_allowed_domains')
|
|
379
|
+
.select('id, name, sandbox_mode, sandbox_allowed_domains, send_allowlist')
|
|
357
380
|
.eq('org_id', project.org_id)
|
|
358
381
|
.order('name');
|
|
359
382
|
if (listErr)
|
|
@@ -361,7 +384,7 @@ export function registerAnalyticsTools(server) {
|
|
|
361
384
|
// Get org info
|
|
362
385
|
const { data: org } = await adminDb()
|
|
363
386
|
.from('organizations')
|
|
364
|
-
.select('id, name, plan')
|
|
387
|
+
.select('id, name, plan, send_allowlist')
|
|
365
388
|
.eq('id', project.org_id)
|
|
366
389
|
.maybeSingle();
|
|
367
390
|
return j({
|
|
@@ -371,6 +394,7 @@ export function registerAnalyticsTools(server) {
|
|
|
371
394
|
name: p.name,
|
|
372
395
|
sandbox_mode: p.sandbox_mode,
|
|
373
396
|
sandbox_allowed_domains: p.sandbox_allowed_domains,
|
|
397
|
+
...sendAllowlistFields(p.send_allowlist, org?.send_allowlist),
|
|
374
398
|
current: p.id === projectId,
|
|
375
399
|
})),
|
|
376
400
|
});
|
package/build/tools/campaigns.js
CHANGED
|
@@ -957,7 +957,7 @@ export function registerCampaignTools(server) {
|
|
|
957
957
|
idempotentHint: false,
|
|
958
958
|
openWorldHint: false,
|
|
959
959
|
},
|
|
960
|
-
description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval.',
|
|
960
|
+
description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval. Optional timezone (IANA name, e.g. America/New_York) sets the campaign time zone at create time; when omitted the database column default applies, unchanged from before.',
|
|
961
961
|
inputSchema: {
|
|
962
962
|
...reentryFields,
|
|
963
963
|
site_id: z.string().uuid(),
|
|
@@ -973,8 +973,12 @@ export function registerCampaignTools(server) {
|
|
|
973
973
|
segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
|
|
974
974
|
send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. Activation via activate_campaigns requires human approval and enrolls nobody. To schedule a one-off segment broadcast use send_broadcast with segment_id and send_at.'),
|
|
975
975
|
send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
|
|
976
|
+
timezone: z.string().optional().describe('Optional IANA time zone for this campaign, e.g. America/New_York. Must be a valid IANA name. When omitted, the database column default applies (no change from earlier behavior).'),
|
|
976
977
|
},
|
|
977
|
-
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key }) => {
|
|
978
|
+
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key, timezone }) => {
|
|
979
|
+
if (timezone !== undefined && !validTimezone(timezone)) {
|
|
980
|
+
return j({ error: `timezone must be a valid IANA time zone name (for example America/New_York); got "${timezone}"`, code: 'validation_failed' });
|
|
981
|
+
}
|
|
978
982
|
const limit = await checkPlanLimit('active_campaigns');
|
|
979
983
|
if (!limit.allowed) {
|
|
980
984
|
return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
|
|
@@ -989,6 +993,8 @@ export function registerCampaignTools(server) {
|
|
|
989
993
|
sends_per_hour: sends_per_hour ?? null,
|
|
990
994
|
requires_approval,
|
|
991
995
|
...(send_class ? { send_class } : {}),
|
|
996
|
+
// Omitted: no key at all, so the column default applies exactly as before (#4838 item 9).
|
|
997
|
+
...(timezone !== undefined ? { timezone } : {}),
|
|
992
998
|
};
|
|
993
999
|
const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
|
|
994
1000
|
if (!reentry.ok)
|
|
@@ -2723,7 +2729,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2723
2729
|
idempotentHint: false,
|
|
2724
2730
|
openWorldHint: true,
|
|
2725
2731
|
},
|
|
2726
|
-
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
|
|
2732
|
+
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics. The recipient must pass every active send limit: while sandbox_mode is on, its domain must be in sandbox_allowed_domains; if the project send allowlist is active, the exact address must be on it; if the organization send allowlist is active, the exact address must be on that too. Being on sandbox_allowed_domains alone is not enough when an exact-address list is active. list_projects shows which lists are active (send_allowlist_active, org_send_allowlist_active). A refusal names the list that refused (send_allowlist_refused or sandbox_refused).',
|
|
2727
2733
|
inputSchema: {
|
|
2728
2734
|
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 only approval_id to run the action.'),
|
|
2729
2735
|
to_email: z.string().email().describe('Email address to send the test to'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.231",
|
|
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",
|