@sendinel/mcp-server 1.0.228 → 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 +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 +62 -8
- package/build/server.js +1 -1
- 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.
|
|
@@ -58,11 +108,15 @@ export function testerRequestTool(action, config, handler) {
|
|
|
58
108
|
return result;
|
|
59
109
|
}
|
|
60
110
|
if (!result.isError && !value.error && (value.id || value.campaign_id || value.ok === true)) {
|
|
61
|
-
const { data, error } = await approvalStore().from('action_approvals').select('decided_by')
|
|
111
|
+
const { data, error } = await approvalStore().from('action_approvals').select('decided_by, decided_via')
|
|
62
112
|
.eq('id', approval_id).eq('project_id', getProjectId()).maybeSingle();
|
|
63
|
-
|
|
113
|
+
// A sandbox auto-approval has no human approver by design (DB CHECK: decided_by IS NULL);
|
|
114
|
+
// checkApproval already re-verified its eligibility before the write. Every other
|
|
115
|
+
// provenance must still name the human who approved it.
|
|
116
|
+
const sandboxDecision = data?.decided_via === 'sandbox' && data.decided_by == null;
|
|
117
|
+
if (error || (!data?.decided_by && !sandboxDecision))
|
|
64
118
|
throw new Error('Applied tester request has no recorded approver');
|
|
65
|
-
await logTesterRequestAudit('tester_applied', action, String(approval_id), data.decided_by);
|
|
119
|
+
await logTesterRequestAudit('tester_applied', action, String(approval_id), sandboxDecision ? null : data.decided_by);
|
|
66
120
|
}
|
|
67
121
|
return result;
|
|
68
122
|
})({ ...params, approval_id: approval_id }, extra);
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
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",
|